Skip to content

app-hidden-to-unpublished is replayed at the artifact-ingestion door, rewriting an authored defineApp({ hidden: true }) into an unpublished app — the default-flip class its own docblock says the retirement flag excludes it from #17885

Description

@os-bill

Found while running the ADR-0049 enforce-or-remove ledger determination dispatched on #16864 (that round is a determination only and writes no behaviour change, so this is filed separately rather than widened into it). Measured on origin/main e7fea4665d, runtime @objectstack/spec 17.4.0.

#16864 lists, as the first thing a fix would need, an audit nobody had run: "Whether any of the remaining 73 entries is a default flip rather than a lossless delete." This is that audit's positive hit, and it is reachable today.

The entry, and what its own docblock claims

packages/spec/src/conversions/registry.ts, the app-hidden-to-unpublished entry (#4829, ADR-0045 amended 2026-08-09), quoted verbatim from its docblock:

retiredFromLoadPath: true — load-bearing here, not bookkeeping.
Retirement is what confines this rewrite to stored rows. hidden is NOT
retired as an authorable key — it keeps its birth contract, narrowed to
navigation — so a conversion running on the load path would rewrite
defineApp({ hidden: true }), and ACCOUNT_APP itself, into unpublished apps
and reproduce #4829 through the conversion layer. Excluded from the load path,
it replays only where the old meaning is the only meaning: the stored-row
rehydration seams (applyConversionsToStoredItem, which pins
includeRetired) and os migrate meta.

packages/spec/src/ui/app.zod.ts backs the premise: hidden is a live authorable key — "Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate)".

The measurement: the load path the docblock excludes includes the artifact-ingestion door

applyArtifactForwardConversions (packages/metadata-core/src/artifact-forward-conversion.ts:291, #12772) replays the full chain with includeRetired: true whenever an artifact's declared engines.protocol floor is below the running spec. It is the framework artifact door's policy, called from packages/metadata/src/plugin.ts:763 (MetadataPlugin._convertArtifactForward), which converts and then hands the result to ObjectStackDefinitionSchema.parse and registers it.

Probe, run against built dist/ on e7fea4665d. Subject: an artifact whose manifest declares engines.protocol: ^17.0.0 — the range create-objectstack stamps, measured on the registry canary in #16693 — carrying one app authored hidden: true.

runtime @objectstack/spec version = 17.4.0

[SUBJECT  floor ^17.0.0] verdict              = converted-forward
[SUBJECT  floor ^17.0.0] notices              = ["app-hidden-to-unpublished"]   <- a RETIRED entry
[SUBJECT  floor ^17.0.0] apps[0].hidden       = undefined                       <- and it was APPLIED
[SUBJECT  floor ^17.0.0] apps[0]._unpublished = true

[NEG CTRL floor ^99.0.0] verdict              = authored-current                <- window shut
[NEG CTRL floor ^99.0.0] notices              = []
[NEG CTRL floor ^99.0.0] apps[0].hidden       = true

[POS CTRL other conv   ] notices contain action-inert-keys-removed = true       <- window open, different entry
[POS CTRL other conv   ] actions[0].shortcut (inert key) = undefined

[applyConversions includeRetired=false] notices=[] apps[0].hidden=true          <- the flag WORKS when not overridden
[applyConversions includeRetired=true ] notices=["app-hidden-to-unpublished"] apps[0].hidden=undefined _unpublished=true

[normalizeStackInput (authoring seam)] notices=[] apps[0].hidden=true           <- the seam the flag does govern

Door output carried through the strict parse the door itself feeds, which is what actually reaches registration:

[floor ^17.0.0] parse ok=true -> registered app: hidden=undefined _unpublished=true
[floor ^99.0.0] parse ok=true -> registered app: hidden=true      _unpublished=undefined

The negative control proves the instrument can answer "no"; the positive control proves the window is open independently of the entry under test; the includeRetired=false leg proves the flag itself is not broken — it is overridden by the caller.

Why the rewritten value is not cosmetic

packages/rest/src/rest-server.ts:3215 is the consumer:

if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) {

The app is dropped for every user without studio.access / setup.access. So an app deliberately authored hidden: true (navigation presentation) is served as withheld-from-everyone — exactly the #4829 failure the _unpublished split was introduced to end, arriving through the conversion layer, which is the outcome the entry's docblock says retiredFromLoadPath prevents.

The trigger is a declared floor, not artifact age. Any app on a permissive range is treated as a pre-split source regardless of when it was authored — the same shape #16693 measured for field-required-notnull-explicit before that entry was removed.

What is NOT measured here

  • ⛔ No end-to-end boot of a real scaffolded app was run. The measurement is the door policy function plus the strict parse it feeds, plus the consumer read at rest-server.ts:3215 — read, not executed.
  • ⛔ Whether any deployed app authors hidden: true is not measured. The platform's own account app does, but it is code-declared (platform-objects ACCOUNT_APP) and, per the entry's docblock, code-declared artifacts do not enter sys_metadata — whether they enter a compiled artifact through this door is a separate reading nobody has taken.
  • ⛔ The remaining 11 retired entries whose old shape the current schema still accepts are page-component keys on a permissive surface; they were not individually classified as flip-vs-lossless. 19 of the 75 retired entries could not be classified at all (their fixtures do not parse standalone) and read NOT MEASURED.

Not the same card as #16864

#16864 is the ledger question — does retiredFromLoadPath mean what it says, and which of the flag or the seams gives. This card is one measured consequence with a named subject, a named consumer and a reproducible probe, and it stands whichever way that ledger question is answered. Dedup: full enumeration of all 534 open non-PR issues (six REST pages, number line closed) greps zero other card mentioning app-hidden-to-unpublished, _unpublished, or applyArtifactForwardConversions; the lit control (retiredFromLoadPath) returns #16864, so the grep works.

Refs #16864, #12772, #16693, #4829, ADR-0045, ADR-0087 D2 addendum 2026-07-31.


Generated by Claude Code

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingdomain:enginepriority:p1High: required for production / M2

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions