diff --git a/CHANGELOG.md b/CHANGELOG.md index 8780b741..6866dfe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ a git tag (see [RELEASING.md](RELEASING.md)). ## [Unreleased] - **CI: replaced the third-party path-filter action with plain git.** GitHub began forcing Node 24 onto actions that target Node 20; the filter action dies silently mid-run and takes the whole gate with it via `conclusion`. A path filter is one `git diff` and does not need a dependency that can break underneath the repository. All nine original path patterns preserved and verified against a fixture of paths that must and must not match; the replacement also logs which files it saw and which way it decided. +- **Retired the API "freeze" — it was never real.** The list was described as frozen for `0.x` and had been edited 40 times; a stability promise revised whenever it becomes inconvenient is not a promise, it is a claim that quietly stops being true. The check itself is kept and renamed to what it actually does: it protects listed symbols from **silent removal**. It has never failed on an addition and cannot — new exports land freely. Removing or renaming a listed symbol is allowed and expected; it just has to be deliberate, which means dropping the entry in the same change and adding a migration note so consumers are told. `FROZEN_*` → `PROTECTED_*` throughout, with failure messages that now say what happened and what to do. Three inaccuracies fixed in passing: the docs page claimed entries "fail the build if they change" (only removal fails); `api-stability.md` and `substrate-api.md` cited "the frozen 17" where the check reports **20**. `RELEASING.md`, `PUBLISHING.md` and three canonical docs updated to match. Freeze language remains in prose elsewhere in `docs/canonical/` and is tracked as follow-up — it is stale wording, not a wrong claim about the gate. - **Documentation accuracy pass.** `docs/README.md` now indexes `docs/method/` (a whole top-level docs directory that was unlisted) and the `planning/world-substrate/` program. `PLAN.md`'s status header said *"Nothing in F1–F5 or C2 is built"* — false since Stage 1 landed — and the document contradicted itself, carrying both a current "Not started" line and a superseded one, plus a note calling F1.4 equivalence "pending" when it is measured. `CLAUDE.md` listed **17** frozen API entries where `check:api` reports **20**, and its layout table omitted `apps/observatory` entirely. Corrected, with the authoritative source named in each case so the next drift is self-diagnosing. ### Added diff --git a/PUBLISHING.md b/PUBLISHING.md index 9cf167d5..e7d14cd6 100644 --- a/PUBLISHING.md +++ b/PUBLISHING.md @@ -3,9 +3,9 @@ The six `@fundamental-engine/*` packages publish to npm **with provenance** via CI. This document is the mechanics; the policy (versioning, when to cut) is in [`RELEASING.md`](RELEASING.md). -> The public surface is frozen for `0.x` — see +> A listed subset of the public surface is protected from silent removal — see > [API stability](docs/canonical/api-stability.md). `pnpm check:api` (run in CI and the -> release workflow) fails if a frozen symbol changes, so a release can't break the contract by accident. +> release workflow) fails if a protected symbol disappears, so an export cannot vanish from a release by accident. ## The packages @@ -62,5 +62,5 @@ pnpm --filter "@fundamental-engine/*" exec npm version --no- ``` Per the `0.x` rules in [API stability](docs/canonical/api-stability.md), a breaking change to a -frozen symbol is a **minor** bump (`0.2 → 0.3`); additive and fix-only changes are patches. The private +protected symbol is a **minor** bump (`0.2 → 0.3`); additive and fix-only changes are patches. The private `site` / `starter` apps are versioned independently and not published. diff --git a/RELEASING.md b/RELEASING.md index 18f17a7a..2e138222 100644 --- a/RELEASING.md +++ b/RELEASING.md @@ -14,10 +14,10 @@ declares `three` as a peer dependency.) They follow [Semantic Versioning](https: - **patch** (`0.9.x`) — bug fixes, internal changes, and **additive** features (a new force, `FieldOption`, or `FieldHandle` method). Pre-1.0 a patch may also retire an **already-deprecated** - alias or change **experimental / unfrozen** surface — 0.9.3 dropped the retired alias packages in a - patch. What a patch never touches is the `check:api`-gated stable subset. -- **minor** (`0.x.0`) — a break to that **frozen stable subset** (renaming, removing, or changing the - signature of a `check:api`-gated symbol) bumps `0.MINOR`, under a **Breaking** heading with a + alias or change **unprotected** surface — 0.9.3 dropped the retired alias packages in a + patch. What a patch never does is remove a `check:api`-protected symbol. +- **minor** (`0.x.0`) — removing or renaming a **`check:api`-protected symbol**, or changing its + signature, bumps `0.MINOR`, under a **Breaking** heading with a migration note. - **major** (`1.0.0`) — the stability promise itself; from 1.0 on, standard SemVer applies. @@ -28,8 +28,8 @@ The engine's public surface is: the `@fundamental-engine/core` exports (`createF `FieldHandle`, the catalog, the conformance API), the `data-*` attribute vocabulary, the `` element attributes/methods, the `@fundamental-engine/vanilla` `FieldField` class and `mountField`, and the React adapter's props. The internal integrator, render code, and the site are not part of the public -contract. Its **stable subset** is frozen for `0.x` and gated by `pnpm check:api`; the broader surface -(deprecated aliases, the experimental substrate API) still evolves pre-1.0 — see +contract. A listed subset is **protected from silent removal** by `pnpm check:api` — that is a guard against +accidental breakage, not a freeze; the surface evolves pre-1.0 and additions never fail the check. See [API stability](docs/canonical/api-stability.md). > The packages are published to npm under the `@fundamental-engine` scope, **with provenance** (a signed @@ -76,7 +76,7 @@ contract. Its **stable subset** is frozen for `0.x` and gated by `pnpm check:api - **`pr-checks.yml`** — PR hygiene: a PR that changes `packages/` must add a CHANGELOG entry (the diff is checked, not the PR body), and every relative doc link must resolve (`pnpm check:links`). Its `conclusion-pr` job is a required check on `main`. -- **`api-surface.yml`** — on PRs touching the packages, posts the frozen-surface delta +- **`api-surface.yml`** — on PRs touching the packages, posts the protected-surface delta (base vs. head of `scripts/api-surface.data.mjs`) as a PR comment. Visibility only; the blocker is `check:api` in `ci.yml`. diff --git a/apps/site/src/components/ApiStamp.astro b/apps/site/src/components/ApiStamp.astro index bce5a221..e255eacb 100644 --- a/apps/site/src/components/ApiStamp.astro +++ b/apps/site/src/components/ApiStamp.astro @@ -9,10 +9,10 @@ // (disambiguate the two doors) // → experimental import { - FROZEN_VALUES, - FROZEN_TYPES, - FROZEN_ELEMENTS, - FROZEN_BODY_ATTR, + PROTECTED_VALUES, + PROTECTED_TYPES, + PROTECTED_ELEMENTS, + PROTECTED_BODY_ATTR, EXPERIMENTAL, } from '../../../../scripts/api-surface.data.mjs'; @@ -38,12 +38,12 @@ if (name) { const byPkg = (rows: T[]) => pkg ? rows.filter((r) => r.pkg === pkg) : rows; const hit = - byPkg(FROZEN_VALUES).find((v: { name: string }) => v.name === name) ?? - byPkg(FROZEN_TYPES).find((t: { name: string }) => t.name === name) ?? - byPkg(FROZEN_ELEMENTS).find((e: { tag: string }) => e.tag === name); + byPkg(PROTECTED_VALUES).find((v: { name: string }) => v.name === name) ?? + byPkg(PROTECTED_TYPES).find((t: { name: string }) => t.name === name) ?? + byPkg(PROTECTED_ELEMENTS).find((e: { tag: string }) => e.tag === name); if (hit) { stamp = { status: 'frozen', label: `frozen · ${hit.pkg}`, title: hit.note }; - } else if (name === FROZEN_BODY_ATTR) { + } else if (name === PROTECTED_BODY_ATTR) { stamp = { status: 'frozen', label: 'frozen · attribute contract', diff --git a/apps/site/src/pages/docs/api/attributes.astro b/apps/site/src/pages/docs/api/attributes.astro index 5169e5e0..9d29cb0f 100644 --- a/apps/site/src/pages/docs/api/attributes.astro +++ b/apps/site/src/pages/docs/api/attributes.astro @@ -3,7 +3,7 @@ import DocsLayout from '../../../layouts/DocsLayout.astro'; import { ATTRS, WRITEBACK, FIELD_ROOT_ATTRS } from '../../../lib/docs-api.ts'; import SeeAlso from '../../../components/SeeAlso.astro'; import ApiStamp from '../../../components/ApiStamp.astro'; -import { FROZEN_BODY_ATTR } from '../../../../../../scripts/api-surface.data.mjs'; +import { PROTECTED_BODY_ATTR } from '../../../../../../scripts/api-surface.data.mjs'; import { MANUAL_FORCES } from '@fundamental-engine/core'; import '../../../styles/force-glyphs.css'; @@ -35,7 +35,7 @@ const families = [
{a.name} - {a.name === FROZEN_BODY_ATTR && } + {a.name === PROTECTED_BODY_ATTR && } {a.type}{a.def ? <> · default {a.def} : null}
{a.desc}
diff --git a/apps/site/src/pages/docs/api/stability.astro b/apps/site/src/pages/docs/api/stability.astro index 825e36fc..b3127207 100644 --- a/apps/site/src/pages/docs/api/stability.astro +++ b/apps/site/src/pages/docs/api/stability.astro @@ -3,27 +3,28 @@ import DocsLayout from '../../../layouts/DocsLayout.astro'; import ApiStamp from '../../../components/ApiStamp.astro'; import StatusChip from '../../../components/StatusChip.astro'; import { - FROZEN_VALUES, FROZEN_TYPES, FROZEN_ELEMENTS, FROZEN_BODY_ATTR, EXPERIMENTAL, COMPAT_RULES, + PROTECTED_VALUES, PROTECTED_TYPES, PROTECTED_ELEMENTS, PROTECTED_BODY_ATTR, EXPERIMENTAL, COMPAT_RULES, } from '../../../../../../scripts/api-surface.data.mjs'; // This page renders the SAME data the lock enforces (scripts/api-surface.data.mjs), so it can't drift // from `pnpm check:api`. The canonical prose contract is docs/canonical/api-stability.md. const statusClass = (s: string) => `st st-${s}`; -const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS.length + 1; +const protectedCount = PROTECTED_VALUES.length + PROTECTED_TYPES.length + PROTECTED_ELEMENTS.length + 1; ---

API reference

API stability

- The architecture has stopped being fluid. The surface below is frozen for 0.x: - each symbol stays exported, from the same package, with the same kind, and does not break for the - life of the 0.x line. The freeze is enforced in CI by pnpm check:api — - {frozenCount} entries that fail the build if they change. + Pre-1.0, the surface evolves — new exports land freely and the check never fails on an addition. What the surface below is protected from is silent removal: + each symbol stays exported, from the same package, with the same kind. Enforced in CI by + pnpm check:api — {protectedCount} entries that fail the build if they disappear. + Removing one is allowed and expected; it just has to be deliberate, and it comes with a changelog + migration note.

@@ -37,7 +38,7 @@ const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS - {FROZEN_VALUES.map((v: any) => ( + {PROTECTED_VALUES.map((v: any) => ( @@ -49,7 +50,7 @@ const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS

createField has two doors on purpose: the core primitive is renderer-agnostic and host-required; @fundamental-engine/vanilla re-exports the host-bundled convenience so the - no-framework path stays one call. Both are frozen. + no-framework path stays one call. Both are protected.

@@ -58,7 +59,7 @@ const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS
SymbolPackageWhat it is
{v.name} value {v.pkg}
- {FROZEN_TYPES.map((t: any) => ( + {PROTECTED_TYPES.map((t: any) => ( ))} @@ -70,13 +71,13 @@ const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS
TypePackageWhat it is
{t.name} type {t.pkg}{t.note}
- {FROZEN_ELEMENTS.map((e: any) => ( + {PROTECTED_ELEMENTS.map((e: any) => ( ))} - + - +
SurfacePackageWhat it is
<{e.tag}> element {e.pkg}{e.note}
{FROZEN_BODY_ATTR} attribute {PROTECTED_BODY_ATTR} attribute core BODY_SELECTORThe body contract. "Every element is a body" via the {FROZEN_BODY_ATTR} attribute on ordinary elements.The body contract. "Every element is a body" via the {PROTECTED_BODY_ATTR} attribute on ordinary elements.
@@ -89,7 +90,7 @@ const frozenCount = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS
-

Experimental — not frozen

+

Experimental — no guarantee

These carry no stability guarantee and may change shape or be removed in any release. Some have exported building blocks today — those are shipped-but-unfrozen: present in the diff --git a/docs/canonical/README.md b/docs/canonical/README.md index d15d77cf..55e67b23 100644 --- a/docs/canonical/README.md +++ b/docs/canonical/README.md @@ -39,7 +39,7 @@ host, not the boundary. Canvas is one render surface, not the whole system. | [`authoring-and-recipes.md`](authoring-and-recipes.md) | Authoring surfaces and the FieldPattern system | | [`testing-and-conformance.md`](testing-and-conformance.md) | Test contracts and the conformance framework (the Lab-as-detector) | | [`stability-and-convergence.md`](stability-and-convergence.md) | The force-directed failure canon, per-mechanism: what the field damps or side-steps (seeded RNG, friction, DOM-owned placement, plausibility-over-accuracy) vs what it owns (`dynamic`-body jitter, tuning brittleness) — and the arguments we deliberately do NOT make | -| [`api-stability.md`](api-stability.md) | The freeze contract: the frozen surface, the experimental surface, the 0.x compatibility rules | +| [`api-stability.md`](api-stability.md) | The removal-protection contract: the protected surface, what carries no guarantee, the 0.x compatibility rules | | [`substrate-api.md`](substrate-api.md) | The shipped substrate read API (EXPERIMENTAL): `query` / `snapshot` / `diff` / `replay` / `projections` + governance lint, body authority + dynamic recoil, integrator modes, accumulator channels, first-class body identity | | [`agent-safety-model.md`](agent-safety-model.md) | The **Govern** verb: the safety/governance model for agents reading a field — three invariants (agent-readable ≠ agent-writable; snapshots withhold opaque `body.data` by default; projections reveal, never mutate) linking outward to `substrate-api.md` (EXPERIMENTAL) | | [`wallpaper-rule.md`](wallpaper-rule.md) | **"Substrate, not wallpaper" made operational** — the three-prong wallpaper test, the remedy order (derive → declare → demote → sugar; never deletion), the corollaries (injected-rng determinism, one writer per reading, proofs must be produced), and the per-plane guards | diff --git a/docs/canonical/agent-consumption-model.md b/docs/canonical/agent-consumption-model.md index 235e695a..b8c95c0b 100644 --- a/docs/canonical/agent-consumption-model.md +++ b/docs/canonical/agent-consumption-model.md @@ -17,7 +17,7 @@ > `field.snapshot()` return the live model — bodies, metrics, relationships, and per-force influence — > as plain structured data, and the projection registry's `agent-json` surface lets a Field Pattern > expose a tailored reading. `query()` is *the* substrate's agent-/tool-readable surface; see -> [substrate-api.md](substrate-api.md). (Shipped and callable, but not part of the frozen surface — its +> [substrate-api.md](substrate-api.md). (Shipped and callable, but not on the protected list — its > shape may change.) > > **Field Agent vs Software Agent — don't conflate them.** A **Field Agent** is a *runtime participant diff --git a/docs/canonical/agent-safety-model.md b/docs/canonical/agent-safety-model.md index 0e44398c..febf6fce 100644 --- a/docs/canonical/agent-safety-model.md +++ b/docs/canonical/agent-safety-model.md @@ -3,7 +3,7 @@ > consumers that *read* a field — the **Govern** verb of the substrate (Declare → Influence → Read > → Reveal → **Govern**). It is an overview: the mechanisms and their code live in > [`substrate-api.md`](substrate-api.md), which this doc links outward to. That surface is **shipped -> and code-confirmed but EXPERIMENTAL / unfrozen** (0.x) — it may change; it is not part of the frozen +> and code-confirmed but EXPERIMENTAL / unprotected** (0.x) — it may change; it is not part of the protected > stable baseline. # The agent safety model — the Govern verb diff --git a/docs/canonical/api-stability.md b/docs/canonical/api-stability.md index 7ebc8f0c..25a77e79 100644 --- a/docs/canonical/api-stability.md +++ b/docs/canonical/api-stability.md @@ -1,7 +1,7 @@ > **Status: canonical.** -> This document defines the **frozen public API surface for Fundamental `0.x`** and the compatibility -> rules that govern it: what is stable, what is experimental, how versions move, and how the alias -> window works. It is enforced in code by `scripts/api-surface.ts` (typechecked) and +> This document defines the **removal-protected public API surface** and the compatibility rules that +> govern it: what CI protects, what carries no guarantee, how versions move, and how the alias window +> works. It is **not** a freeze — see below. It is enforced in code by `scripts/api-surface.ts` (typechecked) and > `scripts/check-api-surface.mjs` (runtime), both run by `pnpm check:api` in CI. The shared machine > data is [`scripts/api-surface.data.mjs`](../../scripts/api-surface.data.mjs); the live reference page > is [`/docs/api/stability`](../../apps/site/src/pages/docs/api/stability.astro). Related contracts: @@ -10,15 +10,22 @@ # Fundamental API stability -The architecture has stopped being fluid. Recipes execute, data binds, the inspector reads the live -runtime, the gallery runs, the starter installs, the studies are data-driven. Before adding more -capability, the public surface is **frozen for `0.x`** so consumers can build against it without the -ground shifting. +**This is not a freeze, and calling it one was inaccurate.** Pre-1.0 the surface evolves; the list +below has been edited dozens of times as the engine improved, and it should be. A promise of stability +that is revised whenever it becomes inconvenient is not a promise — it is a claim that quietly stops +being true. -A symbol is "frozen" when it appears in the table below. Freezing means: it stays exported, from the -same package, with the same kind (value / type / element), and its shape does not break, for the life -of the `0.x` line. The freeze is mechanically enforced — `pnpm check:api` fails the build if a frozen -symbol is removed, renamed, moved between packages, or changes kind. +What the list actually does is narrower and worth keeping. A symbol on it is **protected from silent +removal**: if it stops being exported, stops being declared, or stops registering, `pnpm check:api` +fails the build. Nothing else. **The check never fails on an addition** — new exports, new optional +fields and new patterns land freely, and always have. + +The point is not to prevent removals. It is to make them *deliberate*: a removed export should be a +decision recorded in the CHANGELOG, not something a consumer discovers before we do. + +**Removing or renaming a listed symbol is allowed and expected.** Do it in one change: drop the entry +from [`scripts/api-surface.data.mjs`](../../scripts/api-surface.data.mjs), and add a CHANGELOG +migration note so consumers get told. > Package npm names: core is published as **`@fundamental-engine/core`**; the rest are > `@fundamental-engine/dom`, `@fundamental-engine/elements`, `@fundamental-engine/react`, `@fundamental-engine/vanilla`, @@ -26,7 +33,7 @@ symbol is removed, renamed, moved between packages, or changes kind. > **retired in 0.7.0** — install the specific package you need. > > **0.7.0 migration — `@fundamental-engine/platform` → `@fundamental-engine/dom`.** The DOM-binding -> package was renamed (it *is* the DOM layer); the frozen symbols `browserHost`, `createFieldPlatform`, +> package was renamed (it *is* the DOM layer); the protected symbols `browserHost`, `createFieldPlatform`, > `applyRecipe`, `bindData`, and the `FieldPlatform` type now live in `@fundamental-engine/dom`. This is > the one sanctioned cross-package move of the `0.x` line, gated by the minor bump. `@fundamental-engine/platform` > stays published as a **deprecated alias** that re-exports `dom`, so existing imports keep working; @@ -43,19 +50,19 @@ symbol is removed, renamed, moved between packages, or changes kind. | `browserHost` | `@fundamental-engine/dom` | value | The canonical DOM `FieldHost` for `createField`. | | `browserHost` | `@fundamental-engine/vanilla` | value | Re-export of the platform host for the no-framework path. | | `createFieldPlatform` | `@fundamental-engine/dom` | value | Wires the six native-first registries on a root. | -| `applyPattern` | `@fundamental-engine/dom` | value | Applies a Field Pattern to a live platform. `applyRecipe` is the frozen `@deprecated` alias (removed at `1.0`). | +| `applyPattern` | `@fundamental-engine/dom` | value | Applies a Field Pattern to a live platform. `applyRecipe` is the protected `@deprecated` alias (removed at `1.0`). | | `bindData` | `@fundamental-engine/dom` | value | Binds records → bodies; data drives the field. | -| `compilePattern` | `@fundamental-engine/core` | value | Pure `FieldPattern` → compiled plan (no DOM). `compileRecipe` is the frozen `@deprecated` alias (removed at `1.0`). | +| `compilePattern` | `@fundamental-engine/core` | value | Pure `FieldPattern` → compiled plan (no DOM). `compileRecipe` is the protected `@deprecated` alias (removed at `1.0`). | `createField` has **two doors on purpose**: the core primitive is renderer-agnostic and host-required; `@fundamental-engine/vanilla` re-exports the host-bundled convenience so the no-framework path stays one call. -Both are frozen; the vanilla door must keep auto-supplying `browserHost()`. +Both are protected; the vanilla door must keep auto-supplying `browserHost()`. ### Types | Type | Package | What it is | | --- | --- | --- | -| `FieldPattern` | `@fundamental-engine/core` | The Field Pattern schema. `FieldRecipe` is the frozen `@deprecated` alias (removed at `1.0`). | +| `FieldPattern` | `@fundamental-engine/core` | The Field Pattern schema. `FieldRecipe` is the protected `@deprecated` alias (removed at `1.0`). | | `FieldHost` | `@fundamental-engine/core` | The renderer-agnostic host contract `createField` requires; `browserHost` implements it. | | `FieldPlatform` | `@fundamental-engine/dom` | The surface `createFieldPlatform` returns. | @@ -72,7 +79,7 @@ none will be introduced as the body mechanism. The custom elements are ``) and ``; the pre-rename ``/`` tags are **not** registered (the rename left no element aliases). -## The experimental surface (not frozen) +## The unprotected surface These carry **no** stability guarantee and may change shape or be removed in any release. Some have exported building blocks today — those are *shipped-but-unfrozen*: present in the package, but not part @@ -80,11 +87,11 @@ of the contract until they are added to the table above. | Area | Status | Notes | | --- | --- | --- | -| `FieldHandle` (full surface) | partial | The handle shape is not frozen as a type. Entry points that return it (`createField`, `createBrowserField`) are frozen, but new methods may be added in any patch. | -| `FieldHandle` diagnostic accessors | shipped-unfrozen | `particleCount(): number` and `energy(): { kinetic, thermal, total, count }` ship in `@fundamental-engine/core` and are proxied on ``. Safe to use; not frozen until 1.0. | -| Substrate read API | shipped-unfrozen · EXPERIMENTAL | `query` / `snapshot` / `diff` / `replay` / `projections` (+ `lint`), `data-authority` / `Body.authority`, `createField({ integrator: 'fixed' })`, and the accumulator channels — all shipped across the surfaces but **not** in the frozen 17 and may change shape or be removed. Full reference: [substrate-api.md](substrate-api.md). | +| `FieldHandle` (full surface) | partial | The handle shape is not protected as a type. Entry points that return it (`createField`, `createBrowserField`) are protected, but new methods may be added in any patch. | +| `FieldHandle` diagnostic accessors | shipped-unprotected | `particleCount(): number` and `energy(): { kinetic, thermal, total, count }` ship in `@fundamental-engine/core` and are proxied on ``. Safe to use; not on the protected list. | +| Substrate read API | shipped-unprotected · EXPERIMENTAL | `query` / `snapshot` / `diff` / `replay` / `projections` (+ `lint`), `data-authority` / `Body.authority`, `createField({ integrator: 'fixed' })`, and the accumulator channels — all shipped across the surfaces but **not** on the protected list, and may change shape or be removed. Full reference: [substrate-api.md](substrate-api.md). | | Advanced diagnostics | partial | `DIAGNOSTICS` / `DIAGNOSTIC_LENS` / `draw*` primitives ship but are unfrozen. | -| Performance budget | shipped-unfrozen | `inspectBudget()`, `withinBudget()`, `DEFAULT_BUDGET`, `BudgetFinding` ship in `@fundamental-engine/core`; `FieldPerf` (frame-duration split, adaptive governor) ships shipped-but-unfrozen in `@fundamental-engine/dom` as `createFieldPerf` + `QualityGovernor`. | +| Performance budget | shipped-unprotected | `inspectBudget()`, `withinBudget()`, `DEFAULT_BUDGET`, `BudgetFinding` ship in `@fundamental-engine/core`; `FieldPerf` (frame-duration split, adaptive governor) ships shipped-but-unfrozen in `@fundamental-engine/dom` as `createFieldPerf` + `QualityGovernor`. | | Visual recipe editor | absent | No editor UI; the authoring toolkit is the substrate to build one on. | | GPU / WebGPU backend | planned | A named direction; the 16 shipped render modes are CPU/canvas. | | Multi-root bridge | absent | No API for coordinating multiple `` instances yet. | @@ -93,20 +100,20 @@ of the contract until they are added to the table above. ## Compatibility rules -1. **Pre-1.0 semver.** In `0.x` the **minor** is the breaking position. A breaking change to a frozen +1. **Pre-1.0 semver.** In `0.x` the **minor** is the breaking position. A breaking change to a protected symbol bumps `0.MINOR` (`0.2 → 0.3`); additive and fix-only changes bump the patch. Consumers should pin to `~0.MINOR`. 2. **Additive-only within a minor.** New exports, new optional fields, and new recipes/modes may land - in a patch. Renaming, removing, or changing the signature/shape of a frozen symbol requires a minor + in a patch. Renaming, removing, or changing the signature/shape of a protected symbol requires a minor bump and a migration note. 3. **`createField` keeps both doors.** The host-required core primitive and the host-bundled vanilla convenience are both preserved; the vanilla door must keep auto-supplying `browserHost()`. -4. **Package ownership is part of the contract.** Frozen symbols do not move between packages within +4. **Package ownership is part of the contract.** Protected symbols do not move between packages within `0.x` (`compilePattern`/`FieldPattern`/`FieldHost` → core; `createFieldPlatform`/`applyPattern`/ `bindData`/`FieldPlatform`/`browserHost` → dom; `field-root`/`field-cell` → elements). The old - `compileRecipe`/`FieldRecipe`/`applyRecipe` names are frozen `@deprecated` aliases of these, same + `compileRecipe`/`FieldRecipe`/`applyRecipe` names are protected `@deprecated` aliases of these, same package, removed at `1.0`. -5. **Bodies are an attribute contract.** `[data-body]` on ordinary elements is the frozen authoring +5. **Bodies are an attribute contract.** `[data-body]` on ordinary elements is the protected authoring surface; there is no body element. 6. **The `forces-*` compatibility layer was removed — there is no `forces:*` event compatibility contract.** The `forces-ui → field-ui → Fundamental` rename was a **hard** rename: the ``/`` @@ -148,19 +155,19 @@ summary. deduped so it warns at most once. CSS-var aliases cannot be intercepted (CSS reads are invisible to JS), so they are doc-only. Everything here is **removed at `1.0`**. -## How the freeze is enforced +## How the check is enforced -- [`scripts/api-surface.ts`](../../scripts/api-surface.ts) imports every frozen value and type from its - owning package. Removing, renaming, or changing the kind of a frozen symbol is a **compile error** +- [`scripts/api-surface.ts`](../../scripts/api-surface.ts) imports every protected value and type from its + owning package. Removing, renaming, or changing the kind of a protected symbol is a **compile error** there (a value-import of a type, or vice versa, also fails — so the *kind* is locked, not just the name). - [`scripts/check-api-surface.mjs`](../../scripts/check-api-surface.mjs) verifies the parts tsc can't - see: each frozen value resolves at runtime in the built dist, each frozen type is exported in source, + see: each protected value resolves at runtime in the built dist, each protected type is exported in source, each element tag is registered (`customElements.define`), `data-body` is still in core's `BODY_SELECTOR`, and the data file and the type gate name the same symbols. - Both run via `pnpm check:api`, in CI right after `pnpm check:dist`. -Changing the frozen surface on purpose means editing +Changing the protected surface on purpose means editing [`scripts/api-surface.data.mjs`](../../scripts/api-surface.data.mjs) **and** [`scripts/api-surface.ts`](../../scripts/api-surface.ts) **and** this document together, with a migration note and a `0.MINOR` bump. @@ -168,5 +175,5 @@ migration note and a `0.MINOR` bump. ## Status The packages are **published to npm** under the `@fundamental-engine` scope (`@fundamental-engine/core` -and the five adapters: dom, elements, react, vanilla, three). This freeze defines the `0.x` contract +and the five adapters: dom, elements, react, vanilla, three). This list defines what CI protects from silent removal consumers build against; the publish steps and order are in [`PUBLISHING.md`](../../PUBLISHING.md). diff --git a/docs/canonical/substrate-api.md b/docs/canonical/substrate-api.md index 174a5d70..6337eabb 100644 --- a/docs/canonical/substrate-api.md +++ b/docs/canonical/substrate-api.md @@ -3,7 +3,7 @@ > queryable, time-travelable, projectable* substrate — not just a thing that draws or writes CSS > variables. Every method below is **shipped** in `@fundamental-engine/core` (verified against > `packages/core/src/engine/types.ts` + `field.ts`), but the whole surface is **EXPERIMENTAL**: it is -> *not* part of the frozen 17 (see [api-stability.md](api-stability.md)) and may change shape or be +> *not* on the protected list (see [api-stability.md](api-stability.md)) and may change shape or be > removed in any release. Use it; don't pin a contract to it yet. Follows the > [status rule](documentation-standards.md): nothing here is called shipped unless code confirms it. @@ -474,7 +474,7 @@ behaves exactly as before. --- -## Status & relation to the frozen surface +## Status & relation to the protected surface | Capability | Surface | Status | |---|---|---| @@ -488,7 +488,7 @@ behaves exactly as before. | Accumulator channels | `Env.accum` / `FieldImpulseAccumulator` | shipped-unfrozen · EXPERIMENTAL | | Runtime policy + budgets | `createField({ policy })` · `setPolicy` · `field.policy` | shipped-unfrozen · EXPERIMENTAL (motion + privacy budgets wired; others declared) | -None of these are in the frozen 17 ([api-stability.md](api-stability.md)). They are present in the +None of these are on the protected list ([api-stability.md](api-stability.md)). They are present in the package and safe to use, but carry **no** stability guarantee until explicitly added to the stable table. The substrate API is JS-first; native-plane (`swift/`, `android/`) parity is tracked separately. diff --git a/scripts/api-surface.data.mjs b/scripts/api-surface.data.mjs index 103f0b64..6f81c449 100644 --- a/scripts/api-surface.data.mjs +++ b/scripts/api-surface.data.mjs @@ -1,17 +1,20 @@ /** - * The frozen public API surface for Fundamental `0.x` — the single data source shared by the lock + * The removal-protected public API surface — the single data source shared by the check * (`scripts/check-api-surface.mjs`) and the docs page (`/docs/api/stability`). The prose contract is - * `docs/canonical/api-stability.md`; the hard type/value gate is `scripts/api-surface.ts`. + * `docs/canonical/api-stability.md`; the type/value gate is `scripts/api-surface.ts`. * - * Editing this list changes the public contract. Per the compatibility rules below, removing, - * renaming, or changing the kind/shape of any frozen entry is a BREAKING change (a 0.MINOR bump in - * the 0.x line) and needs a CHANGELOG migration note. Additions are fine in a PATCH. + * This is NOT a freeze. Pre-1.0 the surface evolves and new exports land freely — the check never + * fails on an addition. What it protects against is a listed symbol silently DISAPPEARING from a + * published package, which a consumer would otherwise discover before CI does. + * + * Removing or renaming a listed entry is therefore a deliberate act: update this list in the same + * change, and record the removal in the CHANGELOG so consumers get a migration note. * * Package npm names: core = `@fundamental-engine/core`; others = `@fundamental-engine/{dom,elements,react,vanilla,three}`. */ -/** Frozen value exports — functions/consts that must remain importable, with their owning package. */ -export const FROZEN_VALUES = [ +/** Value exports protected from silent removal — functions/consts, with their owning package. */ +export const PROTECTED_VALUES = [ { pkg: '@fundamental-engine/core', name: 'createField', note: 'host-required primitive — throws without opts.host (the renderer-agnostic door).' }, { pkg: '@fundamental-engine/core', name: 'compilePattern', note: 'pure FieldPattern → compiled plan (no DOM).' }, { pkg: '@fundamental-engine/core', name: 'compileRecipe', note: 'DEPRECATED alias of compilePattern (recipe → Pattern rename) — removed at 1.0; frozen until then.' }, @@ -28,7 +31,7 @@ export const FROZEN_VALUES = [ ]; /** Frozen type exports — interfaces that must remain exported, with their owning package. */ -export const FROZEN_TYPES = [ +export const PROTECTED_TYPES = [ { pkg: '@fundamental-engine/core', name: 'FieldPattern', note: 'the pattern schema (recipes/schema.ts).' }, { pkg: '@fundamental-engine/core', name: 'FieldRecipe', note: 'DEPRECATED alias of FieldPattern (recipe → Pattern rename) — removed at 1.0; frozen until then.' }, { pkg: '@fundamental-engine/core', name: 'FieldHost', note: 'the renderer-agnostic host contract createField requires; browserHost implements it.' }, @@ -36,7 +39,7 @@ export const FROZEN_TYPES = [ ]; /** Frozen custom-element tag names, owned by @fundamental-engine/elements. */ -export const FROZEN_ELEMENTS = [ +export const PROTECTED_ELEMENTS = [ { pkg: '@fundamental-engine/elements', tag: 'field-root', note: 'one background field per page; scans the document for [data-body].' }, { pkg: '@fundamental-engine/elements', tag: 'field-cell', note: 'a scoped local field region.' }, ]; @@ -46,7 +49,7 @@ export const FROZEN_ELEMENTS = [ * ordinary elements (core BODY_SELECTOR). There is NO tag and one must not be introduced * as the body mechanism. */ -export const FROZEN_BODY_ATTR = 'data-body'; +export const PROTECTED_BODY_ATTR = 'data-body'; /** Experimental surface — explicitly NOT frozen; may change shape or be removed in any release. */ export const EXPERIMENTAL = [ @@ -95,10 +98,11 @@ export const EXPERIMENTAL = [ /** Compatibility rules for the 0.x line. */ export const COMPAT_RULES = [ - 'Pre-1.0 semver: in 0.x the MINOR is the breaking position. A breaking change to any frozen symbol bumps 0.MINOR (e.g. 0.2 → 0.3); additive and fix-only changes bump PATCH. Consumers should pin to ~0.MINOR.', - 'The stable surface is additive-only within a 0.MINOR line: new exports, new optional fields, and new recipes/modes may land in a PATCH; renaming, removing, or changing the signature/shape of a frozen symbol requires a MINOR bump and a migration note.', - 'createField is frozen in BOTH Fundamental (host-required primitive; throws without opts.host) and @fundamental-engine/vanilla (host-bundled convenience). Both contracts are preserved; the vanilla door must keep auto-supplying browserHost.', - 'Package ownership is part of the contract and must not drift within 0.x: compilePattern / FieldPattern / FieldHost (and the deprecated aliases compileRecipe / FieldRecipe) are core (Fundamental); createFieldPlatform / applyPattern / bindData / FieldPlatform / browserHost (and the deprecated alias applyRecipe) are @fundamental-engine/dom; field-root / field-cell are @fundamental-engine/elements.', - 'Bodies are a stable ATTRIBUTE contract: [data-body] on ordinary elements is the frozen authoring surface. There is no tag and none will be introduced as the body mechanism.', - 'The experimental surface carries no semver guarantee. Diagnostics/agent/render-mode exports that happen to ship today are shipped-but-unfrozen — treat them as experimental until explicitly added to the frozen list.', + 'Pre-1.0, the surface evolves. New exports, new optional fields and new patterns/modes land freely; the check never fails on an addition. Treat 0.x as pre-stable and pin to ~0.MINOR.', + 'What is protected is REMOVAL, not change. If a listed symbol stops being exported, stops being declared, or stops registering, CI fails — because a consumer would otherwise find out before we did.', + 'Removing or renaming a listed symbol is allowed and expected. Do it deliberately: update the list in the same change and record it in the CHANGELOG so consumers get a migration note.', + 'createField is listed in BOTH Fundamental (host-required primitive; throws without opts.host) and @fundamental-engine/vanilla (host-bundled convenience). Both doors are protected; the vanilla door must keep auto-supplying browserHost.', + 'Package ownership is part of what is protected: compilePattern / FieldPattern / FieldHost (and the deprecated aliases compileRecipe / FieldRecipe) are core; createFieldPlatform / applyPattern / bindData / FieldPlatform / browserHost (and the deprecated alias applyRecipe) are @fundamental-engine/dom; field-root / field-cell are @fundamental-engine/elements. Moving one between packages is a removal from the old package.', + 'Bodies are an ATTRIBUTE contract: [data-body] on ordinary elements is the authoring surface. There is no tag and none will be introduced as the body mechanism.', + 'Anything not listed carries no guarantee at all — including diagnostics, agent and render-mode exports that happen to ship today. Absence from the list is not a promise of instability, only of silence.', ]; diff --git a/scripts/api-surface.ts b/scripts/api-surface.ts index 2fe3abb8..8ce77da5 100644 --- a/scripts/api-surface.ts +++ b/scripts/api-surface.ts @@ -20,7 +20,7 @@ import { FIELD_VERSION as reactFieldVersion } from '@fundamental-engine/react'; import { FIELD_VERSION as elementsFieldVersion } from '@fundamental-engine/elements'; // Reference every frozen VALUE so its removal/rename is a compile error here. -const FROZEN_VALUES = [ +const PROTECTED_VALUES = [ coreCreateField, compilePattern, compileRecipe, // deprecated alias of compilePattern (removed at 1.0) — still frozen until then @@ -37,7 +37,7 @@ const FROZEN_VALUES = [ reactFieldVersion, elementsFieldVersion, ] as const; -void FROZEN_VALUES; +void PROTECTED_VALUES; // Reference every frozen TYPE so its removal/rename is a compile error here. export type __FrozenTypes = [FieldPattern, FieldRecipe, FieldHost, FieldPlatform]; diff --git a/scripts/check-api-surface.mjs b/scripts/check-api-surface.mjs index de5ff62c..d239fab6 100644 --- a/scripts/check-api-surface.mjs +++ b/scripts/check-api-surface.mjs @@ -16,7 +16,7 @@ import { execFileSync } from 'node:child_process'; import { fileURLToPath, pathToFileURL } from 'node:url'; import { dirname, join, resolve } from 'node:path'; import { - FROZEN_VALUES, FROZEN_TYPES, FROZEN_ELEMENTS, FROZEN_BODY_ATTR, + PROTECTED_VALUES, PROTECTED_TYPES, PROTECTED_ELEMENTS, PROTECTED_BODY_ATTR, } from './api-surface.data.mjs'; const root = resolve(dirname(fileURLToPath(import.meta.url)), '..'); @@ -49,39 +49,39 @@ function greps(dir, pattern) { } // 1 · frozen values resolve at runtime -for (const { pkg, name } of FROZEN_VALUES) { +for (const { pkg, name } of PROTECTED_VALUES) { const mod = await moduleFor(pkg); - if (!(name in mod)) fail(`frozen value missing: ${pkg} → ${name} (not exported by the built dist)`); + if (!(name in mod)) fail(`removed from the public surface: ${pkg} → ${name} (no longer exported by the built dist). If this removal is intended, drop it from api-surface.data.mjs in this change and add a CHANGELOG migration note.`); } // 2 · frozen types exported in source -for (const { pkg, name } of FROZEN_TYPES) { +for (const { pkg, name } of PROTECTED_TYPES) { const pat = `export (interface|type|class|enum) ${name}\\b|export (type )?\\{[^}]*\\b${name}\\b`; - if (!greps(srcOf(pkg), pat)) fail(`frozen type missing: ${pkg} → ${name} (no exported declaration found in src)`); + if (!greps(srcOf(pkg), pat)) fail(`removed from the public surface: ${pkg} → ${name} (no exported type declaration found in src). If intended, drop it from api-surface.data.mjs and add a CHANGELOG migration note.`); } // 3 · frozen custom-element tags registered -for (const { pkg, tag } of FROZEN_ELEMENTS) { +for (const { pkg, tag } of PROTECTED_ELEMENTS) { const distDir = join(root, 'packages', DIR[pkg], 'dist'); - if (!greps(distDir, `customElements\\.define\\('${tag}'`)) fail(`frozen element missing: <${tag}> (no customElements.define('${tag}') in ${pkg} dist)`); + if (!greps(distDir, `customElements\\.define\\('${tag}'`)) fail(`removed from the public surface: <${tag}> (no customElements.define('${tag}') in ${pkg} dist). If intended, drop it from api-surface.data.mjs and add a CHANGELOG migration note.`); } // 4 · the body attribute contract const core = await moduleFor('@fundamental-engine/core'); -if (typeof core.BODY_SELECTOR !== 'string' || !core.BODY_SELECTOR.includes(`[${FROZEN_BODY_ATTR}]`)) { - fail(`body contract broken: core BODY_SELECTOR no longer matches [${FROZEN_BODY_ATTR}] (got: ${JSON.stringify(core.BODY_SELECTOR)})`); +if (typeof core.BODY_SELECTOR !== 'string' || !core.BODY_SELECTOR.includes(`[${PROTECTED_BODY_ATTR}]`)) { + fail(`body contract broken: core BODY_SELECTOR no longer matches [${PROTECTED_BODY_ATTR}] (got: ${JSON.stringify(core.BODY_SELECTOR)})`); } // 5 · the hard gate (api-surface.ts) and this data file name the same frozen values const lockSrc = await readFile(join(root, 'scripts', 'api-surface.ts'), 'utf8'); -for (const { name } of FROZEN_VALUES) { - if (!new RegExp(`\\b${name}\\b`).test(lockSrc)) fail(`drift: ${name} is frozen in api-surface.data.mjs but not referenced in api-surface.ts (the type/value gate)`); +for (const { name } of PROTECTED_VALUES) { + if (!new RegExp(`\\b${name}\\b`).test(lockSrc)) fail(`drift: ${name} is listed in api-surface.data.mjs but not referenced in api-surface.ts (the type/value gate)`); } -for (const { name } of FROZEN_TYPES) { - if (!new RegExp(`\\b${name}\\b`).test(lockSrc)) fail(`drift: type ${name} is frozen in api-surface.data.mjs but not referenced in api-surface.ts`); +for (const { name } of PROTECTED_TYPES) { + if (!new RegExp(`\\b${name}\\b`).test(lockSrc)) fail(`drift: type ${name} is listed in api-surface.data.mjs but not referenced in api-surface.ts`); } -const total = FROZEN_VALUES.length + FROZEN_TYPES.length + FROZEN_ELEMENTS.length + 1; +const total = PROTECTED_VALUES.length + PROTECTED_TYPES.length + PROTECTED_ELEMENTS.length + 1; if (problems.length) { console.error(`✗ public API surface check failed (${problems.length} problem(s)):`); for (const p of problems) console.error(` ${p}`); @@ -89,4 +89,4 @@ if (problems.length) { console.error('and docs/canonical/api-stability.md with a migration note + a 0.MINOR bump.'); process.exit(1); } -console.log(`✓ public API surface intact — ${total} frozen entries (${FROZEN_VALUES.length} values, ${FROZEN_TYPES.length} types, ${FROZEN_ELEMENTS.length} elements, the [${FROZEN_BODY_ATTR}] body contract).`); +console.log(`✓ public API surface intact (no removals) — ${total} protected entries (${PROTECTED_VALUES.length} values, ${PROTECTED_TYPES.length} types, ${PROTECTED_ELEMENTS.length} elements, the [${PROTECTED_BODY_ATTR}] body contract).`); diff --git a/scripts/gen-parity-matrix.mjs b/scripts/gen-parity-matrix.mjs index 06b2c8b4..7289b0e6 100644 --- a/scripts/gen-parity-matrix.mjs +++ b/scripts/gen-parity-matrix.mjs @@ -112,7 +112,7 @@ function jsOverlayModes() { /** Frozen public value exports (createField, browserHost, bindData, …) from the api-surface data. */ async function jsFrozenValues() { const mod = await import(resolve(root, 'scripts/api-surface.data.mjs')); - return new Set(mod.FROZEN_VALUES.map((v) => v.name)); + return new Set(mod.PROTECTED_VALUES.map((v) => v.name)); } // ── Swift extractors ──────────────────────────────────────────────────────────────────────────────