diff --git a/.changeset/adr-0087-completion.md b/.changeset/adr-0087-completion.md new file mode 100644 index 0000000000..7026478a9f --- /dev/null +++ b/.changeset/adr-0087-completion.md @@ -0,0 +1,37 @@ +--- +'@objectstack/spec': minor +'@objectstack/cli': minor +'@objectstack/runtime': minor +'@objectstack/service-package': minor +'create-objectstack': minor +--- + +feat(protocol): complete ADR-0087 — load-seam handshake, chain backfill 12–15, release artifacts (#2643) + +Closes the remaining ADR-0087 gaps (see the ADR's as-built Addendum): + +- **P0 load seams (D1).** The protocol handshake now runs on the boot-time + durable-package rehydration path (`@objectstack/service-package` refuses an + incompatible `sys_packages` row with the structured `OS_PROTOCOL_INCOMPATIBLE` + diagnostic and keeps booting) and on `AppPlugin` for code-defined stacks + (fail-fast before the manifest is decomposed). `objectstack lint` gains + `protocol/missing-engines-range` (warning + fix-it) and the + `create-objectstack` blank template stamps `engines: { protocol: '^' }` + (re-stamped at version time by `scripts/sync-template-versions.mjs`) — the + two ends of the grandfathering ratchet. +- **Chain backfill (D2/D3).** `MetadataConversion.retiredFromLoadPath` + implements the load-window's second half (retired entries replay only via + `migrate meta` / fixture CI). Steps 12–15 land: the `api.requireAuth` flip + (semantic), the ADR-0090 wave (3 retired conversions + 5 semantic TODOs), the + `BookAudience` rename (retired conversion), and the ADR-0089 visibility + unification (`visibleOn`/`visibility` → `visibleWhen` as LIVE load-window + conversions) + the `.strict()` flip (semantic). The protocol-11 + `compactLayout` → `highlightFields` rename is backfilled as a retired step-11 + conversion. `migrate meta --from 10` now reaches protocol 15. +- **Release artifacts (D4).** `spec-changes.json` is generated from the + registries (`gen:spec-changes`, CI drift-checked), ships in the npm artifact + together with `api-surface.json`, and is attached to each `@objectstack/spec` + GitHub Release with `added[]`/`removed[]` filled from the api-surface diff + against the previously published release. The upgrade guide + (`docs/protocol-upgrade-guide.md`) is generated from the same registries and + CI drift-checked — a projection that cannot drift. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dd1ba3b80d..a0844001db 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -389,3 +389,9 @@ jobs: - name: Check skill docs are generated from SKILL.md frontmatter run: pnpm --filter @objectstack/spec check:skill-docs + + - name: Check spec-changes.json is regenerated with the ADR-0087 registries + run: pnpm --filter @objectstack/spec check:spec-changes + + - name: Check the protocol upgrade guide is regenerated with the ADR-0087 registries + run: pnpm --filter @objectstack/spec check:upgrade-guide diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9194130807..b5415e05d5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -98,6 +98,17 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + - name: Attach spec-changes.json to the GitHub Release (ADR-0087 D4) + # Rebuilds the change manifest with the api-surface diff against the + # previously PUBLISHED spec (reusing the ADR-0059 §3 gate artifact) and + # uploads it to the @objectstack/spec release the changesets action + # just created. The npm artifact carries the registry-derived copy. + if: steps.changesets.outputs.published == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISHED: ${{ steps.changesets.outputs.publishedPackages }} + run: bash scripts/release-spec-changes.sh + - name: Extract published @objectstack/cli version id: cli-version if: steps.changesets.outputs.published == 'true' diff --git a/docs/adr/0087-metadata-protocol-upgrade-contract.md b/docs/adr/0087-metadata-protocol-upgrade-contract.md index 351a404b34..e103484278 100644 --- a/docs/adr/0087-metadata-protocol-upgrade-contract.md +++ b/docs/adr/0087-metadata-protocol-upgrade-contract.md @@ -1,6 +1,6 @@ # ADR-0087: Metadata protocol upgrades for AI consumers — conversion over notification, executable migrations, machine-verifiable upgrades -**Status**: Proposed (2026-07-04) +**Status**: Accepted (2026-07-04, #2582) · trued up to as-built 2026-07-15 (see Addendum) **Deciders**: ObjectStack Protocol Architects **Builds on**: [ADR-0059](./0059-third-party-backward-compatibility-gates.md) (layered backward-compat gates — this ADR is its consumer-facing sequel), [ADR-0078](./0078-no-silently-inert-metadata.md) (no declarable-but-unenforced metadata — the un-checked `engines.protocol` is exactly this class), [ADR-0025](./0025-plugin-package-distribution.md) (§3.2 `engines.protocol` / `engines.platform` compatibility ranges, §3.10 #3 protocol-first check order), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) (the authoring population this ADR designs for), [ADR-0049](./0049-no-unenforced-security-properties.md) (enforce-or-remove), [ADR-0054](./0054-runtime-proof-for-authorable-surface.md) (prove-it-runs), AGENTS.md Prime Directive #12 (contract-first, no consumer-side dialect fallbacks — §"Why the conversion layer does not violate PD #12" draws the line) **Consumers**: `@objectstack/spec` (protocol version constant, conversion layer, deprecation/change registries), `@objectstack/cli` (`validate`, `doctor`, `migrate meta`), the runtime metadata loader (handshake + conversion), `@objectstack/mcp` (the AI-native change/migration surface), `@objectstack/create-objectstack`, the Release workflow, and every third-party consumer — whose maintainer is assumed to be an **AI agent** @@ -312,3 +312,72 @@ scaling framework-side execution when the consumers can execute themselves. consumer still gets strictly more than the pre-ADR world (fail-fast, a generated guide, runnable migrations) — the bet has no downside for them, only a smaller upside. + +--- + +## Addendum (2026-07-15) — as-built true-up + +### Phase status + +- **P0 — handshake (D1): shipped.** `PROTOCOL_VERSION` + the pure handshake core + landed in #2650 (install seam). The **load seams** followed: the boot-time + durable-package rehydration path (`@objectstack/service-package`) refuses an + incompatible `sys_packages` row with the structured diagnostic and continues + booting; `AppPlugin` (code-defined stacks) fails fast before the manifest is + decomposed. The grandfathering ratchet is closed from both ends: + `objectstack lint` warns on a manifest with no range + (`protocol/missing-engines-range`), and the `create-objectstack` template + stamps `engines: { protocol: '^' }` (re-stamped at version time by + `scripts/sync-template-versions.mjs`). +- **P1 — conversion layer (D2): shipped** in #2897, seeded with the retroactive + protocol-11 table; the PD #12 retirement path was proven on the CRUD + `filters` fallback. +- **P2 — chain + manifest + guide (D3/D4): shipped.** #2897 landed the chain and + `composeSpecChanges`; this true-up adds the release side: `spec-changes.json` + is generated from the registries (`gen:spec-changes`, drift-checked in CI), + ships inside the npm artifact together with `api-surface.json`, and is + attached to each `@objectstack/spec` GitHub Release with the `added[]`/ + `removed[]` arrays filled from the api-surface diff against the previously + *published* release (`scripts/release-spec-changes.sh`). The upgrade guide is + now literally a projection: `docs/protocol-upgrade-guide.md` is generated + from the registries (`gen:upgrade-guide`) and drift-checked in CI. +- **P3 — MCP tools + RC discipline (D6): deferred, evidence-gated** (as the ADR + Boundaries intended): built when external-consumer demand justifies the + operational surface. Nothing else in this ADR depends on it. + +### The load-window's second half is now mechanical + +`MetadataConversion.retiredFromLoadPath` implements "retired from the load path +in N+1 — but never deleted": a retired entry is skipped by the loader +(`applyConversions`) and replayed only by the chain (`migrate meta`) and the +fixture CI. Live-window entries (currently the protocol-15 ADR-0089 visibility +aliases) stay load-active until they graduate. + +### Ratified: the pre-launch launch-window exemption (majors 12–15) + +Majors 12–14 shipped breaks as **pre-launch one-step changes with no alias +window** (ADR-0090 D3/D4 explicitly superseded the alias discipline; +`BookAudience` in 14.0.0 states "launch-window discipline"). That was a +deliberate policy while the platform had no external consumers — but it was +never written down, and it left the chain empty above step 11. This true-up +does both halves: + +- **The policy, stated:** until GA, a metadata-facing break MAY ship one-step + without a load window. The exemption covers the *window* only — never the + *chain*: every such break must land as a chain step (a `retiredFromLoadPath` + conversion when lossless, a semantic TODO when not) in the same release. + After GA the full D2 ladder applies: lossless breaks ship a live conversion + entry or they do not ship. +- **The chain, backfilled:** steps 12–15 now exist. 12: the `api.requireAuth` + default flip (semantic). 13: the ADR-0090 wave — `roles:`→`positions:`, the + two unambiguous OWD aliases, and recipient `role`→`position` as retired + conversions; profiles, hierarchy re-homing, `current_user.roles` CEL + rewrites, the `'full'` alias, and the sharing-model secure default as + semantic TODOs. 14: the `BookAudience` rename (retired conversion). 15: the + ADR-0089 visibility aliases (live conversions) plus the `.strict()` flip + (semantic). `migrate meta --from 10` therefore reaches protocol 15 with + every mechanical rewrite applied and every judgment surfaced — the "arrive + whenever" promise holds across the pre-launch era too. +- **Backfilled history joins the registry, not the loader:** the protocol-11 + `compactLayout`→`highlightFields` rename (retired at authoring in 11.9.1, + pre-dating this ADR) is also preserved as a retired step-11 conversion. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md new file mode 100644 index 0000000000..04c39114b3 --- /dev/null +++ b/docs/protocol-upgrade-guide.md @@ -0,0 +1,109 @@ + + + +# Metadata protocol upgrade guide + +Current protocol: **15.0.0** · chain support floor: **protocol 10** · generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`). + +## How to upgrade — from any past major + +```bash +objectstack migrate meta --from # replays every step below, in order +objectstack migrate meta --from 10 --step # checkpoint after each major (bisect a failure) +objectstack validate && tsc --noEmit && # your own verify loop is the acceptance test +``` + +Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed with acceptance criteria and are yours to resolve — the chain never auto-applies a change that requires judgment. Arriving several majors late is the designed-for case: timeliness is never load-bearing (ADR-0087). + +## Protocol 10 → 11 + +Protocol 11 unified the divergent HTTP callout node types to `http`, made `html` the canonical page kind (deprecating the `jsx` alias), canonicalized the CRUD flow-node filter key, and renamed object `compactLayout` to `highlightFields` (ADR-0085). These are mechanical and replay losslessly. Two related deprecations are semantic and cannot be auto-applied: a composite `titleFormat` render template has no single canonical `nameField`, and SQL-ish RLS predicates must be rewritten to canonical CEL — both are delegated to the consumer with explicit acceptance criteria. + +### Mechanical (applied for you) + +| Conversion | Surface | Change | Load window | +|---|---|---|---| +| `flow-node-http-callout-rename` | `flow.node.type` | flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http' | live — protocol 11 loader accepts the old shape | +| `page-kind-jsx-to-html` | `page.kind` | page kind 'jsx' → 'html' (ADR-0080 canonical spelling) | live — protocol 11 loader accepts the old shape | +| `flow-node-crud-filter-alias` | `flow.node.config.filter` | CRUD flow-node config key 'filters' → 'filter' | live — protocol 11 loader accepts the old shape | +| `object-compactLayout-to-highlightFields` | `object.compactLayout` | object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles) | retired — `migrate meta` only | + +### Semantic (delegated to you, with acceptance criteria) + +- **`object-titleFormat-to-nameField`** — `object.titleFormat` → object.nameField + - Why not automatic: A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make. + - Done when: Each object with a `titleFormat` declares a `nameField`; a composite title is backed by a formula field. `objectstack validate` passes and record display names render identically to before. +- **`rls-sql-predicate-to-cel`** — `security.rls.predicate` → CEL predicate + - Why not automatic: SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain. + - Done when: Every RLS predicate parses as CEL and `objectstack validate` reports no expression errors; row visibility is unchanged for a representative fixture set. + +## Protocol 11 → 12 + +Protocol 12 flipped the REST data-API default to authenticated (`api.requireAuth: true`, ADR-0056 D2). No metadata shape changed, so there is nothing to rewrite mechanically; a deployment that intentionally serves data anonymously must now declare that posture explicitly. + +### Semantic (delegated to you, with acceptance criteria) + +- **`rest-requireauth-default-flip`** — `api.requireAuth` → explicit `api: { requireAuth: false }` (intentionally-public deployments only) + - Why not automatic: The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make. + - Done when: A deployment that relies on anonymous data access declares `api: { requireAuth: false }` on the stack config (and accepts the boot warning); every other consumer verifies its clients authenticate. `objectstack validate` and the consumer test suite pass. + +## Protocol 12 → 13 + +Protocol 13 (ADR-0090 P1) converged the permission model: Role became Position (flat; hierarchy lives on the business-unit tree), the Profile concept was removed, the OWD enum shrank to its canonical four values, and a custom object with an owner field and no `sharingModel` now defaults to `private` instead of public. Key renames replay mechanically; everything that changes *meaning* (profile → position/permission-set design, hierarchy re-homing, CEL identifier rewrites, sharing postures) is delegated with acceptance criteria. + +### Mechanical (applied for you) + +| Conversion | Surface | Change | Load window | +|---|---|---|---| +| `stack-roles-to-positions` | `stack.roles` | stack collection key 'roles' → 'positions' (ADR-0090 D3) | retired — `migrate meta` only | +| `owd-legacy-read-aliases` | `object.sharingModel` | object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4) | retired — `migrate meta` only | +| `sharing-recipient-role-to-position` | `sharingRule.sharedWith.type` | sharing-rule recipient type 'role' → 'position' (ADR-0090 D3) | retired — `migrate meta` only | + +### Semantic (delegated to you, with acceptance criteria) + +- **`permission-set-profile-removed`** — `permissionSet.kind / permissionSet.isProfile` → position-based assignment + permission-set grants (ADR-0090 D2) + - Why not automatic: The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename. + - Done when: No permission set declares `isProfile` or kind `profile`; the intended assignees hold equivalent grants via positions/permission sets. The access matrix (`os compile` access-matrix gate, where enabled) is reviewed and `objectstack validate` passes. +- **`position-hierarchy-flattened`** — `position.parent / sharingRule recipient role_and_subordinates` → business-unit tree + `unit_and_subordinates` (ADR-0090 D3) + - Why not automatic: Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call. + - Done when: No position declares `parent`; former `role_and_subordinates` rules are re-expressed with `unit_and_subordinates` over an equivalent business-unit tree. Row visibility is unchanged for a representative fixture set. +- **`cel-current-user-roles-to-positions`** — `CEL/formula: current_user.roles` → current_user.positions + - Why not automatic: The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author. + - Done when: No expression references `current_user.roles`; formula validation and `objectstack validate` report no unknown-identifier errors; predicate behavior is unchanged for representative users. +- **`owd-full-alias-removed`** — `object.sharingModel: 'full'` → 'public_read_write' or explicit sharing rules + - Why not automatic: The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision. + - Done when: No object declares sharingModel 'full'; the chosen replacement posture is verified against the intended access (who can read/write/delete) for a representative fixture set. +- **`sharing-model-secure-default`** — `object.sharingModel (absent, custom object with owner field)` → an explicit `sharingModel` declaration + - Why not automatic: ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data. + - Done when: Every custom object that relied on the implicit public posture declares an explicit `sharingModel`; row visibility is verified for a representative fixture set (owners, non-owners, admins). + +## Protocol 13 → 14 + +Protocol 14 renamed the book audience gated arm from `{ profile }` to `{ permissionSet }` (packages own permission sets, never positions — ADR-0090 D9). A pure key rename, preserved as a retired conversion; there is no semantic residue. + +### Mechanical (applied for you) + +| Conversion | Surface | Change | Load window | +|---|---|---|---| +| `book-audience-profile-to-permission-set` | `book.audience` | book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9) | retired — `migrate meta` only | + +## Protocol 14 → 15 + +Protocol 15 unified the conditional-visibility predicate under `visibleWhen` (ADR-0089): view-form `visibleOn` and page-component `visibility` are deprecated aliases, accepted and converted at load for this major. It also flipped `FormFieldSchema`, `FormSectionSchema`, and `PageComponentSchema` to `.strict()` — a key those schemas do not declare is now a loud parse error instead of a silent strip (ADR-0049/0078). + +### Mechanical (applied for you) + +| Conversion | Surface | Change | Load window | +|---|---|---|---| +| `view-visibleOn-to-visibleWhen` | `view.form.visibleOn` | view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089) | live — protocol 15 loader accepts the old shape | +| `page-component-visibility-to-visibleWhen` | `page.component.visibility` | page component key 'visibility' → 'visibleWhen' (ADR-0089) | live — protocol 15 loader accepts the old shape | + +### Semantic (delegated to you, with acceptance criteria) + +- **`ui-schemas-strict-unknown-keys`** — `view form fields/sections · page components (undeclared keys)` → declared keys only (`visibleWhen` for visibility predicates) + - Why not automatic: The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata. + - Done when: `objectstack validate` passes with no unknown-key parse errors on form fields, form sections, or page components. + +--- + +*Machine-readable equivalents: `spec-changes.json` (shipped in `@objectstack/spec` and attached to each GitHub Release) and the structured output of `objectstack migrate meta --json`.* diff --git a/packages/cli/src/commands/lint.ts b/packages/cli/src/commands/lint.ts index 5e61478f31..d2701ce491 100644 --- a/packages/cli/src/commands/lint.ts +++ b/packages/cli/src/commands/lint.ts @@ -4,6 +4,7 @@ import { Args, Command, Flags } from '@oclif/core'; import chalk from 'chalk'; import { bundleRequire } from 'bundle-require'; import { normalizeStackInput } from '@objectstack/spec'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { loadConfig, BUNDLE_REQUIRE_EXTERNALS } from '../utils/config.js'; import { computeI18nCoverage } from '../utils/i18n-coverage.js'; import { lintDataModel } from '../lint/data-model-rules.js'; @@ -282,6 +283,33 @@ export function lintConfig(config: any): LintIssue[] { } } + // ── Protocol compatibility range (ADR-0087 D1) ── + // The `engines.protocol` handshake is only as good as its coverage: a package + // that declares no range is grandfathered at install/load (warn-only), so the + // mismatch it would have caught surfaces as a deep crash instead. This nudge + // is the ratchet that closes grandfathering — scaffolds stamp the range for + // new packages; lint flags existing ones that never declared it. + // Scoped to configs that declare a manifest — a bare metadata fragment (no + // package identity) has nowhere to hang an engines range. + { + const manifest = config.manifest as Record | undefined; + const hasRange = + typeof manifest?.engines?.protocol === 'string' || + typeof manifest?.engines?.platform === 'string' || + typeof manifest?.engine?.objectstack === 'string'; + if (manifest && !hasRange) { + issues.push({ + severity: 'warning', + rule: 'protocol/missing-engines-range', + message: + 'Package declares no `engines.protocol` range — a protocol-incompatible runtime ' + + 'cannot refuse it at the boundary (ADR-0087 D1) and it loads unchecked (grandfathered).', + path: 'manifest.engines.protocol', + fix: `engines: { protocol: '^${PROTOCOL_MAJOR}' }`, + }); + } + } + // ── Data-model best practices (relationships / master-detail / roll-ups) ── // Cross-object rules that encode the conventions in ADR-0035 and the // objectstack-data/-ui skills. These double as the eval rubric (see score.ts). diff --git a/packages/cli/src/lint/corpus.ts b/packages/cli/src/lint/corpus.ts index 150457a3fa..993ff1586f 100644 --- a/packages/cli/src/lint/corpus.ts +++ b/packages/cli/src/lint/corpus.ts @@ -15,6 +15,7 @@ * worked examples of the conventions the AI generator should target. */ +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import type { MetadataEvalCase } from './metadata-eval.js'; const manifest = (id: string, namespace: string, name: string) => ({ @@ -23,6 +24,9 @@ const manifest = (id: string, namespace: string, name: string) => ({ version: '1.0.0', name, type: 'app' as const, + // The golden corpus models best practice: declare the protocol range so the + // ADR-0087 handshake can refuse an incompatible runtime at the boundary. + engines: { protocol: `^${PROTOCOL_MAJOR}` }, }); export const DEFAULT_METADATA_EVAL_CORPUS: MetadataEvalCase[] = [ diff --git a/packages/cli/test/lint-protocol-range.test.ts b/packages/cli/test/lint-protocol-range.test.ts new file mode 100644 index 0000000000..4df63a06ca --- /dev/null +++ b/packages/cli/test/lint-protocol-range.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it } from 'vitest'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; +import { lintConfig } from '../src/commands/lint'; + +const RULE = 'protocol/missing-engines-range'; +const protocolIssues = (config: any) => lintConfig(config).filter((i) => i.rule === RULE); + +/** + * ADR-0087 D1 — `objectstack lint` nudges a package with no `engines.protocol` + * range to declare one (the ratchet that closes handshake grandfathering). + */ +describe('lint protocol/missing-engines-range', () => { + it('warns when a manifest declares no compatibility range', () => { + const issues = protocolIssues({ + manifest: { id: 'demo', namespace: 'demo', version: '1.0.0', name: 'Demo', type: 'app' }, + }); + expect(issues).toHaveLength(1); + expect(issues[0]!.severity).toBe('warning'); + expect(issues[0]!.fix).toBe(`engines: { protocol: '^${PROTOCOL_MAJOR}' }`); + }); + + it('accepts engines.protocol', () => { + expect( + protocolIssues({ + manifest: { id: 'demo', engines: { protocol: `^${PROTOCOL_MAJOR}` } }, + }), + ).toEqual([]); + }); + + it('accepts the engines.platform and legacy engine.objectstack fallbacks', () => { + expect(protocolIssues({ manifest: { id: 'a', engines: { platform: '>=15' } } })).toEqual([]); + expect(protocolIssues({ manifest: { id: 'b', engine: { objectstack: '^15.0.0' } } })).toEqual([]); + }); + + it('stays silent for a bare metadata fragment with no manifest', () => { + expect(protocolIssues({ objects: [{ name: 'x', label: 'X', fields: { a: { label: 'A' } } }] })).toEqual([]); + }); +}); diff --git a/packages/cli/test/score.test.ts b/packages/cli/test/score.test.ts index aed92c1a8c..0d85858ddd 100644 --- a/packages/cli/test/score.test.ts +++ b/packages/cli/test/score.test.ts @@ -1,9 +1,10 @@ import { describe, expect, it } from 'vitest'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; import { scoreMetadata } from '../src/lint/score'; /** A clean, convention-following invoice + line-item model. */ const GOOD_STACK = { - manifest: { id: 'demo', namespace: 'demo_app', version: '1.0.0', name: 'Demo', type: 'app' as const }, + manifest: { id: 'demo', namespace: 'demo_app', version: '1.0.0', name: 'Demo', type: 'app' as const, engines: { protocol: `^${PROTOCOL_MAJOR}` } }, objects: [ { name: 'invoice', diff --git a/packages/create-objectstack/src/template-consistency.test.ts b/packages/create-objectstack/src/template-consistency.test.ts index 5be0bb520a..22db434966 100644 --- a/packages/create-objectstack/src/template-consistency.test.ts +++ b/packages/create-objectstack/src/template-consistency.test.ts @@ -54,6 +54,22 @@ describe('blank template package.json', () => { }); }); +describe('blank template manifest engines.protocol (ADR-0087 D1)', () => { + it('stamps the current protocol major so the handshake covers fresh scaffolds', () => { + const config = fs.readFileSync( + path.join(pkgRoot, 'src', 'templates', 'blank', 'objectstack.config.ts'), + 'utf8', + ); + const match = /engines:\s*\{\s*protocol:\s*'\^(\d+)'\s*\}/.exec(config); + expect(match, 'template manifest must stamp engines.protocol (ADR-0087 D1)').not.toBeNull(); + expect( + Number(match![1]), + `template stamps engines.protocol '^${match![1]}' but create-objectstack is v${ownMajor} — ` + + 'scripts/sync-template-versions.mjs re-stamps this at version time; keep them in lockstep', + ).toBe(ownMajor); + }); +}); + describe('README template table', () => { it('lists exactly the templates in the TEMPLATES registry', () => { const readme = fs.readFileSync(path.join(pkgRoot, 'README.md'), 'utf8'); diff --git a/packages/create-objectstack/src/templates/blank/objectstack.config.ts b/packages/create-objectstack/src/templates/blank/objectstack.config.ts index 48abaddd16..2927273a32 100644 --- a/packages/create-objectstack/src/templates/blank/objectstack.config.ts +++ b/packages/create-objectstack/src/templates/blank/objectstack.config.ts @@ -9,6 +9,11 @@ export default defineStack({ type: 'app', name: 'Blank Starter', description: 'Minimal ObjectStack environment — a clean slate for building.', + // Protocol compatibility range (ADR-0087 D1): lets an incompatible runtime + // refuse this package at the boundary with the exact migration command, + // instead of crashing later. Kept in lockstep with releases by + // scripts/sync-template-versions.mjs. + engines: { protocol: '^15' }, }, objects: Object.values(objects), }); diff --git a/packages/runtime/package.json b/packages/runtime/package.json index db3f0cb35e..299200549b 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -36,7 +36,8 @@ "@objectstack/spec": "workspace:*", "@objectstack/types": "workspace:*", "quickjs-emscripten": "^0.32.0", - "zod": "^4.4.3" + "zod": "^4.4.3", + "@objectstack/metadata-core": "workspace:*" }, "optionalDependencies": { "@objectstack/driver-mongodb": "workspace:*" diff --git a/packages/runtime/src/app-plugin.test.ts b/packages/runtime/src/app-plugin.test.ts index f185897515..321dad2836 100644 --- a/packages/runtime/src/app-plugin.test.ts +++ b/packages/runtime/src/app-plugin.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { AppPlugin } from './app-plugin'; import { PluginContext } from '@objectstack/core'; +import { PROTOCOL_MAJOR } from '@objectstack/spec/kernel'; describe('AppPlugin', () => { let mockContext: PluginContext; @@ -320,6 +321,63 @@ describe('AppPlugin', () => { // Default hook body runner (#2588) // ═══════════════════════════════════════════════════════════════ + describe('protocol handshake on the code-defined-stack load seam (ADR-0087 D1)', () => { + let mockManifest: any; + + beforeEach(() => { + mockManifest = { register: vi.fn() }; + vi.mocked(mockContext.getService).mockImplementation(((name: string) => { + if (name === 'manifest') return mockManifest; + throw new Error(`service '${name}' not found`); + }) as any); + }); + + it('refuses an incompatible engines.protocol range BEFORE registering the manifest', async () => { + const plugin = new AppPlugin({ + manifest: { + id: 'com.test.stale', + version: '1.0.0', + engines: { protocol: `^${PROTOCOL_MAJOR - 5}` }, + }, + objects: [], + }); + await expect(plugin.init(mockContext)).rejects.toMatchObject({ + code: 'OS_PROTOCOL_INCOMPATIBLE', + diagnostic: expect.objectContaining({ + packageId: 'com.test.stale', + migrateCommand: `objectstack migrate meta --from ${PROTOCOL_MAJOR - 5}`, + }), + }); + expect(mockManifest.register).not.toHaveBeenCalled(); + }); + + it('loads a compatible range without warnings', async () => { + const plugin = new AppPlugin({ + manifest: { + id: 'com.test.current', + version: '1.0.0', + engines: { protocol: `^${PROTOCOL_MAJOR}` }, + }, + objects: [], + }); + await plugin.init(mockContext); + expect(mockManifest.register).toHaveBeenCalledTimes(1); + const warnings = vi.mocked(mockContext.logger.warn).mock.calls.map((c) => String(c[0])); + expect(warnings.filter((w) => w.includes('[protocol]'))).toHaveLength(0); + }); + + it('grandfathers a bundle with no range — loads with exactly one warning', async () => { + const plugin = new AppPlugin({ + manifest: { id: 'com.test.unversioned', version: '1.0.0' }, + objects: [], + }); + await plugin.init(mockContext); + expect(mockManifest.register).toHaveBeenCalledTimes(1); + const warnings = vi.mocked(mockContext.logger.warn).mock.calls.map((c) => String(c[0])); + expect(warnings.filter((w) => w.includes('[protocol]'))).toHaveLength(1); + }); + }); + describe('default hook body runner install', () => { let mockQL: any; let mockManifest: any; diff --git a/packages/runtime/src/app-plugin.ts b/packages/runtime/src/app-plugin.ts index 791cf62735..9e4d96ac31 100644 --- a/packages/runtime/src/app-plugin.ts +++ b/packages/runtime/src/app-plugin.ts @@ -1,6 +1,7 @@ // Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. import { Plugin, PluginContext, wireAuthoredTranslationSync } from '@objectstack/core'; +import { assertProtocolCompat } from '@objectstack/metadata-core'; import { resolveMultiOrgEnabled } from '@objectstack/types'; import { SeedLoaderService } from './seed-loader.js'; import { loadDisabledPackageIds } from './package-state-store.js'; @@ -136,10 +137,19 @@ export class AppPlugin implements Plugin { const sys = this.bundle.manifest || this.bundle; const appId = sys.id || sys.name; - ctx.logger.info('Registering App Service', { - appId, + // ADR-0087 D1 — protocol handshake on the code-defined-stack LOAD seam, + // BEFORE the manifest is decomposed into the registry. A bundle whose + // declared `engines.protocol` excludes this runtime's major fails boot + // fast with the structured OS_PROTOCOL_INCOMPATIBLE diagnostic (naming + // the `migrate meta` command) instead of crashing later in a schema + // `.parse()` or renderer contract. Absent/unparsable ranges are admitted + // with a warning (grandfathering; never a false rejection). + assertProtocolCompat(sys, undefined, (m) => ctx.logger.warn(`[AppPlugin] ${m}`)); + + ctx.logger.info('Registering App Service', { + appId, pluginName: this.name, - version: this.version + version: this.version }); // Register the app manifest directly via the manifest service. diff --git a/packages/services/service-package/package.json b/packages/services/service-package/package.json index ff1574aabb..4299ed3079 100644 --- a/packages/services/service-package/package.json +++ b/packages/services/service-package/package.json @@ -19,7 +19,8 @@ }, "dependencies": { "@objectstack/core": "workspace:*", - "@objectstack/spec": "workspace:*" + "@objectstack/spec": "workspace:*", + "@objectstack/metadata-core": "workspace:*" }, "devDependencies": { "@types/node": "^26.1.1", diff --git a/packages/services/service-package/src/hydration.test.ts b/packages/services/service-package/src/hydration.test.ts index 59ddd1d5c1..7f28aa3ee3 100644 --- a/packages/services/service-package/src/hydration.test.ts +++ b/packages/services/service-package/src/hydration.test.ts @@ -12,11 +12,11 @@ import { PackageServicePlugin } from './index.js'; */ const SEEDED = { id: 'app.seed', name: 'Seed', version: '1.0.0', type: 'application' }; -function sysPackagesRow() { +function sysPackagesRow(manifest: Record = SEEDED) { return { - id: SEEDED.id, - version: SEEDED.version, - manifest: JSON.stringify(SEEDED), + id: manifest.id, + version: manifest.version, + manifest: JSON.stringify(manifest), metadata: '{}', hash: 'h', created_at: 't', @@ -24,10 +24,13 @@ function sysPackagesRow() { }; } -function makeCtx(registry: { installPackage: ReturnType; getPackage: ReturnType }) { +function makeCtx( + registry: { installPackage: ReturnType; getPackage: ReturnType }, + manifest: Record = SEEDED, +) { const execute = vi.fn(async ({ sql }: { sql: string }) => { // The latest-per-id list query backs packageService.list() used by hydration. - if (/SELECT \* FROM sys_packages/i.test(sql)) return { rows: [sysPackagesRow()] }; + if (/SELECT \* FROM sys_packages/i.test(sql)) return { rows: [sysPackagesRow(manifest)] }; return { rows: [] }; // CREATE TABLE / INDEX / others }); const engine = { execute, registry } as never; @@ -69,4 +72,40 @@ describe('PackageServicePlugin boot hydration (ADR-0033 consolidation)', () => { expect(registry.getPackage).toHaveBeenCalledWith('app.seed'); expect(registry.installPackage).not.toHaveBeenCalled(); }); + + // ADR-0087 D1 — the boot-time rehydration path is a LOAD seam: a durable + // package whose declared engines.protocol excludes this runtime's major is + // refused with the structured diagnostic (and boot continues), instead of + // loading and crashing later deep in a schema parse. + it('refuses to rehydrate a protocol-incompatible package, with the structured diagnostic', async () => { + const stale = { ...SEEDED, id: 'app.stale', engines: { protocol: '^10' } }; + const registry = { + installPackage: vi.fn(), + getPackage: vi.fn(() => undefined), + }; + const { ctx } = makeCtx(registry, stale); + + await new PackageServicePlugin().start(ctx); + + expect(registry.installPackage).not.toHaveBeenCalled(); + const errors = (ctx as any).logger.error.mock.calls.map((c: unknown[]) => String(c[0])); + const refusal = errors.find((e: string) => e.includes('OS_PROTOCOL_INCOMPATIBLE')); + expect(refusal).toBeDefined(); + expect(refusal).toContain('app.stale'); + expect(refusal).toContain('objectstack migrate meta --from 10'); + }); + + it('rehydrates a package with no engines range (grandfathered) with a protocol warning', async () => { + const registry = { + installPackage: vi.fn(), + getPackage: vi.fn(() => undefined), + }; + const { ctx } = makeCtx(registry); // SEEDED declares no engines range + + await new PackageServicePlugin().start(ctx); + + expect(registry.installPackage).toHaveBeenCalledTimes(1); + const warnings = (ctx as any).logger.warn.mock.calls.map((c: unknown[]) => String(c[0])); + expect(warnings.filter((w: string) => w.includes('[protocol]'))).toHaveLength(1); + }); }); diff --git a/packages/services/service-package/src/index.ts b/packages/services/service-package/src/index.ts index c980749879..ce7f1940ae 100644 --- a/packages/services/service-package/src/index.ts +++ b/packages/services/service-package/src/index.ts @@ -2,6 +2,7 @@ import type { Plugin, PluginContext } from '@objectstack/core'; import { createHash } from 'node:crypto'; +import { checkProtocolCompat } from '@objectstack/metadata-core'; import type { ObjectStackManifest } from '@objectstack/spec/kernel'; import type { IDataEngine } from '@objectstack/spec/contracts'; @@ -210,6 +211,35 @@ export class PackageServicePlugin implements Plugin { for (const rec of await packageService.list()) { const id = rec?.manifest?.id; if (id && !registry.getPackage(id)) { + // ADR-0087 D1 — protocol handshake on the boot-time rehydration + // path (the LOAD seam; the install seam already checks in + // metadata-protocol). A durable package persisted under an older + // runtime whose declared `engines.protocol` excludes this runtime's + // major is REFUSED here with the structured diagnostic — skipped, + // never loaded — instead of resurfacing later as a deep schema or + // renderer crash. Boot itself continues: one stale package must not + // brick the environment. Absent/unparsable ranges are admitted + // (grandfathering; never a false rejection). + const compat = checkProtocolCompat(rec.manifest); + if (compat.status === 'incompatible') { + logger.error( + `[protocol] refusing to rehydrate package '${id}' from sys_packages: ` + + `${compat.diagnostic.message} ` + + JSON.stringify(compat.diagnostic), + ); + continue; + } + if (compat.status === 'no-range') { + logger.warn( + `[protocol] package '${id}' declares no engines.protocol range; ` + + `rehydrating without a compatibility check (ADR-0087).`, + ); + } else if (compat.status === 'unparsed-range') { + logger.warn( + `[protocol] package '${id}' declares an unrecognized ${compat.source} range ` + + `'${compat.requiredRange}'; skipping the protocol handshake (ADR-0087).`, + ); + } registry.installPackage(rec.manifest); hydrated++; } diff --git a/packages/spec/package.json b/packages/spec/package.json index 0ee00f6022..ec45a81f01 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -177,7 +177,9 @@ "llms.txt", "README.md", "src/**/*.zod.ts", - "CHANGELOG.md" + "CHANGELOG.md", + "api-surface.json", + "spec-changes.json" ], "scripts": { "build": "pnpm gen:schema && pnpm gen:openapi && tsup && if [ -z \"$OS_SKIP_DTS\" ]; then NODE_OPTIONS=\"--max-old-space-size=12288\" BUILD_DTS=true tsup; fi", @@ -193,6 +195,10 @@ "gen:sbom": "tsx scripts/generate-sbom.ts", "gen:api-surface": "tsx scripts/build-api-surface.ts", "check:api-surface": "tsx scripts/build-api-surface.ts --check", + "gen:spec-changes": "tsx scripts/build-spec-changes.ts", + "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", + "gen:upgrade-guide": "tsx scripts/build-upgrade-guide.ts", + "check:upgrade-guide": "tsx scripts/build-upgrade-guide.ts --check", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage", diff --git a/packages/spec/scripts/build-spec-changes.ts b/packages/spec/scripts/build-spec-changes.ts new file mode 100644 index 0000000000..2ec6bd0cae --- /dev/null +++ b/packages/spec/scripts/build-spec-changes.ts @@ -0,0 +1,119 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * build-spec-changes.ts — generate the machine-readable change manifest + * `spec-changes.json` (ADR-0087 D4). + * + * The manifest is a pure projection of the two ADR-0087 registries — the D2 + * conversion table and the D3 migration chain — folded per major from the + * support floor to the current protocol major, plus one aggregate record for + * the whole range. Because it is generated, it can never drift from what the + * loader and `migrate meta` actually do; a CI `--check` enforces that the + * committed copy is regenerated with any registry change (the ADR-0049 + * enforce-or-remove discipline applied to release artifacts). + * + * pnpm --filter @objectstack/spec gen:spec-changes # regenerate + write + * pnpm --filter @objectstack/spec check:spec-changes # CI: fail on drift + * + * Release-time surface join: `--previous-surface ` diffs the + * current committed `api-surface.json` against a previously *published* one + * (both ship in the npm artifact from protocol 15 on) and fills the + * `added[]`/`removed[]` arrays of the aggregate record, attributed to the + * current major. The Release workflow runs this against the last published + * spec tarball and attaches the result to the GitHub Release; the committed + * copy keeps `added`/`removed` empty (registry-derived content only) so it + * stays deterministic. + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version'; +import { MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry'; +import { + composeSpecChanges, + SpecChangesSchema, + type SpecSurfaceAdd, + type SpecSurfaceRemove, +} from '../src/migrations/spec-changes'; + +const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const SNAPSHOT = resolve(PKG_DIR, 'spec-changes.json'); +const SURFACE = resolve(PKG_DIR, 'api-surface.json'); +const CHECK = process.argv.includes('--check'); +const prevSurfaceIdx = process.argv.indexOf('--previous-surface'); +const PREV_SURFACE = prevSurfaceIdx >= 0 ? process.argv[prevSurfaceIdx + 1] : undefined; + +/** Flatten an api-surface.json ({ entry: ["name (kind)", …] }) into one set. */ +function flattenSurface(path: string): Set { + const doc = JSON.parse(readFileSync(path, 'utf8')) as Record; + const out = new Set(); + for (const [entry, names] of Object.entries(doc)) { + for (const name of names) out.add(`${entry}: ${name}`); + } + return out; +} + +/** Diff two flattened surfaces into the manifest's added/removed arrays. */ +function diffSurfaces(prevPath: string): { added: SpecSurfaceAdd[]; removed: SpecSurfaceRemove[] } { + const prev = flattenSurface(prevPath); + const curr = flattenSurface(SURFACE); + const added: SpecSurfaceAdd[] = [...curr] + .filter((s) => !prev.has(s)) + .sort() + .map((surface) => ({ surface, since: PROTOCOL_MAJOR })); + const removed: SpecSurfaceRemove[] = [...prev] + .filter((s) => !curr.has(s)) + .sort() + .map((surface) => ({ surface, removedIn: PROTOCOL_MAJOR })); + return { added, removed }; +} + +function build(): string { + const surfaceDiff = PREV_SURFACE ? diffSurfaces(PREV_SURFACE) : {}; + + // Per-major records compose (ADR-0087 D4): any tool can fold them into a + // single from→to view. The aggregate is that fold, precomputed. + const perMajor = []; + for (let major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++) { + perMajor.push(SpecChangesSchema.parse(composeSpecChanges(major - 1, major))); + } + const aggregate = SpecChangesSchema.parse( + composeSpecChanges(MIGRATION_SUPPORT_FLOOR, PROTOCOL_MAJOR, surfaceDiff), + ); + + const doc = { + $comment: + 'GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. ' + + 'A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes ' + + 'tool derive from this same data.', + protocolVersion: PROTOCOL_VERSION, + supportFloor: MIGRATION_SUPPORT_FLOOR, + migrateCommand: `objectstack migrate meta --from (N >= ${MIGRATION_SUPPORT_FLOOR})`, + aggregate, + perMajor, + }; + return `${JSON.stringify(doc, null, 2)}\n`; +} + +const next = build(); + +if (CHECK) { + if (PREV_SURFACE) { + console.error('check mode compares the committed (registry-only) manifest; drop --previous-surface'); + process.exit(2); + } + const current = existsSync(SNAPSHOT) ? readFileSync(SNAPSHOT, 'utf8') : ''; + if (current !== next) { + console.error( + 'spec-changes.json is stale — the ADR-0087 registries changed without regenerating the manifest.\n' + + 'Run: pnpm --filter @objectstack/spec gen:spec-changes (and commit the result)', + ); + process.exit(1); + } + console.log('spec-changes.json is up to date.'); +} else { + writeFileSync(SNAPSHOT, next); + console.log(`Wrote ${SNAPSHOT}`); +} diff --git a/packages/spec/scripts/build-upgrade-guide.ts b/packages/spec/scripts/build-upgrade-guide.ts new file mode 100644 index 0000000000..d7703e6301 --- /dev/null +++ b/packages/spec/scripts/build-upgrade-guide.ts @@ -0,0 +1,125 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * build-upgrade-guide.ts — generate `docs/protocol-upgrade-guide.md` as a pure + * projection of the ADR-0087 registries (D4: "the upgrade guide for major N is + * GENERATED from the change registry … it can never drift because it is a + * projection of it"). + * + * Everything in the emitted document comes from the D2 conversion table and + * the D3 migration chain — the same data the loader, `objectstack migrate + * meta`, and `spec-changes.json` run on. Hand-written narrative belongs in the + * ADRs and release notes, not here. + * + * pnpm --filter @objectstack/spec gen:upgrade-guide # regenerate + write + * pnpm --filter @objectstack/spec check:upgrade-guide # CI: fail on drift + */ + +import { readFileSync, writeFileSync, existsSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { CONVERSIONS_BY_MAJOR } from '../src/conversions/registry'; +import { PROTOCOL_MAJOR, PROTOCOL_VERSION } from '../src/kernel/protocol-version'; +import { MIGRATIONS_BY_MAJOR, MIGRATION_SUPPORT_FLOOR } from '../src/migrations/registry'; + +const REPO_ROOT = resolve(fileURLToPath(new URL('.', import.meta.url)), '..', '..', '..'); +const GUIDE = resolve(REPO_ROOT, 'docs', 'protocol-upgrade-guide.md'); +const CHECK = process.argv.includes('--check'); + +function build(): string { + const lines: string[] = []; + const out = (s = '') => lines.push(s); + + out(''); + out(''); + out(); + out('# Metadata protocol upgrade guide'); + out(); + out( + `Current protocol: **${PROTOCOL_VERSION}** · chain support floor: **protocol ${MIGRATION_SUPPORT_FLOOR}** · ` + + 'generated from the ADR-0087 registries (`@objectstack/spec` `conversions/` + `migrations/`).', + ); + out(); + out('## How to upgrade — from any past major'); + out(); + out('```bash'); + out(`objectstack migrate meta --from # replays every step below, in order`); + out('objectstack migrate meta --from 10 --step # checkpoint after each major (bisect a failure)'); + out('objectstack validate && tsc --noEmit && # your own verify loop is the acceptance test'); + out('```'); + out(); + out( + 'Mechanical rewrites are applied for you and reported as a diff; **semantic TODOs** are printed ' + + 'with acceptance criteria and are yours to resolve — the chain never auto-applies a change that ' + + 'requires judgment. Arriving several majors late is the designed-for case: timeliness is never ' + + 'load-bearing (ADR-0087).', + ); + out(); + + for (let major = MIGRATION_SUPPORT_FLOOR + 1; major <= PROTOCOL_MAJOR; major++) { + const step = MIGRATIONS_BY_MAJOR[major]; + const conversions = CONVERSIONS_BY_MAJOR[major] ?? []; + out(`## Protocol ${major - 1} → ${major}`); + out(); + if (!step && conversions.length === 0) { + out('No metadata-facing break shipped in this major — nothing to do.'); + out(); + continue; + } + if (step) { + out(step.rationale); + out(); + } + if (conversions.length > 0) { + out('### Mechanical (applied for you)'); + out(); + out('| Conversion | Surface | Change | Load window |'); + out('|---|---|---|---|'); + for (const c of conversions) { + const window = c.retiredFromLoadPath + ? 'retired — `migrate meta` only' + : `live — protocol ${c.toMajor} loader accepts the old shape`; + out(`| \`${c.id}\` | \`${c.surface}\` | ${c.summary} | ${window} |`); + } + out(); + } + const semantic = step?.semantic ?? []; + if (semantic.length > 0) { + out('### Semantic (delegated to you, with acceptance criteria)'); + out(); + for (const s of semantic) { + out(`- **\`${s.id}\`** — \`${s.surface}\` → ${s.replacement}`); + out(` - Why not automatic: ${s.reason}`); + out(` - Done when: ${s.acceptanceCriteria}`); + } + out(); + } + } + + out('---'); + out(); + out( + '*Machine-readable equivalents: `spec-changes.json` (shipped in `@objectstack/spec` and attached to ' + + 'each GitHub Release) and the structured output of `objectstack migrate meta --json`.*', + ); + out(); + return lines.join('\n'); +} + +const next = build(); + +if (CHECK) { + const current = existsSync(GUIDE) ? readFileSync(GUIDE, 'utf8') : ''; + if (current !== next) { + console.error( + 'docs/protocol-upgrade-guide.md is stale — the ADR-0087 registries changed without regenerating.\n' + + 'Run: pnpm --filter @objectstack/spec gen:upgrade-guide (and commit the result)', + ); + process.exit(1); + } + console.log('protocol-upgrade-guide.md is up to date.'); +} else { + writeFileSync(GUIDE, next); + console.log(`Wrote ${GUIDE}`); +} diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json new file mode 100644 index 0000000000..ee99fca03b --- /dev/null +++ b/packages/spec/spec-changes.json @@ -0,0 +1,312 @@ +{ + "$comment": "GENERATED (ADR-0087 D4) — do not edit. Regenerate with: pnpm --filter @objectstack/spec gen:spec-changes. A projection of the D2 conversion table + D3 migration chain; the upgrade guide and the MCP spec_changes tool derive from this same data.", + "protocolVersion": "15.0.0", + "supportFloor": 10, + "migrateCommand": "objectstack migrate meta --from (N >= 10)", + "aggregate": { + "from": 10, + "to": 15, + "added": [], + "converted": [ + { + "surface": "flow.node.type", + "to": "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", + "conversionId": "flow-node-http-callout-rename", + "toMajor": 11 + }, + { + "surface": "page.kind", + "to": "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", + "conversionId": "page-kind-jsx-to-html", + "toMajor": 11 + }, + { + "surface": "flow.node.config.filter", + "to": "CRUD flow-node config key 'filters' → 'filter'", + "conversionId": "flow-node-crud-filter-alias", + "toMajor": 11 + }, + { + "surface": "object.compactLayout", + "to": "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", + "conversionId": "object-compactLayout-to-highlightFields", + "toMajor": 11 + }, + { + "surface": "stack.roles", + "to": "stack collection key 'roles' → 'positions' (ADR-0090 D3)", + "conversionId": "stack-roles-to-positions", + "toMajor": 13 + }, + { + "surface": "object.sharingModel", + "to": "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", + "conversionId": "owd-legacy-read-aliases", + "toMajor": 13 + }, + { + "surface": "sharingRule.sharedWith.type", + "to": "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", + "conversionId": "sharing-recipient-role-to-position", + "toMajor": 13 + }, + { + "surface": "book.audience", + "to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", + "conversionId": "book-audience-profile-to-permission-set", + "toMajor": 14 + }, + { + "surface": "view.form.visibleOn", + "to": "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", + "conversionId": "view-visibleOn-to-visibleWhen", + "toMajor": 15 + }, + { + "surface": "page.component.visibility", + "to": "page component key 'visibility' → 'visibleWhen' (ADR-0089)", + "conversionId": "page-component-visibility-to-visibleWhen", + "toMajor": 15 + } + ], + "migrated": [ + { + "surface": "object.titleFormat", + "replacement": "object.nameField", + "migrationId": "object-titleFormat-to-nameField", + "toMajor": 11, + "rationale": "A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make." + }, + { + "surface": "security.rls.predicate", + "replacement": "CEL predicate", + "migrationId": "rls-sql-predicate-to-cel", + "toMajor": 11, + "rationale": "SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain." + }, + { + "surface": "api.requireAuth", + "replacement": "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", + "migrationId": "rest-requireauth-default-flip", + "toMajor": 12, + "rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make." + }, + { + "surface": "permissionSet.kind / permissionSet.isProfile", + "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)", + "migrationId": "permission-set-profile-removed", + "toMajor": 13, + "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename." + }, + { + "surface": "position.parent / sharingRule recipient role_and_subordinates", + "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)", + "migrationId": "position-hierarchy-flattened", + "toMajor": 13, + "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call." + }, + { + "surface": "CEL/formula: current_user.roles", + "replacement": "current_user.positions", + "migrationId": "cel-current-user-roles-to-positions", + "toMajor": 13, + "rationale": "The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author." + }, + { + "surface": "object.sharingModel: 'full'", + "replacement": "'public_read_write' or explicit sharing rules", + "migrationId": "owd-full-alias-removed", + "toMajor": 13, + "rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision." + }, + { + "surface": "object.sharingModel (absent, custom object with owner field)", + "replacement": "an explicit `sharingModel` declaration", + "migrationId": "sharing-model-secure-default", + "toMajor": 13, + "rationale": "ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data." + }, + { + "surface": "view form fields/sections · page components (undeclared keys)", + "replacement": "declared keys only (`visibleWhen` for visibility predicates)", + "migrationId": "ui-schemas-strict-unknown-keys", + "toMajor": 15, + "rationale": "The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata." + } + ], + "removed": [] + }, + "perMajor": [ + { + "from": 10, + "to": 11, + "added": [], + "converted": [ + { + "surface": "flow.node.type", + "to": "flow callout node types 'http_request' / 'http_call' / 'webhook' → 'http'", + "conversionId": "flow-node-http-callout-rename", + "toMajor": 11 + }, + { + "surface": "page.kind", + "to": "page kind 'jsx' → 'html' (ADR-0080 canonical spelling)", + "conversionId": "page-kind-jsx-to-html", + "toMajor": 11 + }, + { + "surface": "flow.node.config.filter", + "to": "CRUD flow-node config key 'filters' → 'filter'", + "conversionId": "flow-node-crud-filter-alias", + "toMajor": 11 + }, + { + "surface": "object.compactLayout", + "to": "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", + "conversionId": "object-compactLayout-to-highlightFields", + "toMajor": 11 + } + ], + "migrated": [ + { + "surface": "object.titleFormat", + "replacement": "object.nameField", + "migrationId": "object-titleFormat-to-nameField", + "toMajor": 11, + "rationale": "A single-field `titleFormat` maps 1:1 to `nameField`, but a composite template (e.g. `{firstName} {lastName}`) has no lossless single-field target — it must become a formula field designated as `nameField`. The choice of formula is a judgment the transform cannot make." + }, + { + "surface": "security.rls.predicate", + "replacement": "CEL predicate", + "migrationId": "rls-sql-predicate-to-cel", + "toMajor": 11, + "rationale": "SQL-ish RLS predicates were deprecated in favor of canonical CEL. Translation is not a pure token rename — operators, functions, and null semantics differ — so it cannot be applied losslessly by the chain." + } + ], + "removed": [] + }, + { + "from": 11, + "to": 12, + "added": [], + "converted": [], + "migrated": [ + { + "surface": "api.requireAuth", + "replacement": "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", + "migrationId": "rest-requireauth-default-flip", + "toMajor": 12, + "rationale": "The global default flipped from `false` to `true` in protocol 12: anonymous requests to the `/data/*` CRUD and batch endpoints are rejected with 401 unless the stack opts out. Whether anonymous access was intentional (demo / kiosk) or an accident is a security judgment no transform can make." + } + ], + "removed": [] + }, + { + "from": 12, + "to": 13, + "added": [], + "converted": [ + { + "surface": "stack.roles", + "to": "stack collection key 'roles' → 'positions' (ADR-0090 D3)", + "conversionId": "stack-roles-to-positions", + "toMajor": 13 + }, + { + "surface": "object.sharingModel", + "to": "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", + "conversionId": "owd-legacy-read-aliases", + "toMajor": 13 + }, + { + "surface": "sharingRule.sharedWith.type", + "to": "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", + "conversionId": "sharing-recipient-role-to-position", + "toMajor": 13 + } + ], + "migrated": [ + { + "surface": "permissionSet.kind / permissionSet.isProfile", + "replacement": "position-based assignment + permission-set grants (ADR-0090 D2)", + "migrationId": "permission-set-profile-removed", + "toMajor": 13, + "rationale": "The Profile concept was removed: `isProfile` is gone from `PermissionSetSchema` and the `profile` metadata kind folded into `position`. Mapping a profile onto positions and permission-set grants is an authorization-design decision, not a rename." + }, + { + "surface": "position.parent / sharingRule recipient role_and_subordinates", + "replacement": "business-unit tree + `unit_and_subordinates` (ADR-0090 D3)", + "migrationId": "position-hierarchy-flattened", + "toMajor": 13, + "rationale": "Positions are flat in v2 — `parent` was removed and the `role_and_subordinates` recipient with it; hierarchy lives on the business-unit tree, which expands a DIFFERENT structure than the retired role tree. Re-homing an org hierarchy is a judgment call." + }, + { + "surface": "CEL/formula: current_user.roles", + "replacement": "current_user.positions", + "migrationId": "cel-current-user-roles-to-positions", + "toMajor": 13, + "rationale": "The EvalUser/CEL contract renamed `current_user.roles` to `current_user.positions`. The token lives inside free-form expression strings, where a blind textual substitution could corrupt string literals or comments — so the rewrite is delegated to the author." + }, + { + "surface": "object.sharingModel: 'full'", + "replacement": "'public_read_write' or explicit sharing rules", + "migrationId": "owd-full-alias-removed", + "toMajor": 13, + "rationale": "The legacy `'full'` OWD alias implied full access (including transfer/ delete) — wider than any canonical OWD value, so it has no lossless target ('read'/'read_write' converted mechanically; this one did not). Choosing between `public_read_write` and explicit sharing rules is a security-posture decision." + }, + { + "surface": "object.sharingModel (absent, custom object with owner field)", + "replacement": "an explicit `sharingModel` declaration", + "migrationId": "sharing-model-secure-default", + "toMajor": 13, + "rationale": "ADR-0090 D1 secure default: a custom object with an owner field and NO `sharingModel` now resolves `private` (it used to fall through to fully public). Restoring the old exposure must be a deliberate, visible declaration — the chain must not silently re-open data." + } + ], + "removed": [] + }, + { + "from": 13, + "to": 14, + "added": [], + "converted": [ + { + "surface": "book.audience", + "to": "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", + "conversionId": "book-audience-profile-to-permission-set", + "toMajor": 14 + } + ], + "migrated": [], + "removed": [] + }, + { + "from": 14, + "to": 15, + "added": [], + "converted": [ + { + "surface": "view.form.visibleOn", + "to": "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", + "conversionId": "view-visibleOn-to-visibleWhen", + "toMajor": 15 + }, + { + "surface": "page.component.visibility", + "to": "page component key 'visibility' → 'visibleWhen' (ADR-0089)", + "conversionId": "page-component-visibility-to-visibleWhen", + "toMajor": 15 + } + ], + "migrated": [ + { + "surface": "view form fields/sections · page components (undeclared keys)", + "replacement": "declared keys only (`visibleWhen` for visibility predicates)", + "migrationId": "ui-schemas-strict-unknown-keys", + "toMajor": 15, + "rationale": "The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped unknown key into a parse error. There is no mapping target for an arbitrary unknown key — auto-deleting it would be exactly the silent data loss ADR-0078 bans — so each occurrence needs the author to decide: fix the typo, move it to the right layer, or delete dead metadata." + } + ], + "removed": [] + } + ] +} diff --git a/packages/spec/src/conversions/apply.ts b/packages/spec/src/conversions/apply.ts index ea2d6b8df8..e003060cd6 100644 --- a/packages/spec/src/conversions/apply.ts +++ b/packages/spec/src/conversions/apply.ts @@ -27,6 +27,13 @@ export interface ApplyConversionsOptions { * choice. `objectstack validate` passes a sink that prints them. */ onNotice?: (notice: ConversionNotice) => void; + /** + * Also apply conversions marked `retiredFromLoadPath` (default `false`). + * The load seam never sets this — a retired entry is chain history, not a + * live window. The migration chain and the fixture CI set it so graduated + * transforms stay replayable forever (ADR-0087 D3). + */ + includeRetired?: boolean; /** * Sink for each structured **conflict** — a rename refused because its old * token is a live name in this environment (see {@link ConversionContext}). @@ -53,10 +60,14 @@ export function applyConversions( stack: Record, options: ApplyConversionsOptions = {}, ): Record { - const { onNotice, onConflict, reservedNodeTypes } = options; + const { onNotice, onConflict, reservedNodeTypes, includeRetired = false } = options; let current = stack; for (const conversion of ALL_CONVERSIONS) { + // A retired entry is graduated chain history (ADR-0087 D2 window, second + // half): the loader no longer accepts its old shape — only `migrate meta` + // (and the fixture CI) replays it, via `includeRetired`. + if (conversion.retiredFromLoadPath && !includeRetired) continue; const retiresIn = conversion.toMajor + 1; const context: ConversionContext = { reservedNodeTypes, @@ -122,11 +133,14 @@ export function applyConversionsToFlow(flow: T, options: ApplyConversionsOpti * Collect the notices a stack would emit without needing an external sink — * convenience for `validate` / `lint` / the future MCP `spec_deprecations` tool. */ -export function collectConversionNotices(stack: Record): { +export function collectConversionNotices( + stack: Record, + options: Omit = {}, +): { stack: Record; notices: ConversionNotice[]; } { const notices: ConversionNotice[] = []; - const converted = applyConversions(stack, { onNotice: (n) => notices.push(n) }); + const converted = applyConversions(stack, { ...options, onNotice: (n) => notices.push(n) }); return { stack: converted, notices }; } diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index 881788f54a..06b5cfecf8 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -11,8 +11,11 @@ describe('conversion layer (ADR-0087 D2)', () => { describe('fixture pairs — every entry converts old shape → canonical', () => { for (const conversion of ALL_CONVERSIONS) { it(`${conversion.id}: before → after, emits ${conversion.fixture.expectedNotices} notice(s)`, () => { + // `includeRetired` so graduated (load-retired) entries stay fixture-tested + // forever — the chain replays them even though the loader no longer does. const { stack, notices } = collectConversionNotices( structuredClone(conversion.fixture.before), + { includeRetired: true }, ); // The whole table runs, but fixtures are disjoint, so the result must // equal exactly this entry's `after`. @@ -149,6 +152,37 @@ describe('conversion layer (ADR-0087 D2)', () => { }); }); + describe('load-path retirement (ADR-0087 D2 window, second half)', () => { + it('a retired conversion does NOT apply on the default load path', () => { + const retired = ALL_CONVERSIONS.filter((c) => c.retiredFromLoadPath); + expect(retired.length).toBeGreaterThan(0); + for (const conversion of retired) { + const before = structuredClone(conversion.fixture.before); + const { stack, notices } = collectConversionNotices(structuredClone(before)); + // Old shape passes through untouched — the schema layer (tombstone / + // rejection) owns the refusal now; only `migrate meta` replays these. + expect(stack).toEqual(before); + expect(notices).toHaveLength(0); + } + }); + + it('a live-window conversion still applies at load (protocol-15 aliases)', () => { + const { stack, notices } = collectConversionNotices({ + pages: [ + { + name: 'p', + regions: [{ name: 'main', components: [{ type: 'record:list', visibility: 'true' }] }], + }, + ], + }); + const component = (stack.pages as any[])[0].regions[0].components[0]; + expect(component.visibleWhen).toBe('true'); + expect(component.visibility).toBeUndefined(); + expect(notices).toHaveLength(1); + expect(notices[0]!.conversionId).toBe('page-component-visibility-to-visibleWhen'); + }); + }); + describe('registry invariants', () => { it('every conversion carries a fixture pair and a positive retirement window', () => { for (const c of ALL_CONVERSIONS) { diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 3542850bcc..a48b07403a 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -16,7 +16,7 @@ */ import type { MetadataConversion } from './types.js'; -import { mapFlowNodes, mapPages, renameConfigKey } from './walk.js'; +import { mapCollection, mapFlowNodes, mapPages, renameConfigKey, renameKey } from './walk.js'; /** * Flow callout node type rename (protocol 11.0). @@ -179,12 +179,443 @@ const flowNodeFilterAlias: MetadataConversion = { }, }; +/** + * Object `compactLayout` → `highlightFields` (spec 11.7.0, ADR-0085; alias + * retired at authoring in 11.9.1, #2536). + * + * A pure key rename — the value (ordered field-name list) is unchanged. + * **Retired from the load path**: the schema tombstones `compactLayout` with a + * fix-it error, so the loader must NOT quietly accept it; the entry exists so + * `migrate meta --from 10|11` rewrites old *sources* (backfilled per the + * ADR-0087 true-up — the rename shipped before the conversion layer existed). + */ +const objectCompactLayoutRename: MetadataConversion = { + id: 'object-compactLayout-to-highlightFields', + toMajor: 11, + retiredFromLoadPath: true, + surface: 'object.compactLayout', + summary: "object key 'compactLayout' → 'highlightFields' (ADR-0085 semantic roles)", + apply(stack, emit) { + return mapCollection(stack, 'objects', (obj, path) => { + const renamed = renameKey(obj, 'compactLayout', 'highlightFields'); + if (!renamed) return obj; + emit({ from: 'compactLayout', to: 'highlightFields', path: `${path}.highlightFields` }); + return renamed; + }); + }, + fixture: { + before: { + objects: [{ name: 'crm_lead', label: 'Lead', compactLayout: ['name', 'status'] }], + }, + after: { + objects: [{ name: 'crm_lead', label: 'Lead', highlightFields: ['name', 'status'] }], + }, + expectedNotices: 1, + }, +}; + +/** + * Stack collection `roles:` → `positions:` (protocol 13, ADR-0090 D3). + * + * The distribution concept was renamed Role → Position across the platform; + * the stack-definition collection key renamed with it. A pure key move — the + * item shapes migrate separately (`position.parent` removal is semantic, see + * the step-13 TODOs). **Retired from the load path**: ADR-0090 shipped this as + * a pre-launch one-step rename with no alias window; the entry preserves it as + * replayable chain history. + */ +const stackRolesToPositions: MetadataConversion = { + id: 'stack-roles-to-positions', + toMajor: 13, + retiredFromLoadPath: true, + surface: 'stack.roles', + summary: "stack collection key 'roles' → 'positions' (ADR-0090 D3)", + apply(stack, emit) { + const renamed = renameKey(stack, 'roles', 'positions'); + if (!renamed) return stack; + emit({ from: 'roles', to: 'positions', path: 'positions' }); + return renamed; + }, + fixture: { + before: { + roles: [{ name: 'sales_rep', label: 'Sales Rep' }], + }, + after: { + positions: [{ name: 'sales_rep', label: 'Sales Rep' }], + }, + expectedNotices: 1, + }, +}; + +/** + * OWD legacy aliases `read` / `read_write` → canonical (protocol 13, + * ADR-0090 D4). + * + * The two aliases with an unambiguous canonical spelling convert mechanically; + * the third legacy alias `'full'` has NO lossless target (full access includes + * transfer/delete — wider than `public_read_write`) and is delegated to the + * step-13 semantic TODO instead (D2 scope guard: lossless only). Handles both + * `object.sharingModel` and the nested `object.security.sharingModel` spot. + * **Retired from the load path** (one-step removal; authoring rejects with a + * fix-it). + */ +const owdLegacyReadAliases: MetadataConversion = { + id: 'owd-legacy-read-aliases', + toMajor: 13, + retiredFromLoadPath: true, + surface: 'object.sharingModel', + summary: "object sharingModel 'read' → 'public_read', 'read_write' → 'public_read_write' (ADR-0090 D4)", + apply(stack, emit) { + const CANONICAL: Record = { + read: 'public_read', + read_write: 'public_read_write', + }; + return mapCollection(stack, 'objects', (obj, path) => { + let next = obj; + const direct = next.sharingModel; + if (typeof direct === 'string' && CANONICAL[direct]) { + emit({ from: direct, to: CANONICAL[direct]!, path: `${path}.sharingModel` }); + next = { ...next, sharingModel: CANONICAL[direct] }; + } + const security = next.security; + if (security && typeof security === 'object' && !Array.isArray(security)) { + const nested = (security as Record).sharingModel; + if (typeof nested === 'string' && CANONICAL[nested]) { + emit({ from: nested, to: CANONICAL[nested]!, path: `${path}.security.sharingModel` }); + next = { ...next, security: { ...(security as Record), sharingModel: CANONICAL[nested] } }; + } + } + return next; + }); + }, + fixture: { + before: { + objects: [ + { name: 'crm_deal', label: 'Deal', sharingModel: 'read' }, + { name: 'crm_note', label: 'Note', security: { sharingModel: 'read_write' } }, + ], + }, + after: { + objects: [ + { name: 'crm_deal', label: 'Deal', sharingModel: 'public_read' }, + { name: 'crm_note', label: 'Note', security: { sharingModel: 'public_read_write' } }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * Sharing-rule recipient type `'role'` → `'position'` (protocol 13, + * ADR-0090 D3). + * + * Applies to both `sharedWith.type` and the owner-rule `ownedBy.type`. The + * removed `'role_and_subordinates'` recipient is NOT converted — its v2 + * replacement (`unit_and_subordinates`) expands a *different* tree (business + * units, not the retired role hierarchy), so it is a step-13 semantic TODO. + * **Retired from the load path** (one-step rename, no alias window). + */ +const sharingRecipientRoleToPosition: MetadataConversion = { + id: 'sharing-recipient-role-to-position', + toMajor: 13, + retiredFromLoadPath: true, + surface: 'sharingRule.sharedWith.type', + summary: "sharing-rule recipient type 'role' → 'position' (ADR-0090 D3)", + apply(stack, emit) { + const renameRecipient = (rule: Record, key: string, path: string) => { + const recipient = rule[key]; + if (!recipient || typeof recipient !== 'object' || Array.isArray(recipient)) return rule; + const dict = recipient as Record; + if (dict.type !== 'role') return rule; + emit({ from: 'role', to: 'position', path: `${path}.${key}.type` }); + return { ...rule, [key]: { ...dict, type: 'position' } }; + }; + return mapCollection(stack, 'sharingRules', (rule, path) => { + let next = renameRecipient(rule, 'sharedWith', path); + next = renameRecipient(next, 'ownedBy', path); + return next; + }); + }, + fixture: { + before: { + sharingRules: [ + { + name: 'share_sales', + type: 'owner', + object: 'crm_deal', + sharedWith: { type: 'role', value: 'sales_mgr' }, + ownedBy: { type: 'role', value: 'sales_rep' }, + }, + ], + }, + after: { + sharingRules: [ + { + name: 'share_sales', + type: 'owner', + object: 'crm_deal', + sharedWith: { type: 'position', value: 'sales_mgr' }, + ownedBy: { type: 'position', value: 'sales_rep' }, + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * Book audience gated arm `{ profile }` → `{ permissionSet }` (protocol 14, + * ADR-0090 D2 fallout; shipped in 14.0.0 as a pre-launch one-step rename). + * + * Packages own permission sets but never positions (ADR-0090 D9), so the + * gate is a capability reference. Value carried over 1:1. **Retired from the + * load path** — the zod union rejects `{ profile }` at parse; this entry is + * the replayable chain history the one-step ship skipped. + */ +const bookAudienceProfileToPermissionSet: MetadataConversion = { + id: 'book-audience-profile-to-permission-set', + toMajor: 14, + retiredFromLoadPath: true, + surface: 'book.audience', + summary: "book audience gated arm '{ profile }' → '{ permissionSet }' (ADR-0090 D2/D9)", + apply(stack, emit) { + return mapCollection(stack, 'books', (book, path) => { + const audience = book.audience; + if (!audience || typeof audience !== 'object' || Array.isArray(audience)) return book; + const dict = audience as Record; + if (typeof dict.profile !== 'string' || dict.permissionSet != null) return book; + emit({ from: 'profile', to: 'permissionSet', path: `${path}.audience.permissionSet` }); + const { profile, ...rest } = dict; + return { ...book, audience: { ...rest, permissionSet: profile } }; + }); + }, + fixture: { + before: { + books: [{ name: 'crm_admin_guide', audience: { profile: 'crm_admin' } }], + }, + after: { + books: [{ name: 'crm_admin_guide', audience: { permissionSet: 'crm_admin' } }], + }, + expectedNotices: 1, + }, +}; + +/** Rename a visibility alias key on a dict, emitting with the given path. */ +function renameVisibilityAlias( + dict: Record, + alias: string, + path: string, + emit: (detail: { from: string; to: string; path: string }) => void, +): Record { + const renamed = renameKey(dict, alias, 'visibleWhen'); + if (!renamed) return dict; + emit({ from: alias, to: 'visibleWhen', path: `${path}.visibleWhen` }); + return renamed; +} + +/** + * View form `visibleOn` → `visibleWhen` (protocol 15, ADR-0089 D2). + * + * The conditional-visibility predicate is unified under the canonical + * `visibleWhen` across all layers. Applies to form sections and (recursively + * nested) form fields in every `views[].form` / `views[].formViews.*` + * container. **Live window**: the protocol-15 loader accepts the deprecated + * key (the zod schemas also normalize it at parse — this entry makes the + * acceptance *declared, loud, and expiring* per ADR-0087 D2, and will + * graduate into the step-16 chain when the alias is removed). + */ +const viewVisibleOnToVisibleWhen: MetadataConversion = { + id: 'view-visibleOn-to-visibleWhen', + toMajor: 15, + surface: 'view.form.visibleOn', + summary: "view form section/field key 'visibleOn' → 'visibleWhen' (ADR-0089)", + apply(stack, emit) { + const mapFields = (fields: unknown, path: string): unknown => { + if (!Array.isArray(fields)) return fields; + let changed = false; + const next = fields.map((field, i) => { + if (!field || typeof field !== 'object' || Array.isArray(field)) return field; + let dict = field as Record; + dict = renameVisibilityAlias(dict, 'visibleOn', `${path}[${i}]`, emit); + const nested = mapFields(dict.fields, `${path}[${i}].fields`); + if (nested !== dict.fields) dict = { ...dict, fields: nested }; + if (dict !== field) changed = true; + return dict; + }); + return changed ? next : fields; + }; + + const mapSections = (sections: unknown, path: string): unknown => { + if (!Array.isArray(sections)) return sections; + let changed = false; + const next = sections.map((section, i) => { + if (!section || typeof section !== 'object' || Array.isArray(section)) return section; + let dict = section as Record; + dict = renameVisibilityAlias(dict, 'visibleOn', `${path}[${i}]`, emit); + const fields = mapFields(dict.fields, `${path}[${i}].fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + if (dict !== section) changed = true; + return dict; + }); + return changed ? next : sections; + }; + + const mapForm = (form: unknown, path: string): unknown => { + if (!form || typeof form !== 'object' || Array.isArray(form)) return form; + let dict = form as Record; + for (const key of ['sections', 'groups'] as const) { + const mapped = mapSections(dict[key], `${path}.${key}`); + if (mapped !== dict[key]) dict = { ...dict, [key]: mapped }; + } + const fields = mapFields(dict.fields, `${path}.fields`); + if (fields !== dict.fields) dict = { ...dict, fields }; + return dict; + }; + + return mapCollection(stack, 'views', (view, path) => { + let next = view; + const form = mapForm(next.form, `${path}.form`); + if (form !== next.form) next = { ...next, form }; + const formViews = next.formViews; + if (formViews && typeof formViews === 'object' && !Array.isArray(formViews)) { + let fvChanged = false; + const nextViews: Record = {}; + for (const [name, fv] of Object.entries(formViews as Record)) { + const mapped = mapForm(fv, `${path}.formViews.${name}`); + if (mapped !== fv) fvChanged = true; + nextViews[name] = mapped; + } + if (fvChanged) next = { ...next, formViews: nextViews }; + } + return next; + }); + }, + fixture: { + before: { + views: [ + { + object: 'crm_lead', + form: { + sections: [ + { + label: 'Details', + visibleOn: "record.status == 'open'", + fields: ['name', { field: 'priority', visibleOn: "record.priority != ''" }], + }, + ], + }, + }, + ], + }, + after: { + views: [ + { + object: 'crm_lead', + form: { + sections: [ + { + label: 'Details', + visibleWhen: "record.status == 'open'", + fields: ['name', { field: 'priority', visibleWhen: "record.priority != ''" }], + }, + ], + }, + }, + ], + }, + expectedNotices: 2, + }, +}; + +/** + * Page component `visibility` → `visibleWhen` (protocol 15, ADR-0089 D2). + * + * The page-component spelling of the same predicate. Applies to + * `pages[].regions[].components[]`. **Live window**, same terms as + * {@link viewVisibleOnToVisibleWhen}. (An AI agent's `visibility` property is + * a different, unrelated surface and is not touched.) + */ +const pageComponentVisibilityToVisibleWhen: MetadataConversion = { + id: 'page-component-visibility-to-visibleWhen', + toMajor: 15, + surface: 'page.component.visibility', + summary: "page component key 'visibility' → 'visibleWhen' (ADR-0089)", + apply(stack, emit) { + return mapPages(stack, (page, path) => { + const regions = page.regions; + if (!Array.isArray(regions)) return page; + let regionsChanged = false; + const nextRegions = regions.map((region, ri) => { + if (!region || typeof region !== 'object' || Array.isArray(region)) return region; + const dict = region as Record; + const components = dict.components; + if (!Array.isArray(components)) return region; + let componentsChanged = false; + const nextComponents = components.map((component, ci) => { + if (!component || typeof component !== 'object' || Array.isArray(component)) return component; + const mapped = renameVisibilityAlias( + component as Record, + 'visibility', + `${path}.regions[${ri}].components[${ci}]`, + emit, + ); + if (mapped !== component) componentsChanged = true; + return mapped; + }); + if (!componentsChanged) return region; + regionsChanged = true; + return { ...dict, components: nextComponents }; + }); + if (!regionsChanged) return page; + return { ...page, regions: nextRegions }; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'crm_home', + regions: [ + { + name: 'main', + components: [ + { type: 'record:list', visibility: "page.selectedId != ''" }, + { type: 'element:divider' }, + ], + }, + ], + }, + ], + }, + after: { + pages: [ + { + name: 'crm_home', + regions: [ + { + name: 'main', + components: [ + { type: 'record:list', visibleWhen: "page.selectedId != ''" }, + { type: 'element:divider' }, + ], + }, + ], + }, + ], + }, + expectedNotices: 1, + }, +}; + /** * All conversions, keyed by the protocol major that introduced the canonical * shape. Newest majors last; ordering within a major is application order. */ export const CONVERSIONS_BY_MAJOR: Readonly> = { - 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias], + 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], + 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], + 14: [bookAudienceProfileToPermissionSet], + 15: [viewVisibleOnToVisibleWhen, pageComponentVisibilityToVisibleWhen], }; /** Flattened, deterministic list of every conversion the loader knows about. */ diff --git a/packages/spec/src/conversions/types.ts b/packages/spec/src/conversions/types.ts index d5914eae36..7cb97bf66f 100644 --- a/packages/spec/src/conversions/types.ts +++ b/packages/spec/src/conversions/types.ts @@ -141,6 +141,16 @@ export interface MetadataConversion { id: string; /** The protocol major that introduced the canonical shape. */ toMajor: number; + /** + * When `true`, this conversion is **retired from the load path**: the loader + * no longer accepts the old shape (the schema rejects or tombstones it), and + * the entry exists purely as graduated migration-chain history — replayed by + * `objectstack migrate meta` against *source* metadata, never at load. This + * is the ADR-0087 D2 window's second half ("retired in N+1 — but never + * deleted"), and it is also how a pre-launch one-step rename (which never had + * a load window at all) is preserved in the chain. + */ + retiredFromLoadPath?: boolean; /** Dotted surface, e.g. `flow.node.type`, `page.kind`, `flow.node.config`. */ surface: string; /** One-line human summary of the rename/move (the load-bearing prose, kept to one field). */ diff --git a/packages/spec/src/conversions/walk.ts b/packages/spec/src/conversions/walk.ts index 1e018380e5..307cd60e64 100644 --- a/packages/spec/src/conversions/walk.ts +++ b/packages/spec/src/conversions/walk.ts @@ -76,6 +76,49 @@ export function mapPages(stack: Dict, mapper: (page: Dict, path: string) => Dict return { ...stack, pages: nextPages }; } +/** + * Immutably map every dict item of a top-level array collection + * (`stack[key][]`) — the generic form of {@link mapPages}, for conversions + * targeting `objects`, `books`, `sharingRules`, `views`, …. + * + * `mapper` receives each item dict and its path (`[i]`) and returns the + * same reference (no change) or a new dict. Non-array collections and + * non-dict items pass through untouched. + */ +export function mapCollection( + stack: Dict, + key: string, + mapper: (item: Dict, path: string) => Dict, +): Dict { + const items = stack[key]; + if (!Array.isArray(items)) return stack; + + let changed = false; + const next = items.map((item, i) => { + if (!isDict(item)) return item; + const mapped = mapper(item, `${key}[${i}]`); + if (mapped !== item) changed = true; + return mapped; + }); + + if (!changed) return stack; + return { ...stack, [key]: next }; +} + +/** + * Rename `dict[from]` → `dict[to]`, immutably, only when `from` is present + * (non-null) and the canonical `to` is absent. Returns `null` when there is + * nothing to do — the caller keeps the original reference. + */ +export function renameKey(dict: Dict, from: string, to: string): Dict | null { + if (!(from in dict) || dict[from] == null) return null; + if (dict[to] != null) return null; // canonical already wins — nothing to do + const next: Dict = { ...dict }; + next[to] = next[from]; + delete next[from]; + return next; +} + /** Rename `config[from]` → `config[to]` on a node dict, immutably, only if `to` is absent. */ export function renameConfigKey(node: Dict, from: string, to: string): Dict | null { const config = node.config; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index 7834eb8ba2..f6d4f9c680 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -28,9 +28,11 @@ export const MIGRATION_SUPPORT_FLOOR = 10; /** * Protocol 11 step. * - * Mechanical: the three protocol-11 conversions graduated from the D2 load path + * Mechanical: the four protocol-11 conversions graduated from the D2 load path * (`flow-node-http-callout-rename`, `page-kind-jsx-to-html`, - * `flow-node-crud-filter-alias`). Semantic: the two non-lossless live windows the + * `flow-node-crud-filter-alias`, plus the backfilled + * `object-compactLayout-to-highlightFields` rename that shipped in 11.7.0 before + * the conversion layer existed). Semantic: the two non-lossless live windows the * conversion layer deliberately excludes — a composite `titleFormat` template and * SQL-ish RLS predicates — each surfaced as a structured TODO rather than a silent * or lossy auto-rewrite. @@ -39,8 +41,9 @@ const step11: MigrationStep = { toMajor: 11, rationale: 'Protocol 11 unified the divergent HTTP callout node types to `http`, made ' + - "`html` the canonical page kind (deprecating the `jsx` alias), and canonicalized " + - 'the CRUD flow-node filter key. These are mechanical and replay losslessly. Two ' + + "`html` the canonical page kind (deprecating the `jsx` alias), canonicalized " + + 'the CRUD flow-node filter key, and renamed object `compactLayout` to ' + + '`highlightFields` (ADR-0085). These are mechanical and replay losslessly. Two ' + 'related deprecations are semantic and cannot be auto-applied: a composite ' + '`titleFormat` render template has no single canonical `nameField`, and SQL-ish ' + 'RLS predicates must be rewritten to canonical CEL — both are delegated to the ' + @@ -49,6 +52,7 @@ const step11: MigrationStep = { 'flow-node-http-callout-rename', 'page-kind-jsx-to-html', 'flow-node-crud-filter-alias', + 'object-compactLayout-to-highlightFields', ], semantic: [ { @@ -80,9 +84,206 @@ const step11: MigrationStep = { ], }; +/** + * Protocol 12 step. + * + * The one metadata-facing break was a **secure-default flip**, not a shape + * change: `api.requireAuth` went from `false` to `true` (ADR-0056 D2), so + * anonymous `/data/*` access is denied unless explicitly opted out. Whether a + * deployment *intends* public data access is a judgment the chain cannot make + * — surfaced as a structured TODO. + */ +const step12: MigrationStep = { + toMajor: 12, + rationale: + 'Protocol 12 flipped the REST data-API default to authenticated ' + + '(`api.requireAuth: true`, ADR-0056 D2). No metadata shape changed, so there ' + + 'is nothing to rewrite mechanically; a deployment that intentionally serves ' + + 'data anonymously must now declare that posture explicitly.', + conversionIds: [], + semantic: [ + { + id: 'rest-requireauth-default-flip', + surface: 'api.requireAuth', + replacement: "explicit `api: { requireAuth: false }` (intentionally-public deployments only)", + reason: + 'The global default flipped from `false` to `true` in protocol 12: anonymous ' + + 'requests to the `/data/*` CRUD and batch endpoints are rejected with 401 ' + + 'unless the stack opts out. Whether anonymous access was intentional (demo / ' + + 'kiosk) or an accident is a security judgment no transform can make.', + acceptanceCriteria: + 'A deployment that relies on anonymous data access declares ' + + '`api: { requireAuth: false }` on the stack config (and accepts the boot ' + + 'warning); every other consumer verifies its clients authenticate. ' + + '`objectstack validate` and the consumer test suite pass.', + }, + ], +}; + +/** + * Protocol 13 step — the ADR-0090 permission-model-v2 breaking wave. + * + * ADR-0090 shipped these as **pre-launch one-step renames with no alias + * window** (its D3/D4 explicitly supersede the alias discipline). The lossless + * subset is preserved here as retired conversions so the chain replays it; the + * judgment-laden remainder (profiles, hierarchy re-homing, CEL rewrites, + * postures) is delegated as structured TODOs. + */ +const step13: MigrationStep = { + toMajor: 13, + rationale: + 'Protocol 13 (ADR-0090 P1) converged the permission model: Role became ' + + 'Position (flat; hierarchy lives on the business-unit tree), the Profile ' + + 'concept was removed, the OWD enum shrank to its canonical four values, and ' + + 'a custom object with an owner field and no `sharingModel` now defaults to ' + + '`private` instead of public. Key renames replay mechanically; everything ' + + 'that changes *meaning* (profile → position/permission-set design, hierarchy ' + + 're-homing, CEL identifier rewrites, sharing postures) is delegated with ' + + 'acceptance criteria.', + conversionIds: [ + 'stack-roles-to-positions', + 'owd-legacy-read-aliases', + 'sharing-recipient-role-to-position', + ], + semantic: [ + { + id: 'permission-set-profile-removed', + surface: 'permissionSet.kind / permissionSet.isProfile', + replacement: 'position-based assignment + permission-set grants (ADR-0090 D2)', + reason: + 'The Profile concept was removed: `isProfile` is gone from ' + + '`PermissionSetSchema` and the `profile` metadata kind folded into ' + + '`position`. Mapping a profile onto positions and permission-set grants is ' + + 'an authorization-design decision, not a rename.', + acceptanceCriteria: + 'No permission set declares `isProfile` or kind `profile`; the intended ' + + 'assignees hold equivalent grants via positions/permission sets. The access ' + + 'matrix (`os compile` access-matrix gate, where enabled) is reviewed and ' + + '`objectstack validate` passes.', + }, + { + id: 'position-hierarchy-flattened', + surface: 'position.parent / sharingRule recipient role_and_subordinates', + replacement: 'business-unit tree + `unit_and_subordinates` (ADR-0090 D3)', + reason: + 'Positions are flat in v2 — `parent` was removed and the ' + + '`role_and_subordinates` recipient with it; hierarchy lives on the ' + + 'business-unit tree, which expands a DIFFERENT structure than the retired ' + + 'role tree. Re-homing an org hierarchy is a judgment call.', + acceptanceCriteria: + 'No position declares `parent`; former `role_and_subordinates` rules are ' + + 're-expressed with `unit_and_subordinates` over an equivalent business-unit ' + + 'tree. Row visibility is unchanged for a representative fixture set.', + }, + { + id: 'cel-current-user-roles-to-positions', + surface: 'CEL/formula: current_user.roles', + replacement: 'current_user.positions', + reason: + 'The EvalUser/CEL contract renamed `current_user.roles` to ' + + '`current_user.positions`. The token lives inside free-form expression ' + + 'strings, where a blind textual substitution could corrupt string literals ' + + 'or comments — so the rewrite is delegated to the author.', + acceptanceCriteria: + 'No expression references `current_user.roles`; formula validation and ' + + '`objectstack validate` report no unknown-identifier errors; predicate ' + + 'behavior is unchanged for representative users.', + }, + { + id: 'owd-full-alias-removed', + surface: "object.sharingModel: 'full'", + replacement: "'public_read_write' or explicit sharing rules", + reason: + "The legacy `'full'` OWD alias implied full access (including transfer/ " + + 'delete) — wider than any canonical OWD value, so it has no lossless ' + + "target ('read'/'read_write' converted mechanically; this one did not). " + + 'Choosing between `public_read_write` and explicit sharing rules is a ' + + 'security-posture decision.', + acceptanceCriteria: + "No object declares sharingModel 'full'; the chosen replacement posture is " + + 'verified against the intended access (who can read/write/delete) for a ' + + 'representative fixture set.', + }, + { + id: 'sharing-model-secure-default', + surface: 'object.sharingModel (absent, custom object with owner field)', + replacement: 'an explicit `sharingModel` declaration', + reason: + 'ADR-0090 D1 secure default: a custom object with an owner field and NO ' + + '`sharingModel` now resolves `private` (it used to fall through to fully ' + + 'public). Restoring the old exposure must be a deliberate, visible ' + + 'declaration — the chain must not silently re-open data.', + acceptanceCriteria: + 'Every custom object that relied on the implicit public posture declares ' + + 'an explicit `sharingModel`; row visibility is verified for a ' + + 'representative fixture set (owners, non-owners, admins).', + }, + ], +}; + +/** + * Protocol 14 step. + * + * One metadata-facing break: the book audience gated arm renamed `{ profile }` + * → `{ permissionSet }` (ADR-0090 D2 fallout, shipped one-step pre-launch). + * Fully lossless → one retired conversion, no semantic residue. + */ +const step14: MigrationStep = { + toMajor: 14, + rationale: + 'Protocol 14 renamed the book audience gated arm from `{ profile }` to ' + + '`{ permissionSet }` (packages own permission sets, never positions — ' + + 'ADR-0090 D9). A pure key rename, preserved as a retired conversion; there ' + + 'is no semantic residue.', + conversionIds: ['book-audience-profile-to-permission-set'], + semantic: [], +}; + +/** + * Protocol 15 step. + * + * Mechanical: the ADR-0089 conditional-visibility unification — `visibleOn` + * (view forms) and `visibility` (page components) → canonical `visibleWhen`. + * These are LIVE D2 windows (the 15 loader still accepts the old keys); the + * chain replays the same transforms against source. Semantic: the `.strict()` + * flip on the three UI schemas — an unknown key is now a parse error, and only + * the author can say whether it was a typo, a wrong layer, or dead metadata. + */ +const step15: MigrationStep = { + toMajor: 15, + rationale: + 'Protocol 15 unified the conditional-visibility predicate under ' + + '`visibleWhen` (ADR-0089): view-form `visibleOn` and page-component ' + + '`visibility` are deprecated aliases, accepted and converted at load for ' + + 'this major. It also flipped `FormFieldSchema`, `FormSectionSchema`, and ' + + '`PageComponentSchema` to `.strict()` — a key those schemas do not declare ' + + 'is now a loud parse error instead of a silent strip (ADR-0049/0078).', + conversionIds: ['view-visibleOn-to-visibleWhen', 'page-component-visibility-to-visibleWhen'], + semantic: [ + { + id: 'ui-schemas-strict-unknown-keys', + surface: 'view form fields/sections · page components (undeclared keys)', + replacement: 'declared keys only (`visibleWhen` for visibility predicates)', + reason: + 'The `.strict()` flip (ADR-0089 D3a) turns a previously silently-stripped ' + + 'unknown key into a parse error. There is no mapping target for an ' + + 'arbitrary unknown key — auto-deleting it would be exactly the silent data ' + + 'loss ADR-0078 bans — so each occurrence needs the author to decide: fix ' + + 'the typo, move it to the right layer, or delete dead metadata.', + acceptanceCriteria: + '`objectstack validate` passes with no unknown-key parse errors on form ' + + 'fields, form sections, or page components.', + }, + ], +}; + /** All migration steps, keyed by the major they migrate into. */ export const MIGRATIONS_BY_MAJOR: Readonly> = { 11: step11, + 12: step12, + 13: step13, + 14: step14, + 15: step15, }; /** The majors that have a step, ascending. */ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 366f61d18c..18037c3fc0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1619,6 +1619,9 @@ importers: '@objectstack/metadata': specifier: workspace:* version: link:../metadata + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../metadata-core '@objectstack/objectql': specifier: workspace:* version: link:../objectql @@ -1910,6 +1913,9 @@ importers: '@objectstack/core': specifier: workspace:* version: link:../../core + '@objectstack/metadata-core': + specifier: workspace:* + version: link:../../metadata-core '@objectstack/spec': specifier: workspace:* version: link:../../spec diff --git a/scripts/release-spec-changes.sh b/scripts/release-spec-changes.sh new file mode 100755 index 0000000000..1cdbb98888 --- /dev/null +++ b/scripts/release-spec-changes.sh @@ -0,0 +1,44 @@ +#!/usr/bin/env bash +# ADR-0087 D4 — build the release `spec-changes.json` (the registry projection +# joined with the api-surface diff against the PREVIOUSLY PUBLISHED spec — the +# ADR-0059 §3 gate artifact, reused instead of discarded) and attach it to the +# `@objectstack/spec@` GitHub Release created by the changesets action. +# +# Inputs (env): +# PUBLISHED — the changesets action's `publishedPackages` JSON array +# GH_TOKEN — token for `gh release upload` +set -euo pipefail + +new_version=$(jq -r '.[] | select(.name=="@objectstack/spec") | .version' <<<"${PUBLISHED}") +if [ -z "${new_version}" ] || [ "${new_version}" = "null" ]; then + echo "::error::@objectstack/spec missing from publishedPackages — cannot attach spec-changes.json" + exit 1 +fi + +# Previous published version = newest on npm that isn't the one just published. +prev_version=$(npm view @objectstack/spec versions --json \ + | jq -r --arg v "${new_version}" '[.[] | select(. != $v)] | last // empty') + +workdir=$(mktemp -d) +prev_surface="" +if [ -n "${prev_version}" ]; then + echo "Diffing api-surface against previously published @objectstack/spec@${prev_version}" + tarball=$(cd "${workdir}" && npm pack "@objectstack/spec@${prev_version}" --silent) + tar -xzf "${workdir}/${tarball}" -C "${workdir}" package/api-surface.json 2>/dev/null || true + if [ -f "${workdir}/package/api-surface.json" ]; then + prev_surface="${workdir}/package/api-surface.json" + else + # Releases before protocol 15 did not ship api-surface.json in the npm + # artifact; the manifest is still attached, with empty added[]/removed[]. + echo "@objectstack/spec@${prev_version} ships no api-surface.json — added/removed stay empty" + fi +fi + +if [ -n "${prev_surface}" ]; then + pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts --previous-surface "${prev_surface}" +else + pnpm --filter @objectstack/spec exec tsx scripts/build-spec-changes.ts +fi + +gh release upload "@objectstack/spec@${new_version}" packages/spec/spec-changes.json --clobber +echo "Attached spec-changes.json to release @objectstack/spec@${new_version}" diff --git a/scripts/sync-template-versions.mjs b/scripts/sync-template-versions.mjs index 38548b94de..f5af6fd2be 100644 --- a/scripts/sync-template-versions.mjs +++ b/scripts/sync-template-versions.mjs @@ -49,3 +49,27 @@ if (changed === 0) { writeFileSync(templatePkgPath, JSON.stringify(templatePkg, null, 2) + '\n'); console.log(`✓ blank template: ${changed} @objectstack/* range(s) → ${range} (lockstep with create-objectstack@${version})`); } + +// Also re-stamp the manifest's `engines.protocol` range in the template stack +// config (ADR-0087 D1 — scaffolds populate the handshake field by default, the +// ratchet that closes grandfathering). Same lockstep rationale as above. +const templateConfigPath = join( + root, + 'packages/create-objectstack/src/templates/blank/objectstack.config.ts', +); +const major = String(version).split('.')[0]; +const config = readFileSync(templateConfigPath, 'utf8'); +const stamped = config.replace( + /engines:\s*\{\s*protocol:\s*'[^']*'\s*\}/, + `engines: { protocol: '^${major}' }`, +); +if (stamped === config) { + if (!config.includes(`engines: { protocol: '^${major}' }`)) { + console.error('✗ sync-template-versions: template objectstack.config.ts has no engines.protocol stamp to sync'); + process.exit(1); + } + console.log(`✓ blank template manifest already stamps engines.protocol '^${major}'`); +} else { + writeFileSync(templateConfigPath, stamped); + console.log(`✓ blank template manifest: engines.protocol → '^${major}'`); +}