Repository navigation
Commit 87d0619
feat(spec): the app item read answers by package id — 403 / 404 by the package key, per ADR-0048 §3.1 (#23468)
Fixes #23183
Clause-②: yes
## What this does
ADR-0048 §3.1: "The route/container coordinate for an installed
package's UI is its **package id** … With the current
**one-app-per-package** invariant, the app *is* the package container."
The console therefore opens an app at `/apps/PACKAGE_ID`. When that app
is not in the caller's list, it must tell a withheld app (403) from a
missing one (404). By name, the item read already answers that. By
package id, no declared read did: `GET
/api/v1/meta/app/com.acme.payroll` asked for an app NAMED
`com.acme.payroll` and answered 404 whatever the package held.
The existing app item read now accepts a package id as its key, for
`type: app` only, and answers the verdict the by-name read gives:
| caller / key | answer |
| :--- | :--- |
| a member on a withheld app's package id | `403 PERMISSION_DENIED`, the
by-name read's verdict (the message echoes the key the caller sent,
never the app name) |
| a holder | `200`, the app under its own `name`, the same item the
by-name read serves |
| an unknown package, a package with no app, a package whose app is
unpublished (ADR-0045 §3), a package with more than one app | `404`,
byte-identical to the answer for an unknown app name |
| a `packageId` / `?package=` naming a different package than the key |
`400 INVALID_REQUEST` |
| a key in the package-id grammar on any other type | read by name,
unchanged |
No separate verdict endpoint, no other type gains the arm, and the list
read's withheld-drop is unchanged.
## Where it lands, and why it is not `rest-server.ts`
The card expected `GetMetaItemRequestSchema` plus `packages/rest`'s app
item read. Measurement moved the runtime half to the producer, which the
card's clause allows ("若实测表明真正的生产者在别包,直接按生产者侧修"):
- **Spec** (`packages/spec/src/api/protocol.zod.ts`):
`GetMetaItemRequestSchema.name` declares the arm. The new export
`isAppPackageIdKey(type, name)` (`type === 'app'` and
`MANIFEST_ID_PATTERN`) is its one spelling. `GetMetaItemRequestSchema`
is "still the protocol METHOD contract" (`plugin-rest-api.zod.ts`, the
`GET /:type/:name` route entry), the input of `getMetaItem(request)`
(`protocol.zod.ts`, the protocol interface).
- **Producer** (`packages/metadata-protocol/src/protocol.ts`,
`resolveAppPackageIdKey`, called once at the top of `getMetaItem`): the
key resolves to the package's one app, read by its own name scoped to
that package. Two transports serve `GET /meta/:type/:name`:
`RestServer`, and the runtime dispatcher behind `@objectstack/hono`'s
catch-all. Both hand the protocol the key they were asked for and judge
the envelope through the same `createMetaItemAnswer` chain
(`meta-item-read-gate.ts`). The arm therefore reaches both with no
transport code, and the per-caller gate judges the resolved app exactly
as by name. A REST-only resolution would have left the catch-all host
without the arm, and made the spec declaration false on the protocol
face it declares.
- **REST** (`packages/rest/src/rest-server.ts`,
`fetchCurrentMetaDocument`): `/history`, `/audit` and `/diff` judge "the
item the plain read would judge" through `getMetaItem`, but read their
own data under the literal `:name`. Without a guard they would judge the
resolved app while serving the key's (empty) events. That gives a
non-holder a 403 where history used to answer 200, and a holder a 200
empty diff where `/diff` used to answer the 404 absence. The guard asks
`isAppPackageIdKey` and keeps those doors by name, as before.
`meta-item-read-gate.ts` is untouched.
"The package's app" is what the package-scoped list serves
(`getMetaItems({ type: 'app', packageId })`, the read behind `GET
/meta/app?package=`). That covers the apps the package ships and its
stored rows, and nothing for a disabled package. Drafts are in view
exactly when the read reads drafts (`state: 'draft'` or
`previewDrafts`).
### Readings the PM asked for
- **Zero apps:** 404 (the absence answer).
- **Two or more apps:** 404, plus one `console.warn` per package and app
set naming the package and its apps with the remedy. One-app-per-package
is enforced only by `defineStack` for `manifest.type === 'app'`
(`validateSingleApp`, ADR-0019 D3). The shipped Setup/Studio/Account
packages are `type: 'plugin'`, and the `/meta` save door does not
enforce it either. A distinct "two apps" answer was rejected: it would
let a caller see an app being built in the package (unpublished,
ADR-0045 §3) before it is published.
## Premise verification (the ruling's falsifiable premise)
1. **Disjoint grammars: holds.** `AppSchema.name` is
`SnakeCaseIdentifierSchema` (`/^[a-z][a-z0-9_]*$/`, `ui/app.zod.ts`,
`shared/identifiers.zod.ts`): no dot. `MANIFEST_ID_PATTERN`
(`kernel/manifest.zod.ts`) requires at least one dot. Every mint path
holds it: the `/meta` save door refuses a body whose `name` differs from
the URL name (`savedItemNameRefusal`, 400) and then parses `AppSchema`
(422), and `defineStack` parses `AppSchema`. A dotted app name is
therefore never legal.
2. **No wider disclosure: holds.** Withheld gives 403
`PERMISSION_DENIED`, the by-name verdict, and its message echoes the
caller's own key, so the package-to-app-name mapping is not disclosed.
Unpublished, service-gated, unknown, no-app and multi-app all give the
same 404 bytes; the wire test pins unpublished against unknown on both
hosts. The by-name control `/meta/app/payroll` answers the same 403 to
the same member.
## For the objectui consumer (objectstack-ai/objectui#12285)
Probe the declared read `GET /api/v1/meta/app/PACKAGE_ID`, i.e.
`client.meta.getItem('app', packageId)`, declared by
`GetMetaItemRequestSchema.name` (recognise the key with
`isAppPackageIdKey`). The existing `probeMetaItemAccess` mapping reads
it unchanged: `403 PERMISSION_DENIED` maps to `denied`, the 404 with the
absence code maps to `not_found`, and a `200` carrying `item` maps to
`granted`. The `200` envelope's `name` is the app's own name.
## Tests
- `packages/metadata-protocol/src/protocol.app-package-id-key.test.ts`
(new, producer): covers one app (singular and plural type spelling), the
by-name read, no app / unknown package, more than one app (absence plus
one report), a `packageId` naming another package (`code` + `status`),
the grammar on another type (discriminating: a package of that very id
ships an app), and drafts.
- `packages/runtime/src/domains/meta-app-package-id-key.test.ts` (new,
wire): `RestServer` and the hono catch-all, each over its own real store
(better-sqlite3 `:memory:`, real `sys_metadata*`, real protocol). The
triage pins run on both hosts: member 403 equals the by-name 403; holder
200 equals the by-name item; unknown package id 404 equals unknown app
name bytes; unpublished package equals unknown package bytes; dotted key
on `view` read by name. RestServer-only rows: `/history` and `/audit`
stay by name for a builder who does not hold the app, and a holder's
`/diff` stays the absence answer.
## Local readings
Every build and test went through the shared verify lock, on a shared
box.
**At `d8f08c015e`, the final head, after merging `origin/main`.** Main's
incoming commits touch none of the read paths below; §10's scoped
re-check:
- `pnpm --filter @objectstack/metadata-protocol exec vitest run
src/protocol.app-package-id-key.test.ts`: 8/8.
- `pnpm --filter @objectstack/runtime exec vitest run --project local`
over six files: 6 files, 182/182. The files are
`meta-app-package-id-key`, `meta-item-read-gate-parity`,
`meta-package-all-fold`, `meta-item-query-decision`,
`meta-published-runtime-publish` and `meta-save-actor`.
- `pnpm --filter @objectstack/spec exec vitest run --project local
src/api src/type-alias-convention.pin.test.ts`: 50 files, 1583/1583.
- `pnpm --filter @objectstack/spec check:generated`: all 14 generated
artifacts up to date. The api-surface delta is 0 breaking, 1 added
(`isAppPackageIdKey`).
**At `7b8b2dfc8f`, before the merge:**
- `pnpm --filter @objectstack/metadata-protocol exec vitest run`: 236
files passed, 3 skipped (28393 tests).
- `pnpm --filter @objectstack/rest exec vitest run --project local`:
281/281 files.
- The full run passed 280. `export-integration.test.ts` failed at import
with a missing module (`@objectstack/platform-objects/apps`), because
another process of mine was rebuilding `platform-objects/dist` at that
moment.
- Re-run alone, it passed 26/26.
- `typecheck`:
- spec, metadata-protocol and rest are green, and runtime's `tsc
--noEmit` is green.
- runtime's test layer (`check:test-typecheck`) reports 19 findings, all
in test files this diff does not touch. Among them is TS7016 on
`@objectstack/service-messaging` declarations, which is this worktree's
build state; none is in this diff's test. NOT MEASURED here: CI's
`TypeScript Type Check` reads it on a fresh build.
- **Declared narrowing:** the full `@objectstack/spec` and
`@objectstack/runtime` suites are left to CI (`Test Core`). Locally I
ran the targeted sets above.
**Ablations.** One per behaviour this diff pins, each through
`scripts/ablation-replace.mjs`. In both, the mutation landed (anchor 1
to 0, blob changed) and the restore is proven (blob equals HEAD, `git
diff HEAD` empty). Both subjects resolve from `src`, with no `dist/` in
the path.
- **A, the producer's arm short-circuited:**
`protocol.app-package-id-key.test.ts` went 5 failed / 3 passed.
- Red: the one-app, plural-spelling, multi-app report,
packageId-conflict and drafts rows.
- Green: the by-name, absence and other-type rows, as predicted.
- **B, the REST guard deleted:** `meta-app-package-id-key.test.ts` went
2 failed / 10 passed. `/history` answered the builder `403
PERMISSION_DENIED`, and `/diff` answered the holder a `200` empty diff:
the two predicted reds. Every plain-read row on both hosts stayed green.
**Gates.** `node scripts/pm/dispatch-gates.mjs --commands` derived 122
families from this diff at `d8f08c015e`, and all of them ran locally
after the final commit. 118 exited 0. Four are NOT MEASURED:
- `check:skill-examples` and `check:dual-build-cjs-loads`: exit 3,
PREREQUISITE NOT MET (packages not built in this worktree).
- `check:dts-closure`: exit 1, naming packages this worktree never
built. It reads the tree after a closure build, which only CI performs.
- `check:type-check-debt`: my 560s timeout fired before it reached a
verdict.
The `--ran` reconciliation reads: 122 derived, 118 run, 4 NOT-MEASURED,
0 UNRUN. The local-only `check:dev-prereqs` finds `packages/spec/dist`
fresh. It names one unrelated package dist without a build stamp
(`@objectstack/organizations`). Every family CI runs is read from this
PR's check-runs, not re-run here.
## Acceptance notes
- **Residue boundary.** The read doors stay open to pre-grammar residue
rows by design (`METADATA_ITEM_NAME_PATTERN`'s note). A stored `app` row
whose name is in the package-id grammar could only be residue, and the
item read would now read that key as a package id. The row stays
listable and deletable. Absent a named producer, not filed.
- **Disabled package.** The package-scoped list hides a disabled
package's app, so the package-id key answers 404 for it. The by-name
read does not consult the disabled flag. This is noted, not changed
here.
- **Layered view.** `?layers=true` and `/layers` read through
`getMetaItemLayered`, not `getMetaItem`, so they do not gain the arm,
consistent with the ruling's "the existing app item read".
- **Two `rest-server.ts` comments routed to this dispatch** (seat 4's
clock-out ledger). Both are outside this diff's touched region, so
neither is changed here:
- the `NormalizedRestServerConfig.api.documentation` comment names
`documentation.enabled` and `responseFormat` as the `retiredKey()`
tombstones but not `documentation.version`, which `rest-server.zod.ts`
also tombstones, so it reads partially stale;
- the `parseDeclaredApiConfig` paragraph was not re-measured beyond its
location.
- **Declared file-surface deviation.** The claim's surface named
`GetMetaItemRequestSchema` only in `protocol.zod.ts`, and
`rest-server.ts` / `meta-item-read-gate.ts` for runtime. This diff also
adds:
- an import line and the `isAppPackageIdKey` export next to the schema,
disjoint from #23428's describes;
- the producer in `@objectstack/metadata-protocol`, under the card's
clause;
- a test in `@objectstack/runtime` that holds both hosts equal.
In `rest-server.ts` it touches `fetchCurrentMetaDocument`, not the plain
read handler.
---
_Generated by [Claude
Code](https://claude.ai/code/session_0121ooBYRmS8ziFXm9ai5YHm)_
---------
Co-authored-by: Claude <noreply@anthropic.com>1 parent d54e239 commit 87d0619
10 files changed
Lines changed: 699 additions & 2 deletions
File tree
- .changeset
- content/docs/references/api
- packages
- metadata-protocol/src
- rest/src
- runtime/src/domains
- spec
- api-surface
- export-origins
- src/api
- scripts
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
1424 | 1424 | | |
1425 | 1425 | | |
1426 | 1426 | | |
1427 | | - | |
| 1427 | + | |
1428 | 1428 | | |
1429 | 1429 | | |
1430 | 1430 | | |
| |||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
| 1 | + | |
| 2 | + | |
| 3 | + | |
| 4 | + | |
| 5 | + | |
| 6 | + | |
| 7 | + | |
| 8 | + | |
| 9 | + | |
| 10 | + | |
| 11 | + | |
| 12 | + | |
| 13 | + | |
| 14 | + | |
| 15 | + | |
| 16 | + | |
| 17 | + | |
| 18 | + | |
| 19 | + | |
| 20 | + | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
| 24 | + | |
| 25 | + | |
| 26 | + | |
| 27 | + | |
| 28 | + | |
| 29 | + | |
| 30 | + | |
| 31 | + | |
| 32 | + | |
| 33 | + | |
| 34 | + | |
| 35 | + | |
| 36 | + | |
| 37 | + | |
| 38 | + | |
| 39 | + | |
| 40 | + | |
| 41 | + | |
| 42 | + | |
| 43 | + | |
| 44 | + | |
| 45 | + | |
| 46 | + | |
| 47 | + | |
| 48 | + | |
| 49 | + | |
| 50 | + | |
| 51 | + | |
| 52 | + | |
| 53 | + | |
| 54 | + | |
| 55 | + | |
| 56 | + | |
| 57 | + | |
| 58 | + | |
| 59 | + | |
| 60 | + | |
| 61 | + | |
| 62 | + | |
| 63 | + | |
| 64 | + | |
| 65 | + | |
| 66 | + | |
| 67 | + | |
| 68 | + | |
| 69 | + | |
| 70 | + | |
| 71 | + | |
| 72 | + | |
| 73 | + | |
| 74 | + | |
| 75 | + | |
| 76 | + | |
| 77 | + | |
| 78 | + | |
| 79 | + | |
| 80 | + | |
| 81 | + | |
| 82 | + | |
| 83 | + | |
| 84 | + | |
| 85 | + | |
| 86 | + | |
| 87 | + | |
| 88 | + | |
| 89 | + | |
| 90 | + | |
| 91 | + | |
| 92 | + | |
| 93 | + | |
| 94 | + | |
| 95 | + | |
| 96 | + | |
| 97 | + | |
| 98 | + | |
| 99 | + | |
| 100 | + | |
| 101 | + | |
| 102 | + | |
| 103 | + | |
| 104 | + | |
| 105 | + | |
| 106 | + | |
| 107 | + | |
| 108 | + | |
| 109 | + | |
| 110 | + | |
| 111 | + | |
| 112 | + | |
| 113 | + | |
| 114 | + | |
| 115 | + | |
| 116 | + | |
| 117 | + | |
| 118 | + | |
| 119 | + | |
| 120 | + | |
| 121 | + | |
| 122 | + | |
| 123 | + | |
| 124 | + | |
| 125 | + | |
| 126 | + | |
| 127 | + | |
| 128 | + | |
| 129 | + | |
| 130 | + | |
| 131 | + | |
| 132 | + | |
| 133 | + | |
| 134 | + | |
| 135 | + | |
| 136 | + | |
| 137 | + | |
| 138 | + | |
| 139 | + | |
| 140 | + | |
| 141 | + | |
| 142 | + | |
| 143 | + | |
| 144 | + | |
| 145 | + | |
| 146 | + | |
| 147 | + | |
| 148 | + | |
| 149 | + | |
| 150 | + | |
| 151 | + | |
| 152 | + | |
| 153 | + | |
| 154 | + | |
| 155 | + | |
| 156 | + | |
| 157 | + | |
| 158 | + | |
| 159 | + | |
| 160 | + | |
| 161 | + | |
| 162 | + | |
| 163 | + | |
| 164 | + | |
| 165 | + | |
| 166 | + | |
| 167 | + | |
| 168 | + | |
| 169 | + | |
| 170 | + | |
| 171 | + | |
| 172 | + | |
| 173 | + | |
| 174 | + | |
| 175 | + | |
| 176 | + | |
| 177 | + | |
| 178 | + | |
| 179 | + | |
| 180 | + | |
| 181 | + | |
| 182 | + | |
| 183 | + | |
| 184 | + | |
| 185 | + | |
| 186 | + | |
| 187 | + | |
| 188 | + | |
| 189 | + | |
| 190 | + | |
| 191 | + | |
| 192 | + | |
| 193 | + | |
| 194 | + | |
| 195 | + | |
| 196 | + | |
| 197 | + | |
| 198 | + | |
| 199 | + | |
| 200 | + | |
| 201 | + | |
| 202 | + | |
| 203 | + | |
| 204 | + | |
| 205 | + | |
| 206 | + | |
| 207 | + | |
| 208 | + | |
| 209 | + | |
| 210 | + | |
| 211 | + | |
| 212 | + | |
| 213 | + | |
| 214 | + | |
| 215 | + | |
| 216 | + | |
| 217 | + | |
0 commit comments