Skip to content

Commit 97b0798

Browse files
os-zhuangclaude
andauthored
fix(spec,rest,runtime)!: the ADR-0045 publish gate gets its own machine-managed key — app.hidden goes back to meaning navigation (#4829) (#6942)
* fix(spec,rest,runtime)!: the ADR-0045 publish gate gets its own machine-managed key (#4829) `filterAppForUser` read `app.hidden` as an access gate. `hidden` does not mean that — its contract, written in `app.zod.ts` the day the key was born alongside the built-in Account app, is navigation presentation: hidden apps stay fully routable and permission-checked, they just do not appear in the App Switcher. So the platform's own `account` app, authored `hidden: true` on purpose, was erased from `GET /meta/app` for every user without builder access — password, avatar, sessions and inbox unreachable behind "App not available", while any admin saw a healthy system. ADR-0045 §3 never introduced `hidden`; it borrowed it, citing an "ADR-0019 launcher contract" that does not exist (ADR-0019 contains no `hidden`). One boolean carried two contracts that disagree on whether a normal user may reach the app. Per the maintainer's 2026-08-04 ruling (direction A1) and the 2026-08-07 window re-ruling (lands in v17): - `AppSchema` declares `_unpublished`, the machine-managed publish gate. The `_` prefix is this repo's existing marker for the channel tooling stamps onto artifacts (ADR-0010's `_lock` envelope). Declared rather than omitted because the write path validates against this schema, so an undeclared key would make the platform's own flip unwritable. The strict door answers `unpublished` / `published` / `draft` with "publish state is not authorable". - `hidden` returns to navigation semantics only; its docblock carries the incident. - The REST gate judges `_unpublished`; pins now assert both directions, plus an end-to-end wire pin of the account-app repro (`meta-app-publish-gate.test.ts`). - `publish-drafts` clears `_unpublished` and copies `hidden` through untouched. - ADR-0045 amended, its dangling ADR-0019 reference corrected, and both implementation sites anchored in `scripts/adr-anchors.json` — neither carried an anchor before, which is why §3 could be changed without anyone knowing a decision was being changed. - ADR-0087 conversion `app-hidden-to-unpublished` carries stored rows across. `retiredFromLoadPath` is load-bearing here: it confines the rewrite to stored rows, so an authored `hidden: true` — the Account app included — is never converted into an app no normal user may reach. Fixes #4829 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEKYRu2XjMPRA2AR4ueKUu * test(spec): pin the `_unpublished` acceptance face in both directions (#4829) `_unpublished` is machine-managed but DECLARED — the write path validates against AppSchema, so an undeclared key would make the platform's own visibility flip unwritable. What keeps it out of an author's hands is the `_` prefix plus the strict-door prescriptions, so both halves are pinned rather than asserted in a comment: the schema accepts the gate, the author-shaped spellings (`unpublished` / `published` / `draft`) get "publish state is not authorable" instead of a rename suggestion, and `hidden` still parses because the key was never the wrong one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CEKYRu2XjMPRA2AR4ueKUu --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 73bff86 commit 97b0798

19 files changed

Lines changed: 870 additions & 95 deletions

File tree

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
---
2+
"@objectstack/spec": major
3+
"@objectstack/rest": minor
4+
"@objectstack/runtime": minor
5+
---
6+
7+
fix(spec,rest,runtime)!: the ADR-0045 publish gate gets its own machine-managed key — `app.hidden` goes back to meaning navigation, and the built-in Account app stops 404ing for every normal user (#4829)
8+
9+
<!-- adr-0087: registered app-hidden-to-unpublished -->
10+
11+
**FROM → TO:** nothing to rewrite by hand. `app.hidden` keeps its spelling and its
12+
authoring contract; the publish gate moves to a new machine-managed key,
13+
`app._unpublished`, which no author writes. Stored `sys_metadata` app rows carrying
14+
`hidden: true` are rewritten to `_unpublished: true` by the ADR-0087 conversion
15+
`app-hidden-to-unpublished` — automatically on every stored-row read, and in place via
16+
`os migrate meta --stored --apply`.
17+
18+
## The defect
19+
20+
`filterAppForUser` (`@objectstack/rest`) treated `app.hidden` as an access gate:
21+
22+
```ts
23+
if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) return null;
24+
```
25+
26+
`hidden` does not mean that. Its contract, written in `app.zod.ts` the day the key was
27+
born alongside the built-in Account app, is navigation presentation: *"Hidden apps stay
28+
fully routable and permission-checked"* — keep it out of the App Switcher, surface it from
29+
the avatar menu, which is exactly how personal-settings apps behave in GitHub Settings,
30+
the Google account chip and Salesforce Personal Settings.
31+
32+
So the platform's own `account` app — authored `hidden: true` on purpose — was erased from
33+
`GET /api/v1/meta/app` for every user without `studio.access` / `setup.access`. Clicking
34+
the avatar → Profile landed on *"App not available — it may still be publishing"*, and
35+
password changes, avatar, linked accounts, active sessions and the inbox were all
36+
unreachable. Any admin saw a completely healthy system, which is why it survived a release
37+
candidate and shipped a downstream workaround.
38+
39+
The two contracts arrived from different places. ADR-0045 §3 did not introduce `hidden`; it
40+
**borrowed** it, citing an "ADR-0019 launcher contract (`hidden`, `active`)" as an existing
41+
read side. That contract does not exist — **ADR-0019 contains no `hidden`** and never
42+
discussed launchers, the avatar menu or the Account app. The reference was dangling from
43+
the day it was written, which is why nothing caught the collision it created: one boolean,
44+
two contracts, disagreeing on the only question that matters — *may a normal user reach
45+
this app?*
46+
47+
## What changed
48+
49+
- **`AppSchema` declares `_unpublished`** — the ADR-0045 §3 publish gate. `true` means the
50+
app is unpublished: externally unobservable, not merely unlisted. It is written by the AI
51+
additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and its
52+
`_` prefix is this repo's existing marker for the channel tooling stamps onto artifacts
53+
(ADR-0010's `_lock` / `_provenance` envelope; the prefix `lintAuthoredRecordKeys` already
54+
skips). It is *declared* rather than omitted because the write path validates against
55+
this very schema (`saveMetaItem` → 422; `Registry.validate('app', …)``AppSchema.parse`),
56+
so an undeclared key would make the platform's own flip unwritable. The strict door
57+
answers the author-shaped spellings — `unpublished`, `published`, `draft` — with a
58+
prescription that says *publish state is not authorable*, rather than routing them onto
59+
the key.
60+
- **`app.hidden` is navigation only**, and its docblock now says so with the incident
61+
attached. Authoring `hidden: true` affects the App Switcher and nothing else.
62+
- **The REST gate judges `_unpublished`.** A hidden app is served to everyone, with its
63+
`hidden` flag intact so the shell can place it; an unpublished app still 404s externally
64+
and still reaches builders for direct-URL preview, and `requiredPermissions` still applies
65+
to both.
66+
- **`publish-drafts` clears `_unpublished`** instead of un-hiding. It writes `false` rather
67+
than deleting the key, because ADR-0045 §3 makes publish/unpublish symmetric, and it
68+
copies `hidden` through untouched — publishing no longer rewrites a presentation choice
69+
as a side effect. The response fields keep their `unhiddenApps` / `unhideError` spelling:
70+
they are a wire contract read by the objectui Publish button, and renaming them from a
71+
repo that cannot update that consumer would be a silent break of exactly the kind this
72+
change is about.
73+
- **ADR-0045 is amended**, its dangling ADR-0019 reference corrected, and both
74+
implementation sites (`rest-server.ts`, `runtime/domains/packages.ts`) are now anchored in
75+
`scripts/adr-anchors.json` — neither carried an anchor before, which is why an author
76+
could change ADR-0045's §3 without knowing they were changing a decision.
77+
78+
## Why a new key rather than deleting the gate
79+
80+
Taking `hidden` out of the access decision was proposed first and refused. The gate is §3 of
81+
an **Accepted** ADR with pin tests and a live implementation behind it, so removing it in a
82+
patch would reverse a recorded decision by side effect. It is also the worse failure
83+
direction: a gate that fails **open** exposes a half-built app to real users, silently.
84+
85+
## Migration reach
86+
87+
The conversion is `retiredFromLoadPath: true`, and here that flag is load-bearing rather
88+
than bookkeeping — it confines the rewrite to **stored rows**. `hidden` is not retired as an
89+
authorable key, so a conversion running on the load path would rewrite
90+
`defineApp({ hidden: true })`, and the Account app itself, into unpublished apps and
91+
reproduce the defect through the conversion layer. Excluded from the load path, it replays
92+
only where the old meaning is the only meaning: the stored-row rehydration seams and
93+
`os migrate meta`. Stored `hidden: true` was unambiguous under the old regime — that value
94+
*was* the gate, so nobody stored it to mean "keep me out of the switcher"; code-declared
95+
apps like `ACCOUNT_APP` never enter `sys_metadata`, and the Studio app form has no `hidden`
96+
control.
97+
98+
## Follow-ups (other repos, filed separately)
99+
100+
- **cloud** — the AI materialization write point must stamp `_unpublished: true` where it
101+
stamps `hidden: true` today.
102+
- **objectui** — the Unpublished banner and the Publish button must read/clear
103+
`_unpublished`; the App Switcher keeps reading `hidden`, which now means only what it says.
104+
- **os-project-titanwind-ehr** — PLAT-DEF-040's startup `{hidden:false}` overlay can be
105+
deleted once this ships.

content/docs/references/ui/app.mdx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,8 @@ const result = ActionNavItemSchema.parse(data);
7272
| **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding |
7373
| **active** | `boolean` | optional | Whether the app is enabled |
7474
| **isDefault** | `boolean` | optional | Is default app |
75-
| **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead |
75+
| **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate) |
76+
| **_unpublished** | `boolean` | optional | Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored. |
7677
| **navigation** | `({ id: string; label: string \| Record<string, string>; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar |
7778
| **areas** | `{ id: string; label: string \| Record<string, string>; icon?: string; description?: string \| Record<string, string>; … }[]` | optional | Navigation areas for partitioning navigation by business domain |
7879
| **contextSelectors** | `{ id: string; label: string \| Record<string, string>; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{<id>}` template vars |

0 commit comments

Comments
 (0)