From c398dfabe4f2da870e9fb69a165eee4bd6d0ec13 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 05:21:22 +0000 Subject: [PATCH 1/2] fix(spec,rest,runtime)!: the ADR-0045 publish gate gets its own machine-managed key (#4829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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 objectstack-ai/objectstack#4829 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01CEKYRu2XjMPRA2AR4ueKUu --- .changeset/app-publish-gate-own-key.md | 105 ++++++++++ content/docs/references/ui/app.mdx | 3 +- ...ive-materialization-and-visibility-gate.md | 78 ++++++-- docs/protocol-upgrade-guide.md | 1 + .../src/sys-metadata-repository.test.ts | 14 +- .../rest/src/meta-app-publish-gate.test.ts | 185 ++++++++++++++++++ packages/rest/src/rest-server.ts | 31 ++- packages/rest/src/rest.test.ts | 70 +++++-- packages/runtime/src/domains/packages.ts | 63 ++++-- packages/runtime/src/http-dispatcher.test.ts | 83 ++++++-- packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/app.json | 14 +- packages/spec/spec-changes.json | 12 ++ .../spec/src/conversions/conversions.test.ts | 88 +++++++++ packages/spec/src/conversions/registry.ts | 92 +++++++++ packages/spec/src/migrations/registry.ts | 1 + packages/spec/src/ui/app.zod.ts | 79 +++++++- scripts/adr-anchors.json | 14 ++ 18 files changed, 840 insertions(+), 94 deletions(-) create mode 100644 .changeset/app-publish-gate-own-key.md create mode 100644 packages/rest/src/meta-app-publish-gate.test.ts diff --git a/.changeset/app-publish-gate-own-key.md b/.changeset/app-publish-gate-own-key.md new file mode 100644 index 0000000000..28a18a1e17 --- /dev/null +++ b/.changeset/app-publish-gate-own-key.md @@ -0,0 +1,105 @@ +--- +"@objectstack/spec": major +"@objectstack/rest": minor +"@objectstack/runtime": minor +--- + +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) + + + +**FROM → TO:** nothing to rewrite by hand. `app.hidden` keeps its spelling and its +authoring contract; the publish gate moves to a new machine-managed key, +`app._unpublished`, which no author writes. Stored `sys_metadata` app rows carrying +`hidden: true` are rewritten to `_unpublished: true` by the ADR-0087 conversion +`app-hidden-to-unpublished` — automatically on every stored-row read, and in place via +`os migrate meta --stored --apply`. + +## The defect + +`filterAppForUser` (`@objectstack/rest`) treated `app.hidden` as an access gate: + +```ts +if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) return null; +``` + +`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"* — keep it out of the App Switcher, surface it from +the avatar menu, which is exactly how personal-settings apps behave in GitHub Settings, +the Google account chip and Salesforce Personal Settings. + +So the platform's own `account` app — authored `hidden: true` on purpose — was erased from +`GET /api/v1/meta/app` for every user without `studio.access` / `setup.access`. Clicking +the avatar → Profile landed on *"App not available — it may still be publishing"*, and +password changes, avatar, linked accounts, active sessions and the inbox were all +unreachable. Any admin saw a completely healthy system, which is why it survived a release +candidate and shipped a downstream workaround. + +The two contracts arrived from different places. ADR-0045 §3 did not introduce `hidden`; it +**borrowed** it, citing an "ADR-0019 launcher contract (`hidden`, `active`)" as an existing +read side. That contract does not exist — **ADR-0019 contains no `hidden`** and never +discussed launchers, the avatar menu or the Account app. The reference was dangling from +the day it was written, which is why nothing caught the collision it created: one boolean, +two contracts, disagreeing on the only question that matters — *may a normal user reach +this app?* + +## What changed + +- **`AppSchema` declares `_unpublished`** — the ADR-0045 §3 publish gate. `true` means the + app is unpublished: externally unobservable, not merely unlisted. It is written by the AI + additive-materialization path and cleared by `POST /packages/:id/publish-drafts`, and its + `_` prefix is this repo's existing marker for the channel tooling stamps onto artifacts + (ADR-0010's `_lock` / `_provenance` envelope; the prefix `lintAuthoredRecordKeys` already + skips). It is *declared* rather than omitted because the write path validates against + this very schema (`saveMetaItem` → 422; `Registry.validate('app', …)` → `AppSchema.parse`), + so an undeclared key would make the platform's own flip unwritable. The strict door + answers the author-shaped spellings — `unpublished`, `published`, `draft` — with a + prescription that says *publish state is not authorable*, rather than routing them onto + the key. +- **`app.hidden` is navigation only**, and its docblock now says so with the incident + attached. Authoring `hidden: true` affects the App Switcher and nothing else. +- **The REST gate judges `_unpublished`.** A hidden app is served to everyone, with its + `hidden` flag intact so the shell can place it; an unpublished app still 404s externally + and still reaches builders for direct-URL preview, and `requiredPermissions` still applies + to both. +- **`publish-drafts` clears `_unpublished`** instead of un-hiding. It writes `false` rather + than deleting the key, because ADR-0045 §3 makes publish/unpublish symmetric, and it + copies `hidden` through untouched — publishing no longer rewrites a presentation choice + as a side effect. The response fields keep their `unhiddenApps` / `unhideError` spelling: + they are a wire contract read by the objectui Publish button, and renaming them from a + repo that cannot update that consumer would be a silent break of exactly the kind this + change is about. +- **ADR-0045 is amended**, its dangling ADR-0019 reference corrected, and both + implementation sites (`rest-server.ts`, `runtime/domains/packages.ts`) are now anchored in + `scripts/adr-anchors.json` — neither carried an anchor before, which is why an author + could change ADR-0045's §3 without knowing they were changing a decision. + +## Why a new key rather than deleting the gate + +Taking `hidden` out of the access decision was proposed first and refused. The gate is §3 of +an **Accepted** ADR with pin tests and a live implementation behind it, so removing it in a +patch would reverse a recorded decision by side effect. It is also the worse failure +direction: a gate that fails **open** exposes a half-built app to real users, silently. + +## Migration reach + +The conversion is `retiredFromLoadPath: true`, and here that flag is load-bearing rather +than bookkeeping — it confines the rewrite to **stored rows**. `hidden` is not retired as an +authorable key, so a conversion running on the load path would rewrite +`defineApp({ hidden: true })`, and the Account app itself, into unpublished apps and +reproduce the defect 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 and +`os migrate meta`. Stored `hidden: true` was unambiguous under the old regime — that value +*was* the gate, so nobody stored it to mean "keep me out of the switcher"; code-declared +apps like `ACCOUNT_APP` never enter `sys_metadata`, and the Studio app form has no `hidden` +control. + +## Follow-ups (other repos, filed separately) + +- **cloud** — the AI materialization write point must stamp `_unpublished: true` where it + stamps `hidden: true` today. +- **objectui** — the Unpublished banner and the Publish button must read/clear + `_unpublished`; the App Switcher keeps reading `hidden`, which now means only what it says. +- **os-project-titanwind-ehr** — PLAT-DEF-040's startup `{hidden:false}` overlay can be + deleted once this ships. diff --git a/content/docs/references/ui/app.mdx b/content/docs/references/ui/app.mdx index cc96b572a5..254a71b012 100644 --- a/content/docs/references/ui/app.mdx +++ b/content/docs/references/ui/app.mdx @@ -72,7 +72,8 @@ const result = ActionNavItemSchema.parse(data); | **branding** | `{ primaryColor?: string; accentColor?: string; logo?: string; favicon?: string }` | optional | App-specific branding | | **active** | `boolean` | optional | Whether the app is enabled | | **isDefault** | `boolean` | optional | Is default app | -| **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead | +| **hidden** | `boolean` | optional | Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate) | +| **_unpublished** | `boolean` | optional | Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored. | | **navigation** | `({ id: string; label: string \| Record; icon?: string; order?: number; … } \| { type: 'separator'; id?: string; order?: number } \| … +7 more)[]` | optional | Full navigation tree for the app sidebar | | **areas** | `{ id: string; label: string \| Record; icon?: string; description?: string \| Record; … }[]` | optional | Navigation areas for partitioning navigation by business domain | | **contextSelectors** | `{ id: string; label: string \| Record; icon?: string; optionsSource: object; … }[]` | optional | App-level scope dropdowns whose value is injected into nav items as `{}` template vars | diff --git a/docs/adr/0045-additive-materialization-and-visibility-gate.md b/docs/adr/0045-additive-materialization-and-visibility-gate.md index d21f54ed7d..a6f58dacff 100644 --- a/docs/adr/0045-additive-materialization-and-visibility-gate.md +++ b/docs/adr/0045-additive-materialization-and-visibility-gate.md @@ -1,9 +1,9 @@ # ADR-0045: Additive materialization with a visibility gate — drafts narrow to mutations -**Status**: Accepted (2026-06-12) +**Status**: Accepted (2026-06-12) · **Amended 2026-08-09** (#4829, maintainer ruling 2026-08-04 + window re-ruling 2026-08-07) — the gate moves off `app.hidden` onto a dedicated machine-managed key, `app._unpublished`. The mechanism is unchanged in every other respect; see **"Amendment (2026-08-09): the gate gets its own key"** at the end for what was wrong and why this is a correction of the record rather than a reversal. **Deciders**: ObjectStack Protocol Architects -**Builds on**: [ADR-0027](./0027-metadata-authoring-lifecycle.md) (staged authoring · draft · publish — **this ADR narrows which writes route through its draft workspace**), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) ("AI never publishes — it drafts" — **revised for additive builds**: AI materializes invisibly; *visibility* is the human gate), [ADR-0038](./0038-build-verification-loop.md) (machine verification gate — retained, moved **before** materialization), [ADR-0037](./0037-live-canvas-draft-preview.md) (Live Canvas — its preview surface becomes trivially real under this ADR), [ADR-0005](./0005-metadata-customization-overlay.md) (overlay model — unchanged as storage, narrowed as preview plane), [ADR-0019](./0019-app-as-consumer-unit.md) (app as consumer unit — the `hidden`/launcher contract this ADR's gate rides on) -**Consumers**: `@objectstack/objectql` + `@objectstack/runtime` (materialize/teardown, visibility semantics), `@objectstack/rest` (publish = visibility flip for additive packages), `../cloud/service-ai-studio` (blueprint tools switch from stage-draft to materialize-hidden), `../objectui` (Live Canvas → real app URL; draft preview narrows to mutation diff) +**Builds on**: [ADR-0027](./0027-metadata-authoring-lifecycle.md) (staged authoring · draft · publish — **this ADR narrows which writes route through its draft workspace**), [ADR-0033](./0033-ai-assisted-metadata-authoring.md) ("AI never publishes — it drafts" — **revised for additive builds**: AI materializes invisibly; *visibility* is the human gate), [ADR-0038](./0038-build-verification-loop.md) (machine verification gate — retained, moved **before** materialization), [ADR-0037](./0037-live-canvas-draft-preview.md) (Live Canvas — its preview surface becomes trivially real under this ADR), [ADR-0005](./0005-metadata-customization-overlay.md) (overlay model — unchanged as storage, narrowed as preview plane), [ADR-0019](./0019-app-as-consumer-unit.md) (app as the consumer-facing unit — this ADR's gate decides what that unit exposes; ⚠️ the original text cited ADR-0019 for a "`hidden`/launcher contract", which ADR-0019 does not contain — see the 2026-08-09 amendment) +**Consumers**: `@objectstack/objectql` + `@objectstack/runtime` (materialize/teardown, visibility semantics), `@objectstack/rest` (publish = visibility flip for additive packages), `../cloud/service-ai-studio` (blueprint tools switch from stage-draft to materialize-unpublished), `../objectui` (Live Canvas → real app URL; draft preview narrows to mutation diff) **Premise**: pre-launch, no back-compat debt — specify the target end-state directly. @@ -14,10 +14,10 @@ ## TL;DR 1. **Additive builds materialize immediately.** When an AI build (or Studio batch) creates only *new* artifacts — objects, views, dashboards, datasets, apps, seeds whose names collide with nothing published — it writes them as **real, active metadata** with **real tables and real seed rows**, grouped under the build's package. No draft state, no overlay. -2. **The gate moves from lifecycle to visibility.** The built app carries `hidden: true` ("unlisted"): launchers, home grids, app switchers and end-user nav never show it; builders/admins reach it by direct URL. **Publish = flip visibility** — instant, reversible, trivially understandable. The ADR-0019 launcher contract (`hidden`, `active`) already implements the read side. +2. **The gate moves from lifecycle to visibility.** The built app carries `_unpublished: true`: launchers, home grids, app switchers and end-user nav never show it, and neither does the metadata API; builders/admins reach it by direct URL. **Publish = clear the gate** — instant, reversible, trivially understandable. The read side is `filterAppForUser` in `@objectstack/rest` (amended 2026-08-09: the gate has its own key and does **not** ride on `app.hidden`, which is navigation presentation). 3. **Preview is the real app.** The ADR-0037 Live Canvas iframes the app's *real* URL — full data (seed rows are real rows), full interaction (kanban drags, record creation, filters), zero overlay adaptation. The "draft preview shows an empty shell" class of defect becomes structurally impossible. 4. **Mutations keep the ADR-0027 draft workspace.** Changing or deleting anything *published* — field edits, renames, destructive ops, permission changes — still stages as a draft, surfaces in diff review, and goes live only on human publish. The overlay preview plane (`?preview=draft`) narrows to this purpose: rendering a *pending change to a visible thing*, not a whole pending world. -5. **The ADR-0038 machine gate runs before materialization.** L1 graph lint validates the in-memory build set first; a failed lint materializes **nothing** (the agent repairs and retries). L2/L3 runtime probes then exercise the *real* hidden app — strictly better signal than probing a synthetic overlay. +5. **The ADR-0038 machine gate runs before materialization.** L1 graph lint validates the in-memory build set first; a failed lint materializes **nothing** (the agent repairs and retries). L2/L3 runtime probes then exercise the *real* unpublished app — strictly better signal than probing a synthetic overlay. 6. **Discard = real teardown.** Discarding an unpublished build drops its package's metadata, tables and rows (package-scoped uninstall, ADR-0027 §rollback mechanics). Heavier than deleting overlay rows — and honest: the user is deleting an app, and is told so. --- @@ -89,41 +89,42 @@ Ordering inside the build is what the publish path does today (structure → dat ### 3. The visibility gate -- The build's `app` materializes with **`hidden: true`** (and stays `active: true`). The ADR-0019 launcher contract already excludes hidden apps from every end-user listing surface (launcher, home grid, switcher); direct URLs (`/apps/`) resolve for users with builder/admin permission. Objects and views without a visible app referencing them are unreachable for end users by construction; no per-artifact visibility flag is needed in v1. -- **Publish = `hidden: false`** — one metadata write, instant, reversible (**unpublish = re-hide**, which no draft model can offer). For governed orgs, the ADR-0027/0019 approval gate wraps this flip exactly as it wraps draft-publish today; what is being approved is now *making a working, inspectable app visible* rather than *promoting an invisible diff*. -- The chat's auto-publish policy is unchanged in spirit: whole-app builds in auto-publish environments flip visibility immediately after the machine gate passes; governed environments leave the app hidden pending review. +- The build's `app` materializes with **`_unpublished: true`** (and stays `active: true`). `filterAppForUser` (`@objectstack/rest`) withholds an unpublished app from every metadata response except a builder's, so it is absent from every end-user listing surface (launcher, home grid, switcher) by construction; direct URLs (`/apps/`) resolve for users with builder/admin permission. Objects and views without a visible app referencing them are unreachable for end users by construction; no per-artifact visibility flag is needed in v1. +- **`_unpublished` is machine-managed and excluded from what an author writes.** It is set by the materialization path and cleared by `publish-drafts`; the `_` prefix is the platform's existing marker for the channel tooling stamps onto artifacts (ADR-0010's `_lock` / `_provenance` envelope). ⛔ It is **not** `app.hidden`. `hidden` means "keep this app out of the App Switcher; surface it from the avatar menu" — the personal-settings case, e.g. the built-in Account app — and it never withholds access from anyone. The two are orthogonal and compose: an app can be hidden and published (Account), published and listed (CRM), or unpublished and either. +- **Publish = `_unpublished: false`** — one metadata write, instant, reversible (**unpublish = re-gate**, which no draft model can offer). For governed orgs, the ADR-0027 approval gate wraps this flip exactly as it wraps draft-publish today; what is being approved is now *making a working, inspectable app visible* rather than *promoting an invisible diff*. +- The chat's auto-publish policy is unchanged in spirit: whole-app builds in auto-publish environments clear the gate immediately after the machine gate passes; governed environments leave the app unpublished pending review. **The complete semantics of invisible.** "Hidden" means **externally unobservable**, consistently across every surface — not merely "absent from the launcher": -- **Discovery**: MCP tool listings, API catalogs, and marketplace/app directories exclude hidden apps **unconditionally**. A half-built app must never appear in an agent's tool inventory. -- **Direct API** (ADR-0036 apps-as-APIs): REST/MCP calls against a hidden app return 404 by default — the API analog of an end user pasting the URL. Builder-side integration testing is served by a **short-lived, package-scoped preview token** minted through the ADR-0039 token-scope-tree (no new mechanism); deferred past v1. -- **Outbound side-effects**: notifications, emails, webhooks, and scheduled/triggered flow actions originating from a hidden app are **suppressed by default**. The app is fully real for the builder interacting *with* it; it never reaches out and touches anyone who can't see it. (Blueprint builds author no flows today, so this is a stated invariant with zero v1 code — written down now so the first user who tests an approval flow inside a hidden app doesn't email the whole company.) +- **Discovery**: MCP tool listings, API catalogs, and marketplace/app directories exclude unpublished apps **unconditionally**. A half-built app must never appear in an agent's tool inventory. +- **Direct API** (ADR-0036 apps-as-APIs): REST/MCP calls against a unpublished app return 404 by default — the API analog of an end user pasting the URL. Builder-side integration testing is served by a **short-lived, package-scoped preview token** minted through the ADR-0039 token-scope-tree (no new mechanism); deferred past v1. +- **Outbound side-effects**: notifications, emails, webhooks, and scheduled/triggered flow actions originating from a unpublished app are **suppressed by default**. The app is fully real for the builder interacting *with* it; it never reaches out and touches anyone who can't see it. (Blueprint builds author no flows today, so this is a stated invariant with zero v1 code — written down now so the first user who tests an approval flow inside a unpublished app doesn't email the whole company.) Publish opens all three gates at once; unpublish closes them again. ### 4. Verification (ADR-0038 alignment) -- **L1 graph lint moves before materialization**: it already runs on the in-memory staged set (`stagedBodies`); under this ADR a failing lint means **nothing lands** — no half-built hidden app, no cleanup. The agent repairs the blueprint and re-applies. -- **L2/L3 probes improve**: render and data probes exercise the *real* hidden app — real tables, real seed rows, real dataset queries — before visibility flips. The 0038 incident classes "seed never materialized on publish" and "Published! but empty" cannot recur, because there is no second materialization step at publish time to fail. +- **L1 graph lint moves before materialization**: it already runs on the in-memory staged set (`stagedBodies`); under this ADR a failing lint means **nothing lands** — no half-built unpublished app, no cleanup. The agent repairs the blueprint and re-applies. +- **L2/L3 probes improve**: render and data probes exercise the *real* unpublished app — real tables, real seed rows, real dataset queries — before visibility flips. The 0038 incident classes "seed never materialized on publish" and "Published! but empty" cannot recur, because there is no second materialization step at publish time to fail. ### 5. Mutations: the draft workspace, narrowed and sharpened Everything ADR-0027/0033 says about drafts continues to apply to **changes to published artifacts**: stage → validate → **diff review** → human publish. What changes: - The **overlay preview plane narrows** to mutation review: `?preview=draft` renders a pending *change* to a visible app (the case where a side-by-side/diff actually beats a live app). The Live Canvas no longer depends on it for builds. -- **Edits to a still-hidden app are additive by definition** (nothing visible can break): they materialize directly into the hidden app. The iteration loop — say a thing, see the thing — runs at full fidelity with zero lifecycle friction. Drafting begins the moment the app becomes visible. +- **Edits to a still-unpublished app are additive by definition** (nothing visible can break): they materialize directly into the unpublished app. The iteration loop — say a thing, see the thing — runs at full fidelity with zero lifecycle friction. Drafting begins the moment the app becomes visible. - The chat's Changes panel / draft-status surfaces keep their role for the mutation partition; their pending-count source (`/meta/_drafts`) is unchanged. ### 6. Discard, residue, and quotas — the honest costs - **Discard = trash-can teardown.** Discarding an unpublished build atomically **renames** its package, metadata names, and physical tables into a trash namespace (`__trash_` suffix), hides them everywhere, and a janitor GCs after **7 days**; restore = rename back (conflict-checked). Rename is O(1), restore is lossless, and — critically for the AI loop — **the namespace frees immediately**, so "丢掉重建同名应用" never collides with a tombstone. The trash window also guards against the *agent* mis-firing a discard inside an ADR-0038 self-repair loop, not just the human. The confirmation still names what is deleted ("Delete the unpublished app *生产管理* and its 60 sample rows"). Iterative re-builds into the same package reuse the existing upsert path rather than discard+recreate. *v1 simplification*: typed-confirmation hard delete; the trash rename lands in v1.1 (discard is low-frequency — "不要了重来" flows through same-package upsert, not discard). -- **Preview-entered data survives publish.** Rows a builder creates while testing the hidden app are real and remain after the visibility flip. This matches Power Apps/Retool user expectations and is a *feature* (test data carries over), but must be explicit in the publish confirmation, with a one-click "reset to sample data" (re-run seeds) offered at publish time. +- **Preview-entered data survives publish.** Rows a builder creates while testing the unpublished app are real and remain after the visibility flip. This matches Power Apps/Retool user expectations and is a *feature* (test data carries over), but must be explicit in the publish confirmation, with a one-click "reset to sample data" (re-run seeds) offered at publish time. - **Hidden apps consume real resources.** They count toward environment quotas (tables, rows, storage). v1 guardrail: a per-environment cap on unpublished apps (entitlement-configurable), enforced at apply time with the ADR-0040 §5 limits pattern (soft prompt before hard cap). - **Namespace**: materialized names occupy the real namespace. This is not a regression — overlay drafts already reserve the same `sys_metadata` names today. ### 7. Surface changes (objectui / cloud) -- **Live Canvas** iframes `/apps/` (real app). The amber DraftPreviewBar generalizes to an **"Unpublished app" banner** on hidden apps: same watermark role, same Publish button (now a visibility flip), same exit affordance. Empty/error states from the preview hardening keep their structure with updated copy ("this app hasn't been built yet / failed to load"). +- **Live Canvas** iframes `/apps/` (real app). The amber DraftPreviewBar generalizes to an **"Unpublished app" banner** on unpublished apps: same watermark role, same Publish button (now a visibility flip), same exit affordance. Empty/error states from the preview hardening keep their structure with updated copy ("this app hasn't been built yet / failed to load"). - **`apply_blueprint`** (cloud) switches its additive path from `stageDraft` per artifact to materialization; its envelope keeps `drafted`-equivalent reporting (now `materialized`), `verification`, `packageId`, and gains the §1 classification block. The streaming build tree is unchanged — items appear as they land, but what lands is real. - **`publish-drafts`** for an additive package becomes the visibility flip (+ optional seed re-run); for mutation packages it keeps its current promote semantics. @@ -151,23 +152,58 @@ The **mechanism** — additivity classification, materialize/teardown, the visib ### v1 — "Magic moment on rails" (the only slice that matters first) -Acceptance, browser-level: *the instant an AI build finishes, the canvas shows a real app — seed rows in every list, kanban drags, record creation works; Publish puts it in the launcher instantly.* Plus three guard branches: failed L1 lint materializes nothing; auto-publish-off leaves the app hidden behind the banner; discard removes it after a typed confirmation. +Acceptance, browser-level: *the instant an AI build finishes, the canvas shows a real app — seed rows in every list, kanban drags, record creation works; Publish puts it in the launcher instantly.* Plus three guard branches: failed L1 lint materializes nothing; auto-publish-off leaves the app unpublished behind the banner; discard removes it after a typed confirmation. -1. **Framework**: materialization path for whole-app builds (reuse the package-install register + schema-sync + seed machinery — no new persistence); `hidden`-app visibility-flip publish; L1 lint as a pre-materialization gate (the in-memory lint already exists; the order flips from stage-then-lint to lint-then-land). The one genuinely careful piece: atomicity/teardown on mid-build failure. +1. **Framework**: materialization path for whole-app builds (reuse the package-install register + schema-sync + seed machinery — no new persistence); `_unpublished`-gate visibility-flip publish; L1 lint as a pre-materialization gate (the in-memory lint already exists; the order flips from stage-then-lint to lint-then-land). The one genuinely careful piece: atomicity/teardown on mid-build failure. 2. **Cloud**: `apply_blueprint` swaps `stageDraft` for materialization (contained in `blueprint-tools.ts`); envelope reports `materialized` + the §1 classification; auto-publish becomes the visibility flip. Streaming build tree, lint gate, package binding unchanged. 3. **objectui**: canvas iframes `/apps/` (drops `?preview=draft` for builds); amber bar generalizes to the "Unpublished app" banner (Publish = flip); discard with typed confirmation. -**v1 explicitly defers**: the full mixed-set classifier (v1 covers whole-app builds only — blueprint prompts already exclude existing objects, so "all names new + nothing reachable from visible apps" suffices; incremental edits keep today's draft + diff path, whose review surfaces already shipped); trash-can discard (v1.1); preview tokens (hidden-app API simply off); outbound suppression (stated invariant, no flow authoring in blueprints yet); quota cap (entitlement wiring exists, flip on in v1.1). +**v1 explicitly defers**: the full mixed-set classifier (v1 covers whole-app builds only — blueprint prompts already exclude existing objects, so "all names new + nothing reachable from visible apps" suffices; incremental edits keep today's draft + diff path, whose review surfaces already shipped); trash-can discard (v1.1); preview tokens (unpublished-app API simply off); outbound suppression (stated invariant, no flow authoring in blueprints yet); quota cap (entitlement wiring exists, flip on in v1.1). ### v2+ 4. Mixed-set partitioning (additive partition materializes, mutation partition drafts, one envelope). 5. Trash-can discard + 7-day GC; quota cap on unpublished apps. -6. ADR-0039 preview tokens for hidden-app API integration testing. +6. ADR-0039 preview tokens for unpublished-app API integration testing. 7. **Retire**: P3 synthetic-data plumbing beyond what mutation diff review needs; world-swap cache rules in `MetadataProvider` narrow accordingly. ## Resolved questions (decided 2026-06-12) 1. **Per-artifact visibility** — **not in v1, and not as a visibility flag at all.** The real hazard it pointed at (new artifacts implicitly surfacing on visible apps) is closed by the §1 reachability clause instead. True per-artifact visibility is an *audience-staged rollout* feature (admin-first dashboards, soft launches) — a distinct, likely-commercial capability to be specified when demanded, not built incidentally here. 2. **Discard window** — **trash-can rename + 7-day GC + lossless restore** (§6); v1 ships typed-confirmation hard delete, trash in v1.1. -3. **Hidden-app API exposure** — **default fully dark** (§3 "complete semantics of invisible"): excluded from discovery unconditionally, direct calls 404, outbound side-effects suppressed; builder integration testing later via ADR-0039 package-scoped preview tokens. +3. **Unpublished-app API exposure** — **default fully dark** (§3 "complete semantics of invisible"): excluded from discovery unconditionally, direct calls 404, outbound side-effects suppressed; builder integration testing later via ADR-0039 package-scoped preview tokens. + +--- + +## Amendment (2026-08-09): the gate gets its own key + +**Ruling**: maintainer, 2026-08-04 (direction) and 2026-08-07 (window — lands in v17), on [#4829](https://github.com/objectstack-ai/objectstack/issues/4829). **Change**: the publish gate is `app._unpublished`, a machine-managed key. It was `app.hidden`. Nothing else in this ADR changes — materialize-invisible, publish-as-a-flip, the "complete semantics of invisible", discard, quotas and the phase plan all stand, on a different carrier. + +### What was wrong + +This ADR never introduced `hidden`; it **borrowed** it. §2/§3 as originally written cited "the ADR-0019 launcher contract (`hidden`, `active`)" as an existing read side to ride on. That contract does not exist: **ADR-0019 does not contain the word `hidden`**, and never discussed launchers, the avatar menu, or the Account app. The reference was dangling from the day it was written, which is why nothing caught the collision it created. + +What `hidden` actually is, and was already, is a **navigation-presentation** key with its own written contract in `packages/spec/src/ui/app.zod.ts` — born in the same commit as the built-in Account app (74470ad44, 2026-05-28) and saying, in as many words, that *"hidden apps stay fully routable and permission-checked"* and that the shell surfaces them from the avatar menu. So from 2026-06-12 one boolean carried two contracts that contradict each other on the only question that matters: **may a normal user reach this app?** + +The failure was not theoretical, and it was silent in the way this repo's ADR-0078 class always is. `filterAppForUser` read `hidden` as the access gate, so the platform's own `account` app — authored `hidden: true` on purpose — was erased from `GET /api/v1/meta/app` for every user without `studio.access` / `setup.access`. Password changes, avatar, linked accounts, active sessions and the inbox were unreachable behind an "App not available — it may still be publishing" screen. Anyone holding `setup.access` saw a completely healthy system, so it survived a release candidate and shipped a downstream workaround (`os-project-titanwind-ehr` PLAT-DEF-040: a startup plugin pushing an env-level `{hidden:false}` overlay, whose visible side-effect — "Account" appearing in the App Switcher — is this amendment's argument in miniature). + +### Why a new key rather than removing the gate + +The first attempt at #4829 proposed simply taking `hidden` out of the access decision. That was refused, correctly: the gate is §3 of an **Accepted** ADR with pin tests and a live `publish-drafts` implementation behind it, so deleting it in a patch would reverse a recorded decision by side effect (Prime Directive #13). It is also the worse failure direction — a gate that fails **open** exposes a half-built app to real users, silently, which is strictly worse than the over-restriction being fixed. + +### Why the key is machine-managed + +Presentation and lifecycle are orthogonal concepts; compressing them into one boolean is the root cause, not the symptom. Splitting them restores both contracts, and the split is only durable if the lifecycle half cannot be authored: + +- `hidden: true` is the spelling a human — or an AI (ADR-0033) — reaches for naturally on a personal-settings app, because that is what the spec's own docblock teaches. Under this amendment that spelling is bounded to navigation, so the worst an author can do with it is a launcher placement. +- `_unpublished` is nobody's natural spelling. It is written by the materialization path and cleared by `publish-drafts`, and its `_` prefix is the platform's existing marker for the machine channel (ADR-0010's `_lock` / `_provenance` / `_packageId` envelope; the same prefix `lintAuthoredRecordKeys` skips as "tooling stamps this, an author does not"). The `AppSchema` strict-door also answers the author-shaped near-misses — `unpublished`, `published`, `draft` — with a prescription that says *do not author publish state*, rather than routing them onto the key. + +It is nonetheless **declared** on `AppSchema` rather than omitted, because the write path validates against that very schema (`saveMetaItem` → 422; `Registry.validate('app', …)` → `AppSchema.parse`). An undeclared key would make the platform's own flip unwritable. + +### Migration + +Stored `sys_metadata` app rows carrying `hidden: true` were written under the old regime, where that value uniquely meant *unpublished* — the code-declared Account app is a package artifact and never enters `sys_metadata`, so there is no ambiguous population. They are rewritten mechanically by the ADR-0087 D2 conversion `app-hidden-to-unpublished`, which replays on every stored-row rehydration seam (`applyConversionsToStoredItem`) as well as through `os migrate meta`. + +### Anchors + +The two load-bearing implementation sites are pinned in `scripts/adr-anchors.json` — `packages/rest/src/rest-server.ts` (the gate) and `packages/runtime/src/domains/packages.ts` (the flip). Neither carried an anchor before, which is the recurrence shape Prime Directive #13 names: the files that implemented this ADR's §3 never said which decision they were standing on, so the next author could not have known that changing them was changing a decision. diff --git a/docs/protocol-upgrade-guide.md b/docs/protocol-upgrade-guide.md index 84f86cd8bc..e5853025f7 100644 --- a/docs/protocol-upgrade-guide.md +++ b/docs/protocol-upgrade-guide.md @@ -281,6 +281,7 @@ The same descriptor loses a key in this step, and the pairing is the point (#674 | `page-card-body-to-children` | `page.component.page:card.body` | page:card component prop 'body' → 'children' (#5775 — one composition key across every container; the card renderer already reads both) | retired — `migrate meta` only | | `inline-action-api-params-to-body-extra` | `page.component.element:button.action.params` | inline type:'api' action prop 'params' (object form) → 'bodyExtra' (#5777 — the payload gets its own key; `params` stays the ActionParam[] definition array) | live — protocol 17 loader accepts the old shape | | `page-tabs-type-to-tab-style` | `page.component.page:tabs.type` | page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them) | retired — `migrate meta` only | +| `app-hidden-to-unpublished` | `app.hidden` | stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched) | retired — `migrate meta` only | ### Semantic (delegated to you, with acceptance criteria) diff --git a/packages/objectql/src/sys-metadata-repository.test.ts b/packages/objectql/src/sys-metadata-repository.test.ts index 789ab46803..ae24761860 100644 --- a/packages/objectql/src/sys-metadata-repository.test.ts +++ b/packages/objectql/src/sys-metadata-repository.test.ts @@ -673,15 +673,17 @@ describe('SysMetadataRepository', () => { it('promoteDraft carries the draft package binding onto the promoted active row', async () => { // A whole-app build stages every artifact bound to the app's workspace - // package and the app starts `hidden`. Promotion MUST preserve that - // binding — otherwise the active app row lands unbound (package_id NULL) - // and the ADR-0045 publish visibility flip (which looks up hidden apps - // by package: getMetaItems({ type:'app', packageId })) never matches it, - // leaving the freshly-built app hidden from the app switcher forever. + // package and the app starts `_unpublished` (#4829 — the ADR-0045 §3 + // gate; it rode on `hidden` until the two contracts were split). + // Promotion MUST preserve that binding — otherwise the active app row + // lands unbound (package_id NULL) and the publish visibility flip (which + // looks up unpublished apps by package: getMetaItems({ type:'app', + // packageId })) never matches it, leaving the freshly-built app + // externally unobservable forever. const ref = { org: 'org_alpha', type: 'app' as const, name: 'ticket_service_app' }; await repo.put( ref, - { name: 'ticket_service_app', label: 'Tickets', hidden: true }, + { name: 'ticket_service_app', label: 'Tickets', _unpublished: true }, { parentVersion: null, actor: 'studio', state: 'draft', packageId: 'app.tickets' }, ); await repo.promoteDraft(ref, { actor: 'admin' }); diff --git a/packages/rest/src/meta-app-publish-gate.test.ts b/packages/rest/src/meta-app-publish-gate.test.ts new file mode 100644 index 0000000000..fd23276269 --- /dev/null +++ b/packages/rest/src/meta-app-publish-gate.test.ts @@ -0,0 +1,185 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #4829 — end-to-end proof, over the wire, that the ADR-0045 §3 publish gate +// judges `_unpublished` and NOT `hidden`. +// +// The unit pins in `rest.test.ts` exercise `filterAppForUser` directly. This +// file exists because the bug users actually hit was a RESPONSE BODY fact: the +// platform's built-in `account` app is authored `hidden: true` — deliberately, +// so it reaches users through the avatar dropdown rather than the App Switcher +// (`platform-objects`' ACCOUNT_APP: "Surface via the avatar dropdown, not the +// App Switcher") — and the gate read that flag as "unpublished". Every user +// without `studio.access` / `setup.access` therefore got a `GET /api/v1/meta/app` +// with no `account` in it at all: clicking the avatar → 个人资料 landed on +// "App not available — it may still be publishing", and password, avatar, +// linked accounts, active sessions and inbox were unreachable. Admins saw a +// healthy system, which is why it survived a whole release candidate. +// +// So the acceptance criterion is stated the way the report was: what is in the +// JSON the normal user receives. Both read paths are covered, because they are +// separate handlers that each re-derive the gate — `GET /meta/:type` (list) and +// `GET /meta/:type/:name` (single item). + +import { describe, it, expect, vi } from 'vitest'; +import { RestServer } from './rest-server'; + +/** + * The built-in Account app, in the shape `platform-objects` authors it: hidden + * from the switcher, permission-gated by nothing, reachable by everyone. + */ +const ACCOUNT_APP = { + name: 'account', + label: 'Account', + hidden: true, + navigation: [ + { id: 'nav_profile', type: 'page', pageName: 'account_profile' }, + { id: 'nav_sessions', type: 'page', pageName: 'account_sessions' }, + ], +}; + +/** + * An AI-materialized build mid-flight: real, active metadata that no end user + * may observe until Publish clears the gate (ADR-0045 §2/§3). + */ +const UNPUBLISHED_APP = { + name: 'production_management', + label: '生产管理', + _unpublished: true, + navigation: [{ id: 'nav_secret_lines', type: 'object', objectName: 'secret_production_line' }], +}; + +const CRM_APP = { + name: 'crm', + label: 'CRM', + navigation: [{ id: 'nav_leads', type: 'object', objectName: 'lead' }], +}; + +const ALL_APPS = [ACCOUNT_APP, UNPUBLISHED_APP, CRM_APP]; + +function createMockServer() { + return { + get: vi.fn(), post: vi.fn(), put: vi.fn(), delete: vi.fn(), patch: vi.fn(), use: vi.fn(), + listen: vi.fn().mockResolvedValue(undefined), close: vi.fn().mockResolvedValue(undefined), + }; +} + +function makeRes() { + const res: any = { statusCode: 200, body: undefined }; + res.status = vi.fn((c: number) => { res.statusCode = c; return res; }); + res.json = vi.fn((b: any) => { res.body = b; return res; }); + res.header = vi.fn(); res.setHeader = vi.fn(); res.write = vi.fn(); res.end = vi.fn(); + return res; +} + +/** @param perms system permissions the caller holds */ +function setup(perms: string[]) { + const protocol: any = { + getDiscovery: vi.fn().mockResolvedValue({ version: 'v0', routes: { data: '', metadata: '', ui: '', auth: '/auth' } }), + getMetaTypes: vi.fn().mockResolvedValue([]), + // Deep-clone per call: the filter must never mutate stored metadata. + getMetaItems: vi.fn(async ({ type }: any) => { + const t = String(type ?? ''); + return t === 'app' || t === 'apps' ? JSON.parse(JSON.stringify(ALL_APPS)) : []; + }), + getMetaItem: vi.fn(async ({ name }: any) => { + const found = ALL_APPS.find((a) => a.name === name); + return found ? { type: 'app', name, item: JSON.parse(JSON.stringify(found)) } : undefined; + }), + findData: vi.fn().mockResolvedValue([]), + }; + const rest: any = new RestServer(createMockServer() as any, protocol, { api: { requireAuth: false } } as any); + // The RBAC filter only runs for a resolved caller; stubbing the context is + // the established pattern in this package for exercising it by route. + rest.resolveExecCtx = async () => ({ userId: 'u1', systemPermissions: perms }); + rest.registerRoutes(); + return { rest, protocol }; +} + +async function getList(rest: any, type = 'app') { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type'); + if (!route) throw new Error('meta/:type route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type }, query: {}, body: {}, headers: {} }, res); + return res; +} + +async function getItem(rest: any, name: string, type = 'app') { + const route = rest.getRoutes().find((r: any) => r.method === 'GET' && r.path === '/api/v1/meta/:type/:name'); + if (!route) throw new Error('meta/:type/:name route not registered'); + const res = makeRes(); + await route.handler({ method: 'GET', params: { type, name }, query: {}, body: {}, headers: {} }, res); + return res; +} + +/** LIST elements are metadata documents (`{ type, items: [...] }`, or a bare array). */ +const appsFrom = (body: any): any[] => (Array.isArray(body) ? body : (body?.items ?? [])); +const namesFrom = (body: any): string[] => appsFrom(body).map((a: any) => a?.name); + +describe('#4829 — `GET /meta/app` gates on `_unpublished`, never on `hidden`', () => { + it('LIST: a user with NO permissions receives the hidden `account` app', async () => { + const { rest } = setup([]); + const res = await getList(rest); + + expect(res.statusCode).toBe(200); + // The regression, stated as the bug report stated it. + expect(namesFrom(res.body)).toContain('account'); + // …and its navigation targets came with it, so 个人资料 resolves. + const wire = JSON.stringify(res.body); + expect(wire).toContain('account_profile'); + expect(wire).toContain('account_sessions'); + }); + + it('LIST: the same user does NOT receive an unpublished app, nor its targets', async () => { + const { rest } = setup([]); + const res = await getList(rest); + + expect(namesFrom(res.body)).not.toContain('production_management'); + // ADR-0045 §3 "externally unobservable" is about the wire bytes: an + // unpublished build must not leak the names it is built on either. + expect(JSON.stringify(res.body)).not.toContain('secret_production_line'); + }); + + it('LIST: a normal user sees exactly the published set — account included, build excluded', async () => { + const { rest } = setup(['manage_users']); + expect(namesFrom((await getList(rest)).body).sort()).toEqual(['account', 'crm']); + }); + + it('LIST: a builder additionally receives the unpublished app (direct-URL preview)', async () => { + for (const perm of ['studio.access', 'setup.access']) { + const { rest } = setup([perm]); + expect(namesFrom((await getList(rest)).body).sort()) + .toEqual(['account', 'crm', 'production_management']); + } + }); + + it('LIST: `hidden` is served, not stripped — nav placement stays the shell\'s decision', async () => { + const { rest } = setup([]); + const account = appsFrom((await getList(rest)).body).find((a: any) => a.name === 'account'); + expect(account?.hidden).toBe(true); + }); + + it('SINGLE ITEM: the hidden account app resolves for a user with no permissions', async () => { + const { rest } = setup([]); + const res = await getItem(rest, 'account'); + + expect(res.statusCode).toBe(200); + expect(res.body).toMatchObject({ type: 'app', name: 'account' }); + expect(res.body?.item?.name).toBe('account'); + expect(res.body?.item?.hidden).toBe(true); + }); + + it('SINGLE ITEM: the unpublished app 404s for a non-builder, with the named envelope', async () => { + const denied = await getItem(setup([]).rest, 'production_management'); + // ADR-0112 — a refusal is asserted by `status` AND `code`, never by + // "something falsy came back". The 404 is deliberate over a 403: the + // ADR-0045 §3 contract is *unobservable*, and a 403 confirms existence. + expect(denied.statusCode).toBe(404); + expect(denied.body?.error?.code).toBe('RESOURCE_NOT_FOUND'); + expect(denied.body?.item).toBeUndefined(); + expect(JSON.stringify(denied.body ?? {})).not.toContain('secret_production_line'); + + const allowed = await getItem(setup(['studio.access']).rest, 'production_management'); + expect(allowed.statusCode).toBe(200); + expect(allowed.body?.item?.name).toBe('production_management'); + }); +}); diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 98b8c4870c..b4ed9e61c3 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -2615,6 +2615,9 @@ export class RestServer { /** * Filter an `App` metadata item by the current user's `systemPermissions`. * + * - Drops the app entirely when it is UNPUBLISHED (`_unpublished: true`, + * ADR-0045 §3) and the caller is not a builder. Note the key: `hidden` is + * navigation presentation and is deliberately NOT consulted (#4829). * - Drops the app entirely if its top-level `requiredPermissions` are not * a subset of the user's system permissions. * - Recursively strips child navigation entries (groups, items) whose @@ -2633,9 +2636,9 @@ export class RestServer { * pinned in `rest.test.ts`: server-side CEL needs a bound `user` context * that this layer does not have, and is its own change. * - * Returns `null` when the app should be hidden from the user. Returns a - * shallow copy with filtered `navigation` / `areas` otherwise — the original - * is never mutated so cached metadata stays clean. + * Returns `null` when the app should be withheld from the user entirely. + * Returns a shallow copy with filtered `navigation` / `areas` otherwise — + * the original is never mutated so cached metadata stays clean. * * Takes the **app document itself**, never the `getMetaItem` envelope * (#5563). Both callers now hand it a document: the list path always did, @@ -2647,11 +2650,23 @@ export class RestServer { */ private filterAppForUser(item: any, sysPerms: Set, serviceGate?: (name: string) => boolean): any | null { if (!item || typeof item !== 'object') return item; - // ADR-0045: an unpublished app (`hidden: true`) is externally - // unobservable — only builders (studio/setup access) receive it at all, - // for direct-URL preview. The launcher's client-side hidden filter is a - // listing courtesy; THIS is the visibility gate. - if (item.hidden === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) { + // ADR-0045 §3 (as revised 2026-08, #4829) — the publish gate. An + // UNPUBLISHED app is externally unobservable, not merely unlisted: only + // builders (studio/setup access) receive it at all, for direct-URL + // preview. THIS is the visibility gate; the launcher's client-side + // filtering is a listing courtesy. + // + // ⛔ It judges `_unpublished`, the machine-managed key, and NOT `hidden`. + // `hidden` is navigation presentation — "not in the App Switcher, reach + // it from the avatar menu" — and reading it here made those two + // contracts one boolean. #4829 measured the cost: `account`, the + // platform's own personal-settings app, is authored `hidden: true` for + // exactly the reason its spec docblock gives, so this branch erased it + // from `GET /meta/app` for every user without builder access — password, + // avatar, sessions, inbox all 404 — while any admin saw a healthy + // system. A hidden app is fully routable and permission-checked here; + // only `_unpublished` withholds it. + if (item._unpublished === true && !sysPerms.has('studio.access') && !sysPerms.has('setup.access')) { return null; } const reqApp = Array.isArray(item.requiredPermissions) ? item.requiredPermissions : []; diff --git a/packages/rest/src/rest.test.ts b/packages/rest/src/rest.test.ts index 5a6114d727..9f7d506095 100644 --- a/packages/rest/src/rest.test.ts +++ b/packages/rest/src/rest.test.ts @@ -2931,24 +2931,43 @@ describe('RestServer metadata translation — page documents', () => { }); // --------------------------------------------------------------------------- -// ADR-0045 — hidden-app visibility gate (filterAppForUser) +// ADR-0045 §3 (revised 2026-08, #4829) — the PUBLISH gate (filterAppForUser) +// +// Two keys, two contracts, pinned in both directions because conflating them is +// the defect this block exists to prevent recurring: +// +// `_unpublished: true` machine-managed publish gate — externally +// unobservable, builders only. THIS is what the gate reads. +// `hidden: true` navigation presentation — not in the App Switcher, +// surfaced via the avatar menu. Fully routable and +// permission-checked for EVERY user; the gate ignores it. +// +// Until #4829 this suite pinned the gate on `hidden`, which is why the +// regression it caused survived: the platform's own `account` app is authored +// `hidden: true` (see `platform-objects`' ACCOUNT_APP, "Surface via the avatar +// dropdown, not the App Switcher"), so a green pin was asserting that every +// normal user is denied their own password / avatar / session settings. A pin +// that only tests one direction can be satisfied by the wrong mechanism. +// End-to-end proof over the wire lives in `meta-app-publish-gate.test.ts`. // --------------------------------------------------------------------------- -describe('filterAppForUser — ADR-0045 hidden-app gate', () => { +describe('filterAppForUser — ADR-0045 publish gate', () => { const make = () => new RestServer(createMockServer() as any, createMockProtocol() as any, ANON_API as any); - const hiddenApp = { name: 'production_management', hidden: true, navigation: [] }; + const unpublishedApp = { name: 'production_management', _unpublished: true, navigation: [] }; const visibleApp = { name: 'crm', navigation: [] }; + // The #4829 repro, in the shape `platform-objects` actually authors it. + const accountApp = { name: 'account', hidden: true, navigation: [] }; - it('drops a hidden app for users without builder access', () => { + it('drops an UNPUBLISHED app for users without builder access', () => { const rest: any = make(); - expect(rest.filterAppForUser(hiddenApp, new Set())).toBeNull(); - expect(rest.filterAppForUser(hiddenApp, new Set(['manage_users']))).toBeNull(); + expect(rest.filterAppForUser(unpublishedApp, new Set())).toBeNull(); + expect(rest.filterAppForUser(unpublishedApp, new Set(['manage_users']))).toBeNull(); }); - it('returns a hidden app to builders (studio.access or setup.access)', () => { + it('returns an unpublished app to builders (studio.access or setup.access)', () => { const rest: any = make(); - expect(rest.filterAppForUser(hiddenApp, new Set(['studio.access']))?.name).toBe('production_management'); - expect(rest.filterAppForUser(hiddenApp, new Set(['setup.access']))?.name).toBe('production_management'); + expect(rest.filterAppForUser(unpublishedApp, new Set(['studio.access']))?.name).toBe('production_management'); + expect(rest.filterAppForUser(unpublishedApp, new Set(['setup.access']))?.name).toBe('production_management'); }); it('leaves visible apps untouched for everyone', () => { @@ -2956,14 +2975,43 @@ describe('filterAppForUser — ADR-0045 hidden-app gate', () => { expect(rest.filterAppForUser(visibleApp, new Set())?.name).toBe('crm'); }); - it('still applies requiredPermissions to hidden apps builders can see', () => { + it('still applies requiredPermissions to unpublished apps builders can see', () => { const rest: any = make(); - const gated = { ...hiddenApp, requiredPermissions: ['manage_platform_settings'] }; + const gated = { ...unpublishedApp, requiredPermissions: ['manage_platform_settings'] }; expect(rest.filterAppForUser(gated, new Set(['studio.access']))).toBeNull(); expect( rest.filterAppForUser(gated, new Set(['studio.access', 'manage_platform_settings']))?.name, ).toBe('production_management'); }); + + // ---- the other direction: `hidden` must NOT gate access (#4829) ---- + + it('#4829: a `hidden` app is served to a user with NO permissions at all', () => { + const rest: any = make(); + expect(rest.filterAppForUser(accountApp, new Set())?.name).toBe('account'); + expect(rest.filterAppForUser(accountApp, new Set(['manage_users']))?.name).toBe('account'); + }); + + it('#4829: `hidden` survives the filter untouched — the shell, not the server, acts on it', () => { + const rest: any = make(); + // The server must keep serving the flag: nav placement is the CLIENT's + // decision, and stripping it here would move the launcher bug one layer out. + expect(rest.filterAppForUser(accountApp, new Set())?.hidden).toBe(true); + }); + + it('the two keys are independent — `hidden` does not weaken the publish gate', () => { + const rest: any = make(); + const both = { name: 'draft_settings', hidden: true, _unpublished: true, navigation: [] }; + expect(rest.filterAppForUser(both, new Set())).toBeNull(); + expect(rest.filterAppForUser(both, new Set(['studio.access']))?.name).toBe('draft_settings'); + }); + + it('a hidden app still answers to `requiredPermissions` — nav-only never means ungated', () => { + const rest: any = make(); + const gated = { ...accountApp, requiredPermissions: ['account.access'] }; + expect(rest.filterAppForUser(gated, new Set())).toBeNull(); + expect(rest.filterAppForUser(gated, new Set(['account.access']))?.name).toBe('account'); + }); }); // --------------------------------------------------------------------------- diff --git a/packages/runtime/src/domains/packages.ts b/packages/runtime/src/domains/packages.ts index d6bf46e18c..6cdc0ad0d7 100644 --- a/packages/runtime/src/domains/packages.ts +++ b/packages/runtime/src/domains/packages.ts @@ -192,16 +192,24 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin (result as any).seedApplied = { success: false, error: e?.message ?? 'seed apply failed' }; } } - // ADR-0045: "Publish" makes the package live AND visible. + // ADR-0045 §3: "Publish" makes the package live AND visible. // A materialized (additive) build has no drafts left to - // promote — its app sits at `hidden: true` awaiting the - // visibility flip. Unhide every hidden app bound to this - // package so ONE publish verb serves both regimes (the - // caller never needs to know how the package was built). - // Best-effort: a custom protocol without the meta + // promote — its app sits at `_unpublished: true` awaiting the + // visibility flip. Clear the gate on every unpublished app + // bound to this package so ONE publish verb serves both + // regimes (the caller never needs to know how the package was + // built). Best-effort: a custom protocol without the meta // primitives keeps plain draft-publish semantics. // - // #5242 — `unhidden` and its result assignment live OUTSIDE + // ⛔ #4829 — the gate is `_unpublished`, the MACHINE-managed + // key, and this is the point that clears it. It used to be + // `hidden`, which also means "keep out of the App Switcher" + // to every author — so publishing a package silently rewrote + // a presentation choice, and the REST gate reading the same + // flag 404'd the built-in `account` app for every non-builder. + // `hidden` is now never read or written here. + // + // #5242 — `flipped` and its result assignment live OUTSIDE // this try. A name is pushed only AFTER its `saveMetaItem` // resolved, so at any moment the list is exactly "what is // already flipped on disk". When app k of N throws, the k-1 @@ -212,7 +220,14 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // and the 'metadata:reloaded' announce below — which reads // `unhiddenApps` — skipped them too, leaving boot-cached // consumers stale until the next restart. - const unhidden: string[] = []; + // + // The RESPONSE field keeps its `unhiddenApps` / `unhideError` + // spelling deliberately: it is a wire contract read by the + // objectui Publish button, and renaming it here — in a repo + // that cannot verify or update that consumer — would be a + // silent break of the exact kind #4829 is about. The rename + // rides the objectui follow-up card, together. + const flipped: string[] = []; try { if ( typeof (protocol as any).getMetaItems === 'function' && @@ -227,16 +242,22 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin ? appsRes : Array.isArray((appsRes as any)?.items) ? (appsRes as any).items : []; for (const app of apps) { - if (app && typeof app === 'object' && app.hidden === true && typeof app.name === 'string') { + if (app && typeof app === 'object' && app._unpublished === true && typeof app.name === 'string') { await (protocol as any).saveMetaItem({ type: 'app', name: app.name, - item: { ...app, hidden: false }, + // `false`, not a delete: ADR-0045 §3 makes + // publish/unpublish symmetric ("unpublish = + // re-hide"), so the gate stays a two-state + // flag rather than a key whose absence has + // to be re-derived. Whatever `hidden` the + // app carries is copied through untouched. + item: { ...app, _unpublished: false }, packageId: id, ...(organizationId ? { organizationId } : {}), ...(body?.actor ? { actor: body.actor } : {}), }); - unhidden.push(app.name); + flipped.push(app.name); } } } @@ -257,17 +278,17 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // is still stored hidden", which is plainly false once any // flip persisted, and it left the operator to infer // "nothing changed" from a bare failure line. - const stillHidden = unhidden.length > 0 - ? `the flip stopped PARTWAY — ${unhidden.length} app(s) DID flip and are stored visible ` + - `(${unhidden.join(', ')}; they are reported under \`unhiddenApps\` and were announced for ` + - `re-sync), while every REMAINING hidden app bound to it` - : `every hidden app bound to it`; + const stillUnpublished = flipped.length > 0 + ? `the flip stopped PARTWAY — ${flipped.length} app(s) DID flip and are stored published ` + + `(${flipped.join(', ')}; they are reported under \`unhiddenApps\` and were announced for ` + + `re-sync), while every REMAINING unpublished app bound to it` + : `every unpublished app bound to it`; logger.error( `[Packages] publish-drafts: the ADR-0045 visibility flip FAILED for package '${id}' — its drafts ARE ` + - `published and live, but ${stillHidden} is still STORED with \`hidden: true\`, so those ` + - `apps stay invisible in the launcher while the publish reports success. Nothing retries this flip. ` + + `published and live, but ${stillUnpublished} is still STORED with \`_unpublished: true\`, so those ` + + `apps stay externally unobservable while the publish reports success. Nothing retries this flip. ` + `Re-run POST /packages/${id}/publish-drafts once the cause below is resolved (it is idempotent), or ` + - `unhide one app directly via PUT /meta/app/ with \`{"hidden": false}\`. Cause: ` + + `publish one app directly via PUT /meta/app/ with \`{"_unpublished": false}\`. Cause: ` + `${e?.message ?? String(e)}`, ); (result as any).unhideError = e?.message ?? 'visibility flip failed'; @@ -277,8 +298,8 @@ export async function handlePackagesRequest(deps: DomainHandlerDeps, path: strin // `unhideError`: together they say what did flip and that // something did not, which is the honest report. It must // stay ABOVE the announce block, which reads this field. - if (unhidden.length > 0) (result as any).unhiddenApps = unhidden; - // A publish promoted drafts to active (or unhid an additive + if (flipped.length > 0) (result as any).unhiddenApps = flipped; + // A publish promoted drafts to active (or published an additive // app) at RUNTIME — but boot-cached consumers still hold the // pre-publish view. The load-bearing one is the automation // engine: a record-triggered flow authored + published in the diff --git a/packages/runtime/src/http-dispatcher.test.ts b/packages/runtime/src/http-dispatcher.test.ts index ce79406c53..3a01681fef 100644 --- a/packages/runtime/src/http-dispatcher.test.ts +++ b/packages/runtime/src/http-dispatcher.test.ts @@ -1833,16 +1833,23 @@ describe('HttpDispatcher', () => { ); }); - // ADR-0045: "Publish" = live AND visible. A materialized (additive) - // build leaves its app at hidden:true; publish-drafts must flip it so - // one publish verb serves both the draft and the materialize regimes. - it('POST /packages/:id/publish-drafts unhides the package\'s hidden app', async () => { + // ADR-0045 §3: "Publish" = live AND visible. A materialized (additive) + // build leaves its app at `_unpublished: true`; publish-drafts must clear + // that gate so one publish verb serves both the draft and the + // materialize regimes. + // + // #4829 — the gate moved off `hidden`. The KEY matters here as much as + // the behaviour: `hidden` also means "keep out of the App Switcher", so + // the old flip silently rewrote a presentation choice on publish, and + // the REST gate reading the same flag erased the built-in `account` app + // for every non-builder. + it('POST /packages/:id/publish-drafts clears the publish gate on the package\'s unpublished app', async () => { const publishPackageDrafts = vi.fn().mockResolvedValue({ success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, }); const getMetaItems = vi.fn().mockResolvedValue([ - { name: 'production_management', label: '生产管理', hidden: true, navigation: [] }, - { name: 'already_visible', hidden: false, navigation: [] }, + { name: 'production_management', label: '生产管理', _unpublished: true, navigation: [] }, + { name: 'already_published', _unpublished: false, navigation: [] }, ]); const saveMetaItem = vi.fn().mockResolvedValue({ ok: true }); (kernel as any).getService = vi.fn().mockImplementation((name: string) => { @@ -1855,17 +1862,53 @@ describe('HttpDispatcher', () => { expect(result.response?.status).toBe(200); expect(getMetaItems).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', packageId: 'app.production_management' })); - // Only the hidden app is re-saved, with hidden:false and everything else intact. + // Only the unpublished app is re-saved, with `_unpublished: false` + // and everything else intact. expect(saveMetaItem).toHaveBeenCalledTimes(1); expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ type: 'app', name: 'production_management', - item: expect.objectContaining({ hidden: false, label: '生产管理' }), + item: expect.objectContaining({ _unpublished: false, label: '生产管理' }), packageId: 'app.production_management', })); expect((result.response as any)?.body?.data?.unhiddenApps).toEqual(['production_management']); }); + // #4829 — the other half of the split, pinned at the WRITE point. + // Publishing must not touch navigation presentation: an app authored + // `hidden: true` (the Account / personal-settings shape) that is also + // unpublished comes out of Publish still hidden from the App Switcher. + // Under the old regime this write was what destroyed that choice, + // because "publish" and "show in the switcher" were one key. + it('POST /packages/:id/publish-drafts leaves `hidden` untouched — it publishes, it does not un-hide', async () => { + const publishPackageDrafts = vi.fn().mockResolvedValue({ + success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, + }); + const getMetaItems = vi.fn().mockResolvedValue([ + { name: 'account_like', hidden: true, _unpublished: true, navigation: [] }, + // Hidden but already published: nothing to do here. A flip keyed + // on `hidden` would have re-saved this one and un-hidden it. + { name: 'account', hidden: true, navigation: [] }, + ]); + const saveMetaItem = vi.fn().mockResolvedValue({ ok: true }); + (kernel as any).getService = vi.fn().mockImplementation((name: string) => { + if (name === 'protocol') return Promise.resolve({ publishPackageDrafts, getMetaItems, saveMetaItem }); + if (name === 'objectql') return Promise.resolve({ registry: { getAllPackages: vi.fn().mockReturnValue([]) } }); + return null; + }); + + const result = await dispatcher.handlePackages('/app.acct/publish-drafts', 'POST', {}, {}, { request: {} }); + + expect(result.response?.status).toBe(200); + expect(saveMetaItem).toHaveBeenCalledTimes(1); + expect(saveMetaItem).toHaveBeenCalledWith(expect.objectContaining({ + name: 'account_like', + item: expect.objectContaining({ hidden: true, _unpublished: false }), + })); + expect(saveMetaItem).not.toHaveBeenCalledWith(expect.objectContaining({ name: 'account' })); + expect((result.response as any)?.body?.data?.unhiddenApps).toEqual(['account_like']); + }); + it('POST /packages/:id/publish-drafts reports (not throws) when the visibility flip fails', async () => { const publishPackageDrafts = vi.fn().mockResolvedValue({ success: true, publishedCount: 1, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, @@ -1897,7 +1940,7 @@ describe('HttpDispatcher', () => { success: true, publishedCount: 1, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, }); const getMetaItems = vi.fn().mockResolvedValue([ - { name: 'edu_admin', hidden: true, navigation: [] }, + { name: 'edu_admin', _unpublished: true, navigation: [] }, ]); const saveMetaItem = vi.fn().mockRejectedValue(new Error('sys_metadata write rejected')); (kernel as any).getService = vi.fn().mockImplementation((name: string) => { @@ -1922,7 +1965,7 @@ describe('HttpDispatcher', () => { // The consequence, concretely — what is not durable, and that the // system keeps looking healthy anyway. expect(line).toContain('app.edu'); - expect(line).toMatch(/hidden/i); + expect(line).toMatch(/unpublished/i); expect(line).toMatch(/publish reports success|reports success/i); // The fix — the concrete action that restores the intended state. expect(line).toContain('publish-drafts'); @@ -1939,17 +1982,17 @@ describe('HttpDispatcher', () => { // tells the caller nothing happened for apps whose state DID change, // and the 'metadata:reloaded' announce (which reads `unhiddenApps`) // then skips exactly those apps, leaving boot-cached consumers stale. - it('POST /packages/:id/publish-drafts reports the apps already unhidden when the flip fails MID-LOOP', async () => { + it('POST /packages/:id/publish-drafts reports the apps already published when the flip fails MID-LOOP', async () => { const publishPackageDrafts = vi.fn().mockResolvedValue({ success: true, publishedCount: 0, failedCount: 0, published: [], failed: [], seedApplied: { success: true }, }); - // 4 hidden apps; the write for the 3rd rejects. So `alpha` and - // `beta` are persisted visible, `gamma` and `delta` are not. + // 4 unpublished apps; the write for the 3rd rejects. So `alpha` and + // `beta` are persisted published, `gamma` and `delta` are not. const getMetaItems = vi.fn().mockResolvedValue([ - { name: 'alpha', hidden: true, navigation: [] }, - { name: 'beta', hidden: true, navigation: [] }, - { name: 'gamma', hidden: true, navigation: [] }, - { name: 'delta', hidden: true, navigation: [] }, + { name: 'alpha', _unpublished: true, navigation: [] }, + { name: 'beta', _unpublished: true, navigation: [] }, + { name: 'gamma', _unpublished: true, navigation: [] }, + { name: 'delta', _unpublished: true, navigation: [] }, ]); const saveMetaItem = vi.fn().mockImplementation(async ({ name }: { name: string }) => { if (name === 'gamma') throw new Error('sys_metadata write rejected'); @@ -1988,14 +2031,14 @@ describe('HttpDispatcher', () => { ); // The operator-facing line names BOTH halves: what flipped and - // what is still stored hidden. The old wording claimed "every - // hidden app is still stored hidden", which is false here. + // what is still stored unpublished. The old wording claimed + // "every hidden app is still stored hidden", which is false here. const line = errorSpy.mock.calls .map((c) => String(c?.[0] ?? '')) .find((l) => l.includes('[Packages] publish-drafts')) ?? ''; expect(line).toContain('alpha, beta'); expect(line).toMatch(/PARTWAY/); - expect(line).toMatch(/REMAINING hidden app/); + expect(line).toMatch(/REMAINING unpublished app/); expect(line).toContain('sys_metadata write rejected'); } finally { errorSpy.mockRestore(); diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index b15a0009d5..258254345a 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -99,6 +99,7 @@ "ui/App:_packageId", "ui/App:_packageVersion", "ui/App:_provenance", + "ui/App:_unpublished", "ui/App:active", "ui/App:apis [RETIRED]", "ui/App:areas", diff --git a/packages/spec/liveness/app.json b/packages/spec/liveness/app.json index 22762abc68..f4ba408af5 100644 --- a/packages/spec/liveness/app.json +++ b/packages/spec/liveness/app.json @@ -1,6 +1,6 @@ { "type": "app", - "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:1808-1888 — the SERVER-side authority for app/nav permission + capability gating and ADR-0045 hidden-app visibility), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. The one GAP found there is now CLOSED (#4509, objectui @e8bec83): an `action` item's click dispatches through a host-supplied `onAction` prop that no shipped shell passed, so `actionDef.actionName` reached no dispatcher and every such item dead-clicked. objectui's `useNavActionDispatch` (objectui: packages/app-shell/src/hooks/useNavActionDispatch.ts) resolves the name against `action` metadata and dispatches through the console action runtime, and UnifiedSidebar passes it (objectui: packages/app-shell/src/layout/UnifiedSidebar.tsx:473). A shell that still passes no handler now HIDES action items rather than rendering them dead (objectui: packages/layout/src/NavigationRenderer.tsx:971) — the renderer stops manufacturing the trap. Also note that filterAppForUser USED to walk only the top-level `navigation` tree — it never read `item.areas` at all (it returned early when `navigation` was absent), while the client area switcher renders every area. That made area-level `visible` / `requiredPermissions` FAIL-OPEN gates, not merely unread: a \"hidden\" or permission-gated area showed to everyone. Closed in #4722 for the layer that survived the retirement — the server now runs the same filterNav over every `areas[].navigation` — see the `areas.navigation` row below. AREA GATES, 17.0.0 (#4651): both keys REMOVED and their rows DELETED — NavigationAreaSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Route B (remove) over route A (enforce) was the maintainer's call: enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold, and a gate that never gated is strictly safer removed than shipped for a whole major. The strict rejection carries the prescription (ui/app.zod.ts AREA_VISIBLE_RETIRED / AREA_REQUIRED_PERMISSIONS_RETIRED) and names the layers that DO enforce. The boundary those prescriptions pointed at — per-item gating inside an area being shell-side only — was the real gap #4651 left behind, and #4722 closed it: item-level `requiredPermissions` / `requiresService` are now stripped server-side inside `areas[]` too, `visible` (CEL) deliberately not. Recorded on `areas.navigation` below. The area-LEVEL keys remain retired; they were not revived. Seeded 2026-08-01 (#4488). CONTEXT SELECTORS, 17.0.0 (#4509): `includeAll` and `placement` rows DELETED — AppContextSelectorSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Both were unwarnable (schema defaults materialize at parse, so the lint could not tell authored from supplied), which made removal the only channel that could reach an author. `includeAll` was the sharp one: not unread but deliberately DISOBEYED — selectors are mandatory-scope, and an All row would clear the scope, which on Studio's package selector means listing the platform's own system/cloud kernel packages. STUDIO_APP authored `includeAll: true` against a renderer that ignored it, and that authoring site went with the key.", + "_note": "AppSchema — the navigation shell, the densest hand-authored surface on the platform. Consumers: the REST read layer's filterAppForUser (packages/rest/src/rest-server.ts:2651-2740 — the SERVER-side authority for app/nav permission + capability gating and the ADR-0045 publish gate, which judges `_unpublished` and NOT `hidden` since #4829), the spec i18n translateApp (i18n-resolver.ts:472), and objectui's shell (@940ba24: app-shell AppSidebar/ConsoleLayout/ContextSelectors, layout NavigationRenderer, console RootLandingRedirect). The #4001/#4142 app step already retired seven dead keys as retiredKey tombstones — they stay in the walked shape, so their rows stay here (tombstone rule, orphans.mts). WALK BOUNDARY (#3095 union rule): `navigation` drills into the union's FIRST member (the `object` variant + base keys); the other variants' payload keys sit outside the walk and were verified by hand — dashboardName (NavigationRenderer.tsx:433), pageName (:435-442), url/target (:462), reportName (:460), componentRef (:464,:644), group `expanded` (:856) all live. The one GAP found there is now CLOSED (#4509, objectui @e8bec83): an `action` item's click dispatches through a host-supplied `onAction` prop that no shipped shell passed, so `actionDef.actionName` reached no dispatcher and every such item dead-clicked. objectui's `useNavActionDispatch` (objectui: packages/app-shell/src/hooks/useNavActionDispatch.ts) resolves the name against `action` metadata and dispatches through the console action runtime, and UnifiedSidebar passes it (objectui: packages/app-shell/src/layout/UnifiedSidebar.tsx:473). A shell that still passes no handler now HIDES action items rather than rendering them dead (objectui: packages/layout/src/NavigationRenderer.tsx:971) — the renderer stops manufacturing the trap. Also note that filterAppForUser USED to walk only the top-level `navigation` tree — it never read `item.areas` at all (it returned early when `navigation` was absent), while the client area switcher renders every area. That made area-level `visible` / `requiredPermissions` FAIL-OPEN gates, not merely unread: a \"hidden\" or permission-gated area showed to everyone. Closed in #4722 for the layer that survived the retirement — the server now runs the same filterNav over every `areas[].navigation` — see the `areas.navigation` row below. AREA GATES, 17.0.0 (#4651): both keys REMOVED and their rows DELETED — NavigationAreaSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Route B (remove) over route A (enforce) was the maintainer's call: enforcing needs semantics decided first (does filtering an area remove its items everywhere? does the server bind `user` for area CEL?), which the 17.0.0 window could not hold, and a gate that never gated is strictly safer removed than shipped for a whole major. The strict rejection carries the prescription (ui/app.zod.ts AREA_VISIBLE_RETIRED / AREA_REQUIRED_PERMISSIONS_RETIRED) and names the layers that DO enforce. The boundary those prescriptions pointed at — per-item gating inside an area being shell-side only — was the real gap #4651 left behind, and #4722 closed it: item-level `requiredPermissions` / `requiresService` are now stripped server-side inside `areas[]` too, `visible` (CEL) deliberately not. Recorded on `areas.navigation` below. The area-LEVEL keys remain retired; they were not revived. Seeded 2026-08-01 (#4488). CONTEXT SELECTORS, 17.0.0 (#4509): `includeAll` and `placement` rows DELETED — AppContextSelectorSchema is strict, so the keys left the walked shape and retained rows would report ORPHAN. Both were unwarnable (schema defaults materialize at parse, so the lint could not tell authored from supplied), which made removal the only channel that could reach an author. `includeAll` was the sharp one: not unread but deliberately DISOBEYED — selectors are mandatory-scope, and an All row would clear the scope, which on Studio's package selector means listing the platform's own system/cloud kernel packages. STUDIO_APP authored `includeAll: true` against a renderer that ignored it, and that authoring site went with the key.", "props": { "name": { "status": "live", @@ -46,9 +46,9 @@ }, "hidden": { "status": "live", - "verifiedAt": "2026-08-01", - "evidence": "packages/rest/src/rest-server.ts:1811; objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178", - "note": "SERVER-enforced (ADR-0045): a hidden app is served only to builders (studio/setup access) for direct-URL preview; the client switcher filter is a listing courtesy on top." + "verifiedAt": "2026-08-09", + "evidence": "objectui @940ba24: packages/app-shell/src/layout/AppSidebar.tsx:178; packages/platform-objects/src/apps/account.app.ts:40", + "note": "NAVIGATION PRESENTATION ONLY, and re-verified as such at #4829: keep the app out of the App Switcher; the shell surfaces it from the avatar menu (the built-in Account app is the canonical author). It is NOT an access gate — a hidden app stays fully routable and permission-checked for every user, which is its birth contract in app.zod.ts. Between ADR-0045 (2026-06-12) and its 2026-08-09 amendment the REST gate ALSO read this key as \"unpublished\", which erased the Account app from GET /meta/app for every non-builder; that reading now lives on `_unpublished`. The consumer is therefore the client switcher alone." }, "navigation": { "children": { @@ -276,6 +276,12 @@ "status": "dead", "verifiedAt": "2026-08-01", "note": "retiredKey tombstone (#4142) — fully unimplemented; returns if/when a real mobile navigation ships." + }, + "_unpublished": { + "status": "live", + "verifiedAt": "2026-08-09", + "evidence": "packages/rest/src/rest-server.ts:2669; packages/runtime/src/domains/packages.ts:245", + "note": "MACHINE-MANAGED publish gate (ADR-0045 §3, amended 2026-08-09 / #4829) — never authored: written by the AI additive-materialization path (cloud) and cleared by POST /packages/:id/publish-drafts. SERVER-enforced: filterAppForUser withholds an unpublished app from every metadata response except a builder's (studio/setup access), for direct-URL preview. Declared on AppSchema rather than omitted because the write path validates against that schema (saveMetaItem → 422; Registry.validate('app') → AppSchema.parse), so the flip itself would be unwritable otherwise. Stored pre-amendment rows carrying `hidden: true` are rewritten here by the ADR-0087 conversion `app-hidden-to-unpublished`." } } } diff --git a/packages/spec/spec-changes.json b/packages/spec/spec-changes.json index e1f7bb59ae..a3f627d548 100644 --- a/packages/spec/spec-changes.json +++ b/packages/spec/spec-changes.json @@ -373,6 +373,12 @@ "to": "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", "conversionId": "page-tabs-type-to-tab-style", "toMajor": 17 + }, + { + "surface": "app.hidden", + "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", + "conversionId": "app-hidden-to-unpublished", + "toMajor": 17 } ], "migrated": [ @@ -1227,6 +1233,12 @@ "to": "page:tabs component prop 'type' → 'tabStyle' (#6776 — a props key named `type` collides with the node's dispatch key and is unauthorable in flat/JSX carriers; `tabStyle` is the spelling the renderer reads in all of them)", "conversionId": "page-tabs-type-to-tab-style", "toMajor": 17 + }, + { + "surface": "app.hidden", + "to": "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", + "conversionId": "app-hidden-to-unpublished", + "toMajor": 17 } ], "migrated": [ diff --git a/packages/spec/src/conversions/conversions.test.ts b/packages/spec/src/conversions/conversions.test.ts index a0de955157..66c0a6e18a 100644 --- a/packages/spec/src/conversions/conversions.test.ts +++ b/packages/spec/src/conversions/conversions.test.ts @@ -1192,4 +1192,92 @@ describe('conversion layer (ADR-0087 D2)', () => { expect(PageSchema.safeParse(page({ tabStyle: 'card', items: [] })).success).toBe(true); }); }); + + /** + * `app-hidden-to-unpublished` (#4829, ADR-0045 amended 2026-08-09). + * + * The fixture pair pins before → after and the notice count. What needs its + * own cover here is the property the fixture cannot express, and the one this + * entry would be dangerous without: **its reach is the stored population and + * nothing else.** + * + * `hidden` is not a retired key. It keeps its birth contract — navigation + * presentation — so an author writing `hidden: true` (the Account / + * personal-settings case, which the platform itself does) must come out of the + * load path with `hidden: true` still on the app. A conversion firing there + * would rewrite that into an unpublished app and reproduce #4829 one layer + * down, through the very machinery meant to repair it. `retiredFromLoadPath` + * is what buys that, so it is asserted directly rather than inferred. + */ + describe('app-hidden-to-unpublished (#4829)', () => { + const storedApp = (app: Record) => ({ apps: [{ name: 'production_management', ...app }] }); + const convertStored = (stack: Record) => { + const notices: ConversionNotice[] = []; + const out = applyConversions(stack, { includeRetired: true, onNotice: (n) => notices.push(n) }); + return { out, notices }; + }; + const appOf = (stack: Record) => (stack.apps as Record[])[0]!; + + it('rewrites a stored `hidden: true` app to `_unpublished: true`, dropping `hidden`', () => { + const { out, notices } = convertStored(storedApp({ hidden: true, label: 'PM', navigation: [] })); + expect(appOf(out)).toEqual({ name: 'production_management', _unpublished: true, label: 'PM', navigation: [] }); + expect(notices.map((n) => n.conversionId)).toEqual(['app-hidden-to-unpublished']); + expect(notices[0]!.path).toBe('apps[0]._unpublished'); + expect(notices[0]!.from).toBe('hidden'); + expect(notices[0]!.to).toBe('_unpublished'); + }); + + it('leaves `hidden: false` alone — it meant "listed" under both regimes', () => { + const before = storedApp({ hidden: false, navigation: [] }); + const { out, notices } = convertStored(before); + expect(out).toBe(before); + expect(notices).toEqual([]); + }); + + it('#4923: a row that already carries `_unpublished` keeps BOTH keys, untouched', () => { + // The machine has already spoken about this row. Reconciling a + // disagreeing pair is a human's call, not the loader's — and it is what + // makes the pass idempotent on a row that has already converted. + const before = storedApp({ hidden: true, _unpublished: false, navigation: [] }); + const { out, notices } = convertStored(before); + expect(out).toBe(before); + expect(notices).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions(storedApp({ hidden: true, navigation: [] }), { includeRetired: true }); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('replays on the STORED seam, which is the population it exists for', () => { + const converted = applyConversionsToStoredItem('app', { name: 'edu_admin', hidden: true, navigation: [] }); + expect(converted).toEqual({ name: 'edu_admin', _unpublished: true, navigation: [] }); + }); + + // ---- the reach, asserted in the negative ---- + + it('⛔ does NOT fire on the LOAD path — an authored `hidden: true` survives verbatim', () => { + // `includeRetired` defaults to false, which IS the load seam + // (`normalizeStackInput` for defineStack / validate / lint). This is the + // assertion that keeps the built-in Account app — authored `hidden: true` + // for the avatar-menu reason — from being converted into an app no normal + // user may reach, which is the #4829 defect itself. + const account = { apps: [{ name: 'account', label: 'Account', hidden: true, navigation: [] }] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(account, { onNotice: (n) => notices.push(n) }); + expect(out).toBe(account); + expect(appOf(out)).toEqual({ name: 'account', label: 'Account', hidden: true, navigation: [] }); + expect(notices).toEqual([]); + }); + + it('is declared `retiredFromLoadPath` — the flag IS the load-path exclusion', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === 'app-hidden-to-unpublished'); + expect(entry).toBeDefined(); + expect(entry!.retiredFromLoadPath).toBe(true); + expect(entry!.toMajor).toBe(17); + }); + }); }); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 2bc1bce250..d1ffe3ff0e 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -5288,6 +5288,97 @@ const pageTabsTypeToTabStyle: MetadataConversion = { }, }; +/** + * `app.hidden: true` → `app._unpublished: true` on **stored** rows (protocol 17, + * #4829, ADR-0045 amended 2026-08-09). + * + * ADR-0045 §3 originally hung its publish gate on `app.hidden`, citing an + * "ADR-0019 launcher contract" that does not exist — ADR-0019 contains no + * `hidden`. `hidden` already had a contract of its own, written in + * `ui/app.zod.ts` the day the key was born: navigation presentation, *"hidden + * apps stay fully routable and permission-checked"*, for personal-settings apps + * reached from the avatar menu. One boolean, two contracts, contradicting each + * other on the only question that matters — and the platform's own `account` + * app, authored `hidden: true` on purpose, was therefore withheld from every + * user without builder access. The gate now reads the machine-managed + * `_unpublished`; this entry carries the existing population across. + * + * **Why the rewrite is unambiguous.** Under the old regime a `hidden: true` row + * in `sys_metadata` could only have come from the materialization path, because + * that value *was* the gate: an app stored that way was invisible to every + * non-builder, so nobody stored it to mean "keep me out of the switcher". The + * one app that really does mean that is code-declared (`platform-objects`' + * ACCOUNT_APP), and code-declared artifacts never enter `sys_metadata`. The + * Studio app form has no `hidden` control either (`ui/app.form.ts`), so no + * authoring path could have produced a second meaning. + * + * **`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`. + * + * That split is also the answer for anyone who later wants a *stored* app to be + * nav-hidden: declare it on the app artifact, which this entry never touches. If + * a stored-row spelling is ever wanted it needs its own decision — a Studio + * control, and a rule for how the two populations coexist — not this entry + * quietly ceasing to fire. + * + * A row that already carries `_unpublished` is left ALONE, both keys intact + * ({@link renameKey}'s house rule, #4923): the machine has already spoken about + * that row, and a disagreeing pair is for a human to reconcile rather than for + * the loader to pick a winner. It is also what makes a second pass a no-op. + */ +const appHiddenToUnpublished: MetadataConversion = { + id: 'app-hidden-to-unpublished', + toMajor: 17, + retiredFromLoadPath: true, + surface: 'app.hidden', + summary: + "stored app publish gate 'hidden' → '_unpublished' (#4829, ADR-0045 amended — `hidden` carried BOTH the publish gate and 'keep out of the App Switcher', so the built-in Account app was withheld from every non-builder; the gate is now the machine-managed `_unpublished`, and `hidden` is navigation presentation only, never an access gate. Stored rows only — an authored `hidden: true` is left untouched)", + apply(stack, emit) { + return mapCollection(stack, 'apps', (app, path) => { + if (app.hidden !== true) return app; + if (app._unpublished != null) return app; + const next = { ...app }; + delete next.hidden; + next._unpublished = true; + emit({ from: 'hidden', to: '_unpublished', path: `${path}._unpublished` }); + return next; + }); + }, + fixture: { + // DISJOINT from every other app fixture: none of these apps carries a key + // another entry strips, so each replays through the whole table hitting + // only its own. + before: { + apps: [ + // The materialized build mid-flight — the population this exists for. + { name: 'production_management', label: '生产管理', hidden: true, navigation: [] }, + // Published and listed: `hidden: false` meant exactly that under both + // regimes, so there is nothing to rewrite. + { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, + // Already carries the canonical gate. Left verbatim — the loader does + // not reconcile a disagreeing pair on the author's behalf (#4923), and + // this is what makes a second pass a no-op. + { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, + ], + }, + after: { + apps: [ + { name: 'production_management', label: '生产管理', _unpublished: true, navigation: [] }, + { name: 'crm', label: 'CRM', hidden: false, navigation: [] }, + { name: 'team_settings', hidden: true, _unpublished: false, navigation: [] }, + ], + }, + expectedNotices: 1, + }, +}; + export const CONVERSIONS_BY_MAJOR: Readonly> = { 11: [flowNodeHttpRename, pageKindJsxToHtml, flowNodeFilterAlias, objectCompactLayoutRename], 13: [stackRolesToPositions, owdLegacyReadAliases, sharingRecipientRoleToPosition], @@ -5347,6 +5438,7 @@ export const CONVERSIONS_BY_MAJOR: Readonly strictObject( { surface: 'this app', @@ -1203,6 +1226,13 @@ export const AppSchema = lazySchema(() => strictObject( home: HOME_PAGE_ID_RETIRED, homepage: HOME_PAGE_ID_RETIRED, landingpage: HOME_PAGE_ID_RETIRED, + // #4829 — the publish gate is `_unpublished`, and it is MACHINE-managed. + // An explicit entry rather than the edit-distance suggester, which would + // answer this spelling with a bare "did you mean `_unpublished`?" and so + // teach the one thing the key exists to prevent: an author writing it. + unpublished: UNPUBLISHED_IS_MACHINE_MANAGED, + published: UNPUBLISHED_IS_MACHINE_MANAGED, + draft: UNPUBLISHED_IS_MACHINE_MANAGED, }, history: 'Until #4001 these were dropped silently — the app still parsed, so navigation or ' + @@ -1253,10 +1283,55 @@ export const AppSchema = lazySchema(() => strictObject( * Mirrors GitHub Settings / Google account chip / Salesforce * "Personal Settings" — visible to every user, but reached from the * avatar rather than the app launcher. + * + * ⛔ **NOT an access gate, and never was one on this surface.** Between + * ADR-0045 (2026-06-12) and its 2026-08 revision the REST metadata gate + * (`filterAppForUser`, `packages/rest/src/rest-server.ts`) read THIS key as + * "unpublished ⇒ externally unobservable", which is a second, contradictory + * contract on one boolean. The measured cost (#4829): the platform's own + * `account` app is authored `hidden: true` for exactly the reason this + * docblock gives, so every user without `studio.access`/`setup.access` had it + * erased from `GET /meta/app` — password, avatar, sessions and inbox all + * unreachable, while any admin saw a healthy system. Presentation and + * lifecycle are orthogonal; the publish gate now rides its own machine-managed + * key, `_unpublished` (declared directly below). Authoring `hidden: true` affects + * navigation and nothing else. */ hidden: z.boolean().optional() - .describe('Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead'), - + .describe('Hide from the App Switcher; the shell surfaces hidden apps via the avatar menu instead (navigation only — never an access gate)'), + + /** + * ADR-0045 §3 — the **publish gate**. `true` means the app is *unpublished*: + * externally unobservable, not merely unlisted. `filterAppForUser` drops it + * from every metadata response except a builder's (`studio.access` / + * `setup.access`, for direct-URL preview); ADR-0045's discovery, direct-API + * and outbound-side-effect gates hang off the same bit. + * + * **Machine-managed — do not author it.** It is written by the AI additive + * materialization path (which lands a real, invisible app) and cleared by + * `POST /packages/:id/publish-drafts` (the visibility flip that IS "Publish"). + * The `_` prefix is this repo's marker for the channel tooling stamps onto + * artifacts rather than an author writing it — the same channel as ADR-0010's + * `_lock` / `_provenance` / `_packageId` envelope, and the prefix + * `lintAuthoredRecordKeys` already exempts from the unknown-authoring-key + * report for that reason. + * + * Why a dedicated key and not `hidden` (#4829, maintainer ruling 2026-08-04): + * `hidden: true` is a spelling an author — very often an AI (ADR-0033) — + * reaches for naturally on a personal-settings app, and under the old regime + * that spelling silently 404'd the app for every non-builder. Nobody reaches + * for `_unpublished` by accident, so the failure mode this key can produce is + * bounded to the machine that owns it. + * + * It is declared here (rather than omitted) because the write path validates + * against this very schema — `saveMetaItem` answers 422 on an off-spec body, + * and `Registry.validate('app', …)` runs `AppSchema.parse` — so the flip and + * the ADR-0087 conversion of stored rows both need the key to be legal. + */ + _unpublished: z.boolean().optional() + .describe('Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored.'), + + /** * Full Navigation Tree — supports unlimited nesting depth. * Pages are referenced by name via `type: 'page'` items. diff --git a/scripts/adr-anchors.json b/scripts/adr-anchors.json index 26cf535bb3..93bc02366a 100644 --- a/scripts/adr-anchors.json +++ b/scripts/adr-anchors.json @@ -294,6 +294,20 @@ "ADR-0106" ], "invariant": "ADR-0106 D7 — getMetadataReadableFields differs from getReadableFields in exactly one place, and the asymmetry is the decision. On the DATA plane a caller resolving to zero permission sets falls OPEN, mirroring the engine middleware, because reporting a narrowing the data path would not enforce is its own drift. On the METADATA plane the same caller resolves the configured fallback permission set (the two-step /auth/me/permissions performs), so a guest-facing deployment's schema exposure is a deliberate permission-set decision rather than an accidental everything-default. Converging the two methods in either direction reverses this." + }, + { + "file": "packages/rest/src/rest-server.ts", + "adrs": [ + "ADR-0045" + ], + "invariant": "ADR-0045 §3 (amended 2026-08-09, #4829) — `filterAppForUser` IS the publish gate: an unpublished app is externally unobservable, not merely unlisted, and only a builder (studio.access / setup.access) receives it, for direct-URL preview. The gate judges `_unpublished`, the machine-managed key, and MUST NOT judge `hidden`. `hidden` is navigation presentation — 'keep it out of the App Switcher, surface it from the avatar menu' — and a hidden app stays fully routable and permission-checked for every user. Reading `hidden` here is what erased the built-in `account` app from GET /meta/app for every non-builder (#4829): password, avatar, sessions and inbox unreachable, while any admin saw a healthy system. Neither key may absorb the other, and the gate may not be deleted either — removing it fails OPEN, exposing a half-built app to real users, which is the worse direction (the reason the first #4829 attempt was refused)." + }, + { + "file": "packages/runtime/src/domains/packages.ts", + "adrs": [ + "ADR-0045" + ], + "invariant": "ADR-0045 §3 (amended 2026-08-09, #4829) — POST /packages/:id/publish-drafts is the visibility flip: ONE publish verb serves both regimes, so after promoting drafts it clears `_unpublished` on every app bound to the package. It writes `_unpublished: false` rather than deleting the key, because ADR-0045 §3 makes publish/unpublish symmetric. It MUST NOT read or write `hidden`: that key is the author's navigation choice, and a flip keyed on it silently rewrote presentation as a side effect of publishing (#4829). The flip is a metadata WRITE riding on someone else's success, so its failure is reported at `error` level with consequence and fix, and the apps already flipped are reported (`unhiddenApps`) and announced even when the loop dies mid-way (#4754, #5242) — a partial flip that reports nothing leaves boot-cached consumers stale with no signal." } ] } From 95692f6ab7c7fcba8550c42a5d72dc6b04792bed Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 9 Aug 2026 05:34:01 +0000 Subject: [PATCH 2/2] test(spec): pin the `_unpublished` acceptance face in both directions (#4829) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `_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 Claude-Session: https://claude.ai/code/session_01CEKYRu2XjMPRA2AR4ueKUu --- packages/spec/src/ui/app.test.ts | 30 ++++++++++++++++++++++++++++++ packages/spec/src/ui/app.zod.ts | 3 +-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/app.test.ts b/packages/spec/src/ui/app.test.ts index eaab54e698..381dee32fc 100644 --- a/packages/spec/src/ui/app.test.ts +++ b/packages/spec/src/ui/app.test.ts @@ -1147,6 +1147,36 @@ describe('unknown keys are rejected, not stripped (#4001 PR B)', () => { expect(result.success).toBe(false); expect(result.error!.issues.map((i) => i.message).join('\n')).toContain('FormView.sharing'); }); + + // #4829 — the ADR-0045 publish gate's acceptance face, both directions. + // + // `_unpublished` is machine-managed but DECLARED, because the write path + // validates against this very schema (`saveMetaItem` → 422; + // `Registry.validate('app', …)` → `AppSchema.parse`): 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 prescriptions below — + // so both halves are pinned, or "machine-managed" is only a comment. + it('accepts the machine-managed `_unpublished` gate — the flip has to be writable', () => { + expect(AppSchema.safeParse({ name: 'app_a', label: 'A', _unpublished: true }).success).toBe(true); + expect(AppSchema.safeParse({ name: 'app_a', label: 'A', _unpublished: false }).success).toBe(true); + }); + + it('answers the author-shaped publish spellings with "not authorable", never a rename', () => { + // A bare edit-distance suggestion here would read "did you mean + // `_unpublished`?" — teaching the one thing the key exists to prevent. + for (const key of ['unpublished', 'published', 'draft']) { + const message = unknownKeyIssue(AppSchema, { name: 'app_a', label: 'A', [key]: true })!.message; + expect(message).toContain('Publish state is not authorable'); + expect(message).toContain('publish-drafts'); + expect(message).not.toMatch(new RegExp(`\`${key}\`\\s*→`)); + } + }); + + it('still accepts `hidden` — it keeps its (navigation-only) authoring contract', () => { + // The Account app's shape. Retiring `hidden` was NOT the fix: the key was + // never wrong, the second contract layered onto it was. + expect(AppSchema.safeParse({ name: 'account', label: 'Account', hidden: true }).success).toBe(true); + }); }); describe('navigation items (discriminated union)', () => { diff --git a/packages/spec/src/ui/app.zod.ts b/packages/spec/src/ui/app.zod.ts index 5e523d4022..efcf831770 100644 --- a/packages/spec/src/ui/app.zod.ts +++ b/packages/spec/src/ui/app.zod.ts @@ -1331,8 +1331,7 @@ export const AppSchema = lazySchema(() => strictObject( _unpublished: z.boolean().optional() .describe('Machine-managed publish gate (ADR-0045 §3) — true = unpublished, externally unobservable. Written by AI materialization, cleared by publish-drafts. Never authored.'), - - /** + /** * Full Navigation Tree — supports unlimited nesting depth. * Pages are referenced by name via `type: 'page'` items. * Groups can contain other groups for arbitrary sidebar depth.