Skip to content

Commit 134b410

Browse files
os-muskclaude
andauthored
fix(metadata-core,spec): the artifact door stops replaying the default-flip class, so an authored hidden: true app is no longer registered unpublished (#17899)
Fixes #17885 Clause-②: yes The artifact-ingestion door replayed `app-hidden-to-unpublished` — a DEFAULT FLIP — over authored metadata, so a compiled artifact carrying `defineApp({ hidden: true })` reached registration as `_unpublished: true` and was then withheld by `filterAppForUser` from every user without `studio.access` / `setup.access`. The door now refuses that one class by id. Nothing else about the door, the flag, the entry or the consumer moves. ## Where the fix is, and why it is not anywhere else | candidate | why not | |---|---| | `rest-server.ts:3215` (the consumer) | READ-ONLY in the declared surface, and correct: withholding on `_unpublished` is the ADR-0045 gate. The defect is who WRITES `_unpublished`. | | flip the door to `includeRetired: false` | Reverses #12772. The retired window exists so an artifact built by 17.1.0 tooling carrying `allowRestore`/`allowPurge` still boots instead of dying at the tombstone. This branch's own suite proves it would break: the firing control at `artifact-forward-conversion.test.ts` drives exactly that artifact through the same window. | | the registry entry's `apply` | The entry cannot answer the question. A machine-written pre-split row and an author who wrote `hidden: true` yesterday are byte-identical at the item level, so no predicate over the app distinguishes them. Whether the rewrite is sound depends on the CALLER's evidence, not on the item. | | `retiredFromLoadPath` on the entry | Does not reach here at all. #16864's determination landed on this base (PR #17888, commit `29dd1a6ddb`): both recorded decisions bought the SEAMS arm, the flag is `live` but its jurisdiction is the authoring funnel and nothing else. `types.ts` now says so in terms: "For a conversion whose old and new shapes are both legal and mean different things (a default flip, not a rename), the data-at-rest seams will still apply it." | ⇒ The seam is where the evidence lives, so the seam is where the refusal goes. `applyConversions` gains `excludeConversionIds` — "my evidence cannot carry this entry" — and the door names the one class it refuses, with the reason beside the id. **Why THIS seam and not the others.** The other two data-at-rest seams argue from "a row at rest has no author to teach". An artifact does have one: it is compiled from a source that still exists, and the door's own boot warning already tells that author to rebuild. Worse, the door's evidence is the artifact's **declared `engines.protocol` floor**, not its age — and `^17.0.0` is the range `create-objectstack` stamps, so an app authored today lands inside the window. For a lossless delete or a rename of a shape the schema now refuses, guessing wrong costs nothing: the key is inert and the replay is a rescue. For a default flip, guessing wrong destroys authored intent. Same measurement #16693 made for `field-required-notnull-explicit`, and the WITHDRAWN block that removal left in `registry.ts` says it in advance: "Before setting that flag on a DEFAULT FLIP — as opposed to a lossless delete or a rename — read that card, because the flag does not mean what its name and every docblock around it say it means." ## ⛔ What is deliberately NOT settled here - **The #16864 ledger question is not reopened.** `retiredFromLoadPath` keeps exactly the jurisdiction that determination gave it. This adds a seam-level refusal beside it; it does not redefine the flag. - **`registry.ts` is untouched.** No entry is withdrawn, no `apply` narrowed, no fixture moved. The entry still fires at the stored-row seam and through `os migrate meta`, which is what its own docblock claims — narrowing the door is what makes that claim TRUE again rather than falsifying it further. - **The consumer is untouched.** `packages/rest/src/rest-server.ts` is not in the diff. ## The pin: subject, two controls, and the post-parse leg `packages/metadata-core/src/artifact-forward-conversion.test.ts` — six cases, modelled on the #16693 block directly above them: 1. **SUBJECT** — floor `^17.0.0`, runtime `17.4.0`: `verdict === 'converted-forward'` and `authoredFloor === '17.0.0'` (anti-vacuity: the window really is open), `apps[0].hidden === true`, `_unpublished` undefined, no `app-hidden-to-unpublished` notice, and copy-on-write hands back the same reference. 2. **⭐⭐ POST-STRICT-PARSE** — the same door output fed to `ObjectStackDefinitionSchema.parse`, which is what `MetadataPlugin._parseAndRegisterArtifact` does at `plugin.ts:915`, and the app read back OUT of the parsed object. This is the object that reaches registration; a pin on the conversion's return value alone would not catch a parse that re-introduced the key. 3. **NEGATIVE CONTROL** — floor `^99.0.0`: `verdict === 'authored-current'`, zero notices. The instrument can answer "no" for the other reason. 4. **FIRING CONTROL 1** — a NON-retired conversion (`page-kind-jsx-to-html`) still fires in the subject's own window, on an artifact that also carries the hidden app. 5. **FIRING CONTROL 2** — a RETIRED conversion still fires in that same window (`allowRestore`/`allowPurge` stripped). This is the control this particular fix could plausibly have broken, and it is what makes "#12772 is not reversed" a measurement rather than a claim. 6. **SEAM SCOPE** — `applyConversionsToStoredItem('app', …)` still converts. A fix that had neutered the entry would go green on all five legs above and silently strand the stored population. Plus three cases on the primitive in `packages/spec/src/conversions/conversions.test.ts`: the refusal, its firing control (one id refused, the rest of the chain runs, in the same call), and "absent or empty list changes nothing". ## Ablation — both halves, on-disk proof and hash-verified restore Each leg: assert the file equals its HEAD blob · count the marker (must be 1) · delete the line · re-count (must be 0) and assert the blob hash MOVED · run · restore with `git checkout HEAD -- FILE` · assert the hash is back and `git diff HEAD` is empty. A `trap` on EXIT/INT/TERM holds the restore on the crash path, and every path is absolute. **A — the door half** (`excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE,` removed). Marker 1 to 0, blob `309b8444` to `f2e84cab`: ``` Test Files 1 failed | 15 passed (16) Tests 3 failed | 275 passed (278) FAIL leaves `hidden: true` alone on an artifact the retired window IS open for AssertionError: the authored navigation choice is untouched: expected undefined to be true FAIL registers `hidden: true` — asserted AFTER the strict parse the door feeds AssertionError: what registration receives: expected undefined to be true FAIL still applies a non-retired conversion in that same window (its app-side line) ``` ⭐ The direction is not just "red": the SUBJECT and the POST-PARSE legs fail, while the NEGATIVE control, FIRING CONTROL 2 and the SEAM-SCOPE leg stay green — exactly the three that must not depend on the fix. Restore: hash back to `309b8444`, `git diff HEAD` empty. **B — the primitive half** (`if (excluded?.has(conversion.id)) continue;` removed from `apply.ts`). Marker 1 to 0, blob `de2efc0c` to `ee65383a`: ``` Test Files 1 failed | 471 passed | 1 skipped (473) Tests 2 failed | 13449 passed | 1 skipped (13452) FAIL a seam can refuse a named entry by id even with `includeRetired: true` FAIL refuses only the named id — the rest of the chain still runs AssertionError: the refused entry did not fire: expected undefined to be true ``` Restore: hash back to `de2efc0c`, `git diff HEAD` empty. ## Verification - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived **79** commands from the real change set (5 paths vs merge base `7e74af3df`). All 79 run, all **exit 0**; `--ran` reconciles 79 derived / 79 run / 0 UNRUN. Three answered **exit 3 (PREREQUISITE NOT MET, read as NOT MEASURED, never a pass)** on the first pass — `check:dual-build-cjs-loads`, `check:lean-entry-closure`, `check:type-check-debt` — plus four spec `dist`-readers that refused on a src/dist digest mismatch; the closure was built (`turbo run build` over `./packages/*` + `./packages/*/*`, 72 tasks) and all seven re-run at exit 0. - `pnpm --filter @objectstack/metadata-core test` — 16 files / **278 tests**, exit 0. `typecheck` exit 0. - `pnpm --filter @objectstack/spec test` — 473 files / **13453 tests**, exit 0. `typecheck` exit 0. ⛔ No `--project` narrowing was added by this branch (spec's own `test` script carries `--project local` itself; #17853's trap is a narrowing added over a named file, which is not done here). - `check:api-surface` is **green without regeneration** — the published export listing does not move. - Published-surface measurement, the sound way (grep the BUILT entry `.d.ts` for symbol NAMES, with a fabricated-name negative control): `ApplyConversionsOptions` 7 hits, `MetadataConversion` 6, `applyConversions` 8, fabricated control **0**. ⇒ the symbol set is UNCHANGED — no export added, moved or removed. What does move is one line of text inside an existing exported interface: `excludeConversionIds` appears once in `dist/index.d.ts`. `check-widening-tells --declaration no` exits 0 (no T1/T2/T3/T4 tell), and `check-clause2-carriers --pair 17899` exits 0. - `packages/spec/src/conversions/registry.ts` is NOT in the diff, so the dispatch's registry-plus-surface fork does not fire by its own terms. The `.d.ts` text change is reported here anyway, because the seat owns the declaration and this is the measurement it asked for. - ⛔ Still NOT measured, and not claimed: no end-to-end boot of a scaffolded app; the `rest-server.ts:3215` link is read, not executed. One cheap reading was taken and it only confirms the card's own sentence — `packages/platform-objects/src/apps/account.app.ts:40` does author `hidden: true` — while whether a code-declared app enters a compiled artifact through this door remains unread. ## Acceptance notes - `noted, not filed:` the same rewrite is reachable at the STORED-ROW seam for a post-split authored row: `PUT /meta/app/NAME` validates against `AppSchema`, which accepts `hidden`, so a row written after the 2026-08-09 split with `hidden: true` and no `_unpublished` is indistinguishable from a pre-split materialized one and is converted on rehydration. The entry's population argument ("under the old regime a `hidden: true` row could only have come from the materialization path") is an argument about the OLD regime only. Not filed: it is the same ledger question #16864 answered for the flag and would settle the stored seam's evidence by side effect, which triage ruled out for this card. 承接者: the `domain:spec` seat holding #16864's remaining two carriers (#17894, #17895). - `noted, not filed:` the exclusion list is per-door rather than per-entry, so the NEXT default flip is not covered until somebody adds its id here. The robust shape is a declaration on the registry entry, which would move `packages/spec`'s built entry `.d.ts` and re-open the Clause-② fork this card's dispatch draws. 承接者: whoever takes the general mechanism follow-up to #16864. Authored by the `domain:engine` dev round of session `session_01RuoNSXUbBoWHkNS4AknTrM` (https://claude.ai/code/session_01RuoNSXUbBoWHkNS4AknTrM), dispatched from seat post #6367. --- _Generated by [Claude Code](https://claude.ai/code)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ee6fbd7 commit 134b410

5 files changed

Lines changed: 296 additions & 3 deletions

File tree

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
"@objectstack/spec": minor
3+
"@objectstack/metadata-core": patch
4+
---
5+
6+
The artifact-ingestion door no longer replays the **default-flip** class of ADR-0087 conversion, so an artifact carrying `defineApp({ hidden: true })` is registered with `hidden: true` — not as an unpublished app (#17885, #4829).
7+
8+
`app-hidden-to-unpublished` rewrites `app.hidden: true` into `app._unpublished: true`. Both keys are live and they mean opposite kinds of thing: `hidden` is navigation presentation and *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the machine-managed publish gate `filterAppForUser` drops the app on for every user without `studio.access` / `setup.access`. Measured before the change, on an artifact declaring `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — against a 17.4.0 runtime: the door emitted the `app-hidden-to-unpublished` notice and the object that reached registration carried `hidden: undefined`, `_unpublished: true`. So an author who asked for "keep this out of the App Switcher" got "nobody but a builder can see this" — the incident the `_unpublished` split was introduced to end, arriving through the conversion layer.
9+
10+
- **The entry is not withdrawn and no key moves.** It still fires where its precondition is a fact — the stored-row rehydration seams (a pre-split `hidden: true` row can only have come from the materialization path) and `os migrate meta`, where the operator asserts the source's age. What changed is that the artifact door, whose evidence is the artifact's **declared `engines.protocol` floor** rather than its age, no longer treats that guess as sufficient for a rewrite that reinterprets a live authorable key.
11+
- **The retired window stays open.** Closing it wholesale would fix this and re-break #12772: an artifact built by 17.1.0 tooling carrying `allowRestore` / `allowPurge` would again be refused at the tombstone with no operator remedy. The door refuses one named class by id, with its reason written beside it, and the pin drives a retired conversion and a non-retired one through the same window to prove it.
12+
- **New seam option, no new export.** `applyConversions` accepts `excludeConversionIds` — the seat-level spelling of "my evidence cannot carry this entry". `retiredFromLoadPath` cannot express it: that flag's jurisdiction is the authoring funnel and nothing else.
13+
- ⛔ **The consumer is unchanged.** `filterAppForUser` withholding on `_unpublished` is correct; the defect was who writes `_unpublished`.
14+
15+
Deployments whose apps were being served as unpublished purely because of a permissive `engines.protocol` range will see those apps again, for every user, on the next boot. No artifact file changes and no stored row is rewritten.

‎packages/metadata-core/src/artifact-forward-conversion.test.ts‎

Lines changed: 139 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717
*/
1818

1919
import { describe, it, expect } from 'vitest';
20-
import type { ConversionNotice } from '@objectstack/spec';
20+
import { ObjectStackDefinitionSchema, applyConversionsToStoredItem, type ConversionNotice } from '@objectstack/spec';
2121
import {
2222
applyArtifactForwardConversions,
2323
parseRangeFloor,
@@ -263,6 +263,144 @@ describe('the artifact door never stamps a column constraint (ADR-0113, #16693)'
263263
});
264264
});
265265

266+
/**
267+
* #17885 — the artifact door must not replay the DEFAULT-FLIP class.
268+
*
269+
* `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09) rewrites
270+
* `app.hidden: true` into `app._unpublished: true`. Both keys are live and
271+
* they mean opposite kinds of thing: `hidden` is navigation presentation and
272+
* *"never an access gate"* (`ui/app.zod.ts`), while `_unpublished` is the
273+
* machine-managed publish gate `filterAppForUser` drops the app on for every
274+
* user without `studio.access` / `setup.access`
275+
* (`packages/rest/src/rest-server.ts` — untouched by this fix, and correct:
276+
* the defect is WHO writes `_unpublished`).
277+
*
278+
* The entry is `retiredFromLoadPath: true`, which does NOT hold it back here —
279+
* that flag's jurisdiction is the authoring funnel and nothing else (#16864's
280+
* determination, landed). So before this fix an artifact declaring
281+
* `engines.protocol: ^17.0.0` — the range `create-objectstack` stamps — had
282+
* every `defineApp({ hidden: true })` in it registered as an unpublished app,
283+
* reproducing the very incident the `_unpublished` split was introduced to
284+
* end, through the conversion layer.
285+
*
286+
* Four legs, and the two controls are what make the first two mean anything:
287+
* SUBJECT (the window is open and `hidden` survives) · NEGATIVE (a floor the
288+
* window is shut for) · POSITIVE, twice (a non-retired conversion AND a
289+
* retired one still fire in the subject's own window — the door is narrowed,
290+
* not closed) · and the entry itself still firing at the seam it is sound at.
291+
*/
292+
describe('the artifact door never turns an authored `hidden: true` into an unpublished app (#17885, #4829)', () => {
293+
/** One authored `hidden: true` app, plus a `jsx` page as the live non-retired control. */
294+
const hiddenAppDefinition = (protocolRange: string) => ({
295+
manifest: {
296+
id: 'app.example.hr', name: 'hr', version: '1.0.0', type: 'app',
297+
engines: { protocol: protocolRange },
298+
},
299+
apps: [{ name: 'account', label: 'Account', hidden: true, navigation: [] }],
300+
});
301+
302+
it('leaves `hidden: true` alone on an artifact the retired window IS open for', () => {
303+
const def = hiddenAppDefinition('^17.0.0');
304+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
305+
306+
// ⭐ ANTI-VACUITY: the window really is open on this input. A green line
307+
// below because the door skipped the artifact would prove nothing.
308+
expect(result.verdict).toBe('converted-forward');
309+
expect(result.authoredFloor).toBe('17.0.0');
310+
311+
const app = (result.definition as { apps: Record<string, unknown>[] }).apps[0]!;
312+
expect(app.hidden, 'the authored navigation choice is untouched').toBe(true);
313+
expect(app._unpublished, 'no publish gate is invented for the author').toBeUndefined();
314+
expect(result.notices.map((n) => n.conversionId)).not.toContain('app-hidden-to-unpublished');
315+
// Copy-on-write: nothing was recognized, so the same reference comes back.
316+
expect(result.definition).toBe(def);
317+
});
318+
319+
/**
320+
* ⭐⭐ The assertion that actually matters: the door's output is fed to
321+
* `ObjectStackDefinitionSchema.parse` in `MetadataPlugin._parseAndRegisterArtifact`,
322+
* and THAT object is what reaches registration and then `filterAppForUser`.
323+
* A pin on the conversion's return value alone would not have caught a strict
324+
* parse that re-introduced the key.
325+
*/
326+
it('registers `hidden: true` — asserted AFTER the strict parse the door feeds', () => {
327+
const def = hiddenAppDefinition('^17.0.0');
328+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
329+
expect(result.verdict).toBe('converted-forward');
330+
331+
const parsed = ObjectStackDefinitionSchema.parse(result.definition) as {
332+
apps?: { name: string; hidden?: boolean; _unpublished?: boolean }[];
333+
};
334+
const registered = parsed.apps!.find((a) => a.name === 'account')!;
335+
expect(registered.hidden, 'what registration receives').toBe(true);
336+
expect(registered._unpublished, 'what `filterAppForUser` withholds on').toBeUndefined();
337+
});
338+
339+
/**
340+
* ⭐ NEGATIVE CONTROL — the instrument can answer "no" for the other reason.
341+
* A floor at or above the runtime shuts the window outright, so `hidden`
342+
* surviving here says nothing about the fix; it says the leg above was read
343+
* on an input where the window was genuinely open.
344+
*/
345+
it('floor ^99.0.0 — the window is shut and nothing is replayed at all', () => {
346+
const def = hiddenAppDefinition('^99.0.0');
347+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
348+
expect(result.verdict).toBe('authored-current');
349+
expect(result.notices).toEqual([]);
350+
expect((result.definition as { apps: Record<string, unknown>[] }).apps[0]!.hidden).toBe(true);
351+
});
352+
353+
/**
354+
* ⭐ FIRING CONTROL 1 — a NON-RETIRED conversion still fires in the subject's
355+
* own window. `page-kind-jsx-to-html` (ADR-0080) is not retired, so a door
356+
* that had stopped converting anything would fail here.
357+
*/
358+
it('still applies a non-retired conversion in that same window', () => {
359+
const def = {
360+
...hiddenAppDefinition('^17.0.0'),
361+
pages: [{ name: 'landing', kind: 'jsx', source: '<div>hi</div>' }],
362+
};
363+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
364+
expect(result.verdict).toBe('converted-forward');
365+
expect(result.notices.map((n) => n.conversionId)).toContain('page-kind-jsx-to-html');
366+
expect((result.definition as { pages: Record<string, unknown>[] }).pages[0]!.kind).toBe('html');
367+
// …and the app in the SAME artifact still keeps its authored key.
368+
expect((result.definition as { apps: Record<string, unknown>[] }).apps[0]!.hidden).toBe(true);
369+
});
370+
371+
/**
372+
* ⭐ FIRING CONTROL 2 — and the RETIRED window is still open, which is the
373+
* control this particular fix could plausibly have broken. Closing the
374+
* retired window wholesale would fix #17885 and re-break #12772: an artifact
375+
* built by 17.1.0 tooling would again be refused at the tombstone.
376+
*/
377+
it('still replays RETIRED conversions in that same window (#12772 is not reversed)', () => {
378+
const def = legacyPermissionDefinition('^17.0.0');
379+
const result = applyArtifactForwardConversions(def, { runtimeSpecVersion: '17.4.0' });
380+
expect(result.verdict).toBe('converted-forward');
381+
const objects = (result.definition as { permissions: { objects: Record<string, Record<string, unknown>> }[] })
382+
.permissions[0]!.objects;
383+
expect(objects.crm_ticket!.allowRestore).toBeUndefined();
384+
expect(objects.crm_ticket!.allowPurge).toBeUndefined();
385+
expect(result.notices.length).toBeGreaterThan(0);
386+
});
387+
388+
/**
389+
* ⭐ SEAM SCOPE — the entry is refused by THIS DOOR, not removed from the
390+
* chain. The stored-row seam, where a `hidden: true` row can only have come
391+
* from the pre-split materialization path, still carries the population
392+
* across. A fix that had neutered the entry would go green on every leg
393+
* above and silently strand those rows.
394+
*/
395+
it('leaves the entry firing at the stored-row seam it is sound at', () => {
396+
const row = applyConversionsToStoredItem('app', {
397+
name: 'production_management', label: 'Production', hidden: true, navigation: [],
398+
}) as Record<string, unknown>;
399+
expect(row._unpublished, 'the stored-row seam still converts').toBe(true);
400+
expect(row.hidden).toBeUndefined();
401+
});
402+
});
403+
266404
describe('parseRangeFloor — the range spellings artifacts actually carry', () => {
267405
it.each([
268406
['^17.1.0', [17, 1, 0]],

‎packages/metadata-core/src/artifact-forward-conversion.ts‎

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,11 +62,35 @@
6262
* Cross-major gaps are the protocol *handshake*'s jurisdiction
6363
* (`checkProtocolCompat`) and refuse before conversion could matter.
6464
*
65+
* ## The one class the window does NOT admit — default flips
66+
*
67+
* The window above is a RESCUE: without it an artifact carrying a key the
68+
* current schema has since tombstoned is refused outright, with no operator
69+
* remedy. Every entry of that kind is safe to replay here, because the old
70+
* shape has no live meaning left to destroy.
71+
*
72+
* A DEFAULT FLIP is not that. Its old shape still parses, still means
73+
* something on today's authoring surface, and the rewrite changes what it
74+
* means — so replaying it is a reinterpretation, sound only where "this input
75+
* predates the flip" is a FACT. At this door it is a GUESS, and a weak one:
76+
* the key is the artifact's **declared `engines.protocol` floor**, not its
77+
* age, and `^17.0.0` is the range `create-objectstack` stamps — so an app
78+
* authored today, against today's surface, lands inside the window (measured
79+
* for `field-required-notnull-explicit`, #16693; measured again for
80+
* `app-hidden-to-unpublished`, #17885). Retirement does not hold those back
81+
* either: `retiredFromLoadPath`'s jurisdiction is the AUTHORING funnel and
82+
* nothing else (#16864's determination, and the flag's own docblock now says
83+
* so) — which is exactly why the window has to name them here.
84+
*
85+
* ⇒ {@link DEFAULT_FLIPS_NOT_REPLAYED_HERE} lists them, and the door refuses
86+
* them by id. Each id owes its reason beside it.
87+
*
6588
* ## What this deliberately is NOT
6689
*
6790
* - Not a second conversion table: the ADR-0087 registry in
6891
* `@objectstack/spec` stays the single authority on *what* converts; this
69-
* module only decides *whether the retired window opens* for one artifact.
92+
* module only decides *whether the retired window opens* for one artifact —
93+
* now per entry for the one named class above, rather than all-or-nothing.
7094
* - Not a validator: like `applyConversions` itself, this never throws and
7195
* never gates. Gating stays at the caller's schema parse.
7296
* - Not the flow-specific seam: flows convert here too (context-less, exactly
@@ -243,6 +267,29 @@ export function resolveInstalledSpecVersion(): string | null {
243267
return null;
244268
}
245269

270+
/**
271+
* ADR-0087 entries the versioned window deliberately does NOT replay here —
272+
* the DEFAULT-FLIP class (see the module doc). Module-local on purpose: this
273+
* is the door's own refusal, not a fact about the registry, and exporting it
274+
* would invite a second caller to inherit a judgement it has not made.
275+
*
276+
* - `app-hidden-to-unpublished` (#17885, #4829, ADR-0045 amended 2026-08-09):
277+
* rewrites `app.hidden: true` into `app._unpublished: true`. `hidden` is a
278+
* LIVE authorable key — navigation presentation, *"never an access gate"*
279+
* (`ui/app.zod.ts`) — while `_unpublished` is the machine-managed publish
280+
* gate that `filterAppForUser` (`packages/rest/src/rest-server.ts`) drops
281+
* the app on for every user without `studio.access` / `setup.access`. So
282+
* replaying it here turns an authored `defineApp({ hidden: true })` into an
283+
* app nobody but a builder can see — reproducing #4829, the incident the
284+
* `_unpublished` split was introduced to end, through the conversion layer.
285+
* The entry is sound where it says it is (the stored-row rehydration seams,
286+
* where a `hidden: true` row can only have come from the pre-split
287+
* materialization path, and `os migrate meta --from <=16`, where the
288+
* operator asserts the source's age) — this door is neither, so it opts out
289+
* rather than the entry ceasing to fire.
290+
*/
291+
const DEFAULT_FLIPS_NOT_REPLAYED_HERE: readonly string[] = ['app-hidden-to-unpublished'];
292+
246293
/**
247294
* Apply the versioned forward conversion to one compiled-artifact definition.
248295
*
@@ -289,6 +336,7 @@ export function applyArtifactForwardConversions<T>(
289336
const notices: ArtifactConversionNotice[] = [];
290337
const converted = applyConversions(definition as Record<string, unknown>, {
291338
includeRetired: true,
339+
excludeConversionIds: DEFAULT_FLIPS_NOT_REPLAYED_HERE,
292340
onNotice: (n) => {
293341
notices.push(n);
294342
options.onNotice?.(n);

‎packages/spec/src/conversions/apply.ts‎

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,34 @@ export interface ApplyConversionsOptions {
7070
* rather than silently clobber it.
7171
*/
7272
reservedNodeTypes?: ReadonlySet<string>;
73+
/**
74+
* Conversion ids this seam refuses to replay, whatever `includeRetired`
75+
* says. Empty/absent by default: every seam replays the whole window it
76+
* opened.
77+
*
78+
* **Why a seam needs this at all.** `includeRetired` opens the window for a
79+
* WHOLE CLASS of caller (data at rest), and the entries inside that window
80+
* are not one kind. Most are lossless deletes or renames of a shape the
81+
* current schema now REFUSES — replaying those is the rescue the window
82+
* exists for, because without it the row or artifact is simply unbootable.
83+
* A few are DEFAULT FLIPS: the old shape still parses, still means
84+
* something, and the rewrite changes what it means. For those the replay is
85+
* not a rescue, it is a reinterpretation — and whether it is sound depends
86+
* on the CALLER, not on the entry: only a seam that can say "this input
87+
* predates the flip" as a FACT rather than a guess may apply one.
88+
*
89+
* ⇒ The entry cannot answer that (nothing in the item distinguishes a
90+
* machine-written row at rest from an author who wrote the same key
91+
* yesterday), and `retiredFromLoadPath` does not answer it either — its
92+
* jurisdiction is the authoring funnel and nothing else (see that flag's
93+
* own docblock on `MetadataConversion`). This option is where a seam says
94+
* which entries its own evidence cannot carry.
95+
*
96+
* ⛔ NOT a second conversion table and never a filter of convenience: the
97+
* registry stays the single authority on WHAT converts. A caller passing
98+
* this owes a written reason per id, at the call site.
99+
*/
100+
excludeConversionIds?: readonly string[];
73101
}
74102

75103
/**
@@ -84,10 +112,17 @@ export function applyConversions(
84112
stack: Record<string, unknown>,
85113
options: ApplyConversionsOptions = {},
86114
): Record<string, unknown> {
87-
const { onNotice, onConflict, reservedNodeTypes, includeRetired = false } = options;
115+
const { onNotice, onConflict, reservedNodeTypes, includeRetired = false, excludeConversionIds } = options;
116+
const excluded = excludeConversionIds && excludeConversionIds.length > 0
117+
? new Set(excludeConversionIds)
118+
: null;
88119
let current = stack;
89120

90121
for (const conversion of ALL_CONVERSIONS) {
122+
// The seam's own refusal, read BEFORE the retirement window: a caller that
123+
// cannot carry a given entry's precondition does not get it back by
124+
// opening the window (see `excludeConversionIds`).
125+
if (excluded?.has(conversion.id)) continue;
91126
// A retired entry is graduated chain history (ADR-0087 D2 window, second
92127
// half): the AUTHORING funnel (`normalizeStackInput`) no longer replays it,
93128
// so the tombstone teaches the author instead. The data-at-rest seams —

0 commit comments

Comments
 (0)