Repository navigation
fix(rest): the stored-version doors serve an app whole to whoever may save it, and pruned to everyone else - #20284
Conversation
… and pruned to everyone else Ruling 5856774816 (letter B, confirmed 5856866273), ADR-0106 D4's shape: on /layers, ?layers=true and /diff a caller who may write the app reads the full stored version; every other caller who may open it reads exactly what the plain read gives them, pruned. - MetaReadGatePolicy.app: 'pending-decision' retires; 'author-exempt' honours MetaReadGateCaller.mayWriteItem, a caller property. - RestServer.metaSaveVerdict: the save door's admission spelled once; PUT /meta/:type/:name and the exemption both ask it. - The layered view and /diff now serve the documents the gate serves (they discarded them), so a non-author reads pruned layers and pruned diff values; /diff substitutes emitted values, never drops an entry. Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…-version doors Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…lease notes Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…p-author-exemption
…veVerdict, which the stored-version read doors ask too Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
…ger resolves Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP Co-authored-by: Claude <noreply@anthropic.com>
📓 Docs Drift CheckThis PR changes 1 package(s): 20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: ⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails. What this run could not see
Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin aacf0c7ba0db3d16428246bc1ed82f8241b0b98d && git checkout aacf0c7ba0db3d16428246bc1ed82f8241b0b98d
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 89f87f23440d61f8fcd10b2c2991c663a917181e 9d955a369a86c13a7047d2852fba3a9bda85f728 && git checkout -B drift-repro 89f87f23440d61f8fcd10b2c2991c663a917181e && git merge --no-ff 9d955a369a86c13a7047d2852fba3a9bda85f728
node scripts/docs-audit/affected-docs.mjs --json 89f87f23440d61f8fcd10b2c2991c663a917181e
|
Contract reviewServed-tier: ① Derived judgments
② Semver level
③ Boundary flags
Implemented-by: Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions) VERDICT: PASS |
|
…ot (objectstack-ai#20215) (objectstack-ai#20329) Fixes objectstack-ai#20215 Clause-②: no `os init` (the `app` and `plugin` templates) now wires every barrel `os generate` writes into, and declares the capabilities the flow scaffold runs on. After writing, `os g` loads the config again and says whether the new item reached the stack. When the write makes a config that used to load stop loading, `os g` refuses and takes the write back out. `os g` never edits a config. ## Premise, re-measured on `origin/main` `6a6a17b6` before any edit I built the CLI's dependency closure at `6a6a17b6`, ran `os init my-app -t app --no-install`, generated each of the seven types as `order_line`, and then ran `os validate`: | step | exit | what it printed | |---|---|---| | each `os g` | 0 | `Tip: Run objectstack validate to check your config` | | `os validate` | **0** | `Data: 2 Objects 5 Fields` · `UI: 0 Apps` · `Logic: 0 Flows` | Row 2 reproduces. With all six barrels wired by hand, `os validate` exits 1 with "flow 'order_line_flow' declares a 'record_change' trigger but `requires` does not include 'triggers'". Adding `requires: ['triggers']` gives exit 0, `UI: 1 Apps 1 Views 1 Dashboards 1 Actions`, `Logic: 1 Flows`. Booting that hand-wired project with `os serve --dev` measured two more facts: 1. **The view scaffold is refused at boot.** The server said "Invalid `views:` container from manifest 'com.example.my-app': the container's own `name` is 'order_line', which disagrees with the object key it binds to, 'my_app_order_line' … drop `name`, or set it to 'my_app_order_line'". `os validate` had passed it. So wiring `src/views` alone would have turned "the view is silently absent" into "the server does not boot" on the road's next step. 2. **`triggers` is not enough for a flow to run.** With `requires: ['triggers']` the server booted and printed "Flows: 1 flow(s) declared but the automation engine is not enabled — they will never run. Add requires: ['automation', 'triggers']". Each trigger plugin logged "automation service not available — … NOT installed". With both tokens it printed `Flows: 1 flow(s) 1 bound to triggers (record_change, schedule, time_relative, api) · 1 draft`. ## Row 1: the route, measured The route is: `os init` imports every generator's barrel, and `os g` reports whether its file reached the stack without ever editing the config. The floor is exact for every config shape. After writing, `os g` loads the config through the same `loadConfig` that `os validate` uses, folds it the way the counter does (`authoringRuleUnionStack`), and looks for the item's metadata `name` under the stack key (`singularToPlural(type)`). It never parses the config's text, so a reordered config, variables, `.mjs` and `packages[]` are all read the same way. `os g object`, `os g view` and `os g flow` (`order_line`) were run in each shape. The config hash is sha1, taken before and after the three runs: | config shape | config hash before → after | what `os g view` / `os g flow` said | `os validate` | |---|---|---|---| | (a) fresh `os init -t app` | `fbea6b0e` → `fbea6b0e` | reaches the stack, both | exit 0, `1 Views`, `1 Flows` | | (b1) hand-edited: imports and keys reordered, an extra import | unchanged | reaches, both | exit 0, counted | | (b2) hand-edited: `defineStack` fed from variables (`const ui = {…}; const stack = {…, ...ui}`) | unchanged | reaches, both | exit 0, counted | | (b3) the pre-fix `os init` config (`./src/objects` only) | unchanged | **not wired**: prints the import and the key (and `requires` for the flow) | exit 0, `0 Apps`, `0 Flows` | | (b4) the same config as `objectstack.config.mjs` | unchanged | reaches, both | exit 0, counted | | (b5) the `create-objectstack` `blank` shape (`./src/objects/index.js`, `requires: ['automation']`) | unchanged | **not wired**; the flow advice prints the whole list `requires: ['automation', 'triggers'],` | exit 0, `0 Flows` | | (c) no config | n/a | **not wired**: no config here, prints the lines | n/a | | (d) fresh `os init -t plugin` | unchanged | reaches, both | exit 0, counted | These rows were measured on `cae468f49`. The wiring-advice text in (b5) is from `c21f96460`. **Why not "`os g` edits the config".** That route would be a config editor, a capability the CLI has nowhere today: `os init` only ever writes a fresh config, and no command rewrites one. Its safety would rest on a recognizer for the author's file. For example, shape (b2) has no `defineStack` object literal to insert a key into, so an editor must detect it and fall back to the message. The route above changes no config byte in any shape, and needs no editor. That is why this is not a `needs_decision`. The editor route was not built, so its column is analysis, NOT MEASURED. **Empty barrels.** `os init` writes an `index.ts` containing only `export {};` for each directory the template puts nothing in, and never overwrites an existing one (keyed by renderer, so the objects barrel keeps its old write). The empty barrels must not break the build or typecheck: - `os validate` exits 0 on a fresh project: `UI: 0 Apps`, `Logic: 0 Flows`. - `os compile` exits 0. - The emitted project's own `tsc --noEmit` exits 0, measured by the existing `scaffold-emission-typechecks.test.ts`, which went **red** on the first version of this change. It led to one design change, described in the next paragraph. **`exportsOf`, not `Object.values`.** `Object.values(emptyBarrel)` does not type-check against `defineStack` for the keys that also accept a name-keyed map. With no export to infer from, TypeScript takes the element type from the map branch, whose `name` is optional. Measured: TS2322 on `actions`, `flows`, `dashboards` and `apps` of a fresh project, while `views` and `skills`, which have no map form, passed. Three alternatives were measured and all still failed: a spread, `Array.from`, and `.flat()`. The template therefore declares one local helper, `exportsOf`, whose element type comes from the barrel alone: an empty list while the barrel exports nothing, and the exported type once it does. Both states type-check with 0 errors, and a populated barrel is checked exactly as strictly as before. **Prefixed names survive.** Object names still go through `objectNameFor`. The reach check looks for exactly the name the scaffold writes (`itemName`, held equal to the emitted `name` by a pin, with and without a namespace). ## Row 2: the template declares what the flow needs Every template that wires `src/flows` declares `requires: ['automation', 'triggers']`. The list is derived as the union of the generators' own `requires`, which today is the flow scaffold's pair. The flow scaffold's header also states the pair. I chose this over "`os g flow` adds `triggers` to `requires`" because adding to `requires` is the same config editor. It includes `automation` as well because of the boot measurement above: without it, the flow validates and never runs. The one cost is that a fresh project that never holds a flow still mounts the automation engine and the trigger plugins. The config comment says both tokens can go if the project will never hold a flow. Where the stack carries a flow but lacks a token, `os g flow` warns and prints the whole `requires` list to use. ## What `os g` says now | verdict | when | exit | what is left on disk | |---|---|---|---| | reaches the stack | the loaded stack carries the item | 0 | the scaffold and the barrel line | | cannot run | reached, and the stack's `requires` lacks a token the scaffold runs on | 0 | as above; the whole `requires` list is printed | | not wired | the config loads and does not carry it, or there is no config | 0 | as above, the config untouched; the import and the key are printed | | refused | the config loaded before the write and does not load after it | **1** | nothing: the scaffold, the barrel line and any directory this run created are removed, and the tree is byte-identical | | cannot tell | the config did not load before the write either | 0 | as before this change; the verdict says it cannot tell | "Refused" is what the wired barrels make reachable. Measured on `c21f96460` in a fresh project: - `os g action approve` without an `approve` object: exit 1 with `defineStack`'s own "Action 'approve' references object 'my_app_approve' which is not defined in objects", tree unchanged. - `os g app crm`: exit 1, tree unchanged. - `os g flow` into a wired config without `requires`: exit 1, tree unchanged. The "cannot tell" row keeps the `objectstack-ai#20197` control: in a config that does not load, `os g dashboard sales` still generates, exit 0. ## Two fixes in `generate.ts`, same class, in place Both are the card's defect class, a scaffold that never reaches the stack or is refused once it does. Both are mechanical, both sit in this claim's file, and both are covered by this card's gates. - **The view container's `name` is its object key**, prefix included. The server registers a views container under that key and refuses one whose `name` disagrees. The `objectstack-ai#20197` census pinned the view's own `name` as unprefixed because no `os validate` gate judged it; that assertion is updated, and the reason is written into the pin. - **Barrel membership is asked of the compiler** (`barrelExportsBinding`), not by `indexContent.includes(binding)`. Measured: after `os g view order_line`, `os g view order` found `order` inside `orderLine` and exported nothing. The new `export {};` barrels would have made that bite `os g dashboard port`. ## Docs `content/docs/deployment/cli.mdx`, `os generate` section: - The four verdicts, and that `os g` never edits the config. - A **Collected as** column in the types table. - A view's `name` is its object key. - The example block now binds every scaffold to the object it generates first. `os g action approve` / `os g app crm` would now be refused in an `os init` project. "Typical Workflow": step 3 is now `os g flow opportunity`. As written, `os g flow lead_qualification` now counted (`1 Flows`) but `os validate` warned the flow "targets object 'my_crm_lead_qualification', which this stack does not define … the flow will never fire". With `opportunity`, only the draft-status advisory remains. Step 4 ("Validate everything") is true as written: measured on `4173b2067`, exit 0, `4 Objects`, `1 Flows`. ## Changeset `.changeset/20215-generate-scaffolds-reach-stack.md` is a `patch` for `@objectstack/cli`. It is a bug fix in a released package, `Clause-②: no` as claimed, the same shape `objectstack-ai#20197` landed its `os g` refusals under. It states what `os init` and `os g` now write and say that they did not before. The pending namespace-prefix note this PR falsified is corrected in place instead (next section), so this changeset carries no supersession paragraph. ## A pending release note corrected in place (DELIBERATE CORRECTION) This PR rewrites two sentences of `.changeset/20197-generate-object-namespace-prefix.md`, another card's PENDING release note. This PR makes both sentences false, and both notes compile into the same release. Commit `a03756d5e` carries that correction alone. Commit `da4aca641` then drops the supersession paragraph this PR's own changeset carried, because the sentences it pointed at no longer exist. `node scripts/check-empty-changeset.mjs --base origin/main` is red on this PR by design. It names that one file, "present on the merge base and CHANGED by this PR", and this is its DELIBERATE CORRECTION class: "your change may have made this PENDING release note false, and you rewrote it in the same stroke. Remedy: do NOT restore it -- say so on the PR and get it confirmed; restoring it from the base would put the false sentence back." The confirmation is the same-head contract review (seat answer `5860440515`; claim `5859284846` amended to name this file). The precedent is PR objectstack-ai#20284. Line 11, **One namespace source.**, last sentence: - Before: "`dashboard` and `skill` scaffolds name no object and never read the config." - After: "`dashboard` and `skill` scaffolds name no object, so a config that does not load does not stop them, but `os g` loads the config after every write, theirs included, to report whether the scaffold reaches the stack." Line 12, **Unchanged:**, second sentence: - Before: "A view's, action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before." - After: "An action's, flow's, dashboard's, app's and skill's own `name`, and an action's flow `target`, are written as before; a view's own `name` now equals the object key it binds to, prefix included." Nothing else in that file moved: `git diff --word-diff` of `a03756d5e` shows these two sentences only (2 insertions, 2 deletions). Readings for this round on head `eedad4d37`, after merging `origin/main` `a78f731ad`: - `check-empty-changeset` exit 1, naming only the file above. - The 95 derived families all ran. `--ran` reports "95 derived, 95 run, 0 NOT-MEASURED, 0 UNRUN", and every exit is 0 except that one. - `pnpm lint` exit 0. - `node scripts/check-issue-citations.mjs --base origin/main` exit 0 (18 citations resolve). - CLI typecheck and the five per-PR scaffold pins: 81/81. - The nightly chains: 17/17. ## Pins - `packages/cli/test/generate-scaffold-wiring.test.ts` (unit, per-PR) covers: - the roster (every stack key is a key the stack schema declares; `itemName` is the emitted `name`); - the `app`/`plugin` templates (each barrel imported, wired, written; `requires` declared; the emitted project loads with every key a list); - barrel membership, the reach reader and the wiring lines; - `os init` keeping an author's barrel. - `packages/cli/test/generate-stack-reach.test.ts` (spawns the CLI, integration tier, per-PR, NOT `.e2e`) covers: - "refused", with the tree byte-identical, for an action with no object and a flow in a requires-less stack, plus a control where the same action generates once the object exists; - "not wired", for a pre-fix config (config byte-identical) and for no config; - "cannot run"; - `os g dashboard port` against the `export {};` barrel. - `packages/cli/test/generate-scaffolds-reach-stack.e2e.test.ts` (nightly) is triage's pin: `os init -t app`, then `os g` of every type, then `os validate` exits 0 with `2 Objects`, `1 Apps`, `1 Views`, `1 Dashboards`, `1 Actions`, `1 Flows`. `os compile`'s artifact carries every generated item, the skill included (`os validate`'s summary has no skills row). ## Verification Round 0 readings, on head `e33889d77` unless noted (patch round 1's readings on `eedad4d37` are in the DELIBERATE CORRECTION section): - `pnpm --filter @objectstack/cli typecheck` (tsc plus the test layer): exit 0. - `pnpm lint` (whole repo, not narrowed): exit 0. - CLI `unit` project: 230 files, 3296 tests, all pass on `01a556a52` (after merging `origin/main`). The only later commit touches one integration-tier test file. - CLI `integration` project, in two batches: 58 files, 485 pass, 1 skipped (not in a file this PR touches), on `01a556a52`. `generate-stack-reach.test.ts` passes 7/7 on `e33889d77`. - `OS_TEST_TIERS=nightly`, `generate-scaffolds-reach-stack.e2e.test.ts` plus the existing `generate-object-namespace-prefix.e2e.test.ts`: 17/17. - Derived gates (`node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands`): 94 families, each run with its exit code recorded. `--ran` reports "94 derived, 94 run, 0 NOT-MEASURED, 0 UNRUN", all exit 0. On `01a556a52`, three gates first refused with exit 3 (a prerequisite: packages outside the CLI closure had no `dist`). They were re-run to exit 0 after building. - `node scripts/check-issue-citations.mjs --base origin/main`: exit 0 (27 citations resolve). - Merged `origin/main` at `6ac33a57d` (which carries PR objectstack-ai#20244's `cli.mdx` edit, a disjoint range), with a clean merge. The five commits `origin/main` gained since touch no `packages/cli` or `cli.mdx` path. ## Ablations Each ablation was committed first, mutated through `scripts/ablation-replace.mjs` (the anchor must hit, and the landing is proven by blob hash), run, and restored. Every restore was proven: the blob equals HEAD's (`init.ts` `3770e16c`, `generate.ts` `3a92cfa4`) and `git diff HEAD` is empty. All four ran on `e33889d77`, and every direction was red. | mutation | per-PR guards | nightly chain | |---|---|---| | revert the wiring (the templates wire `objects` only) | 9 failed | 8 failed (every `os g` prints wiring lines; counts; artifact) | | revert row 2 (delete the template's `requires` line) | 4 failed | 3 failed (`os g flow` refused; counts; artifact) | | view `name` back to the unprefixed stem | 3 failed (incl. the `objectstack-ai#20197` census) | n/a | | barrel check back to `includes` | 1 failed (`os g dashboard port`) | n/a | ## Acceptance notes - **The `npm create objectstack` starter is not wired.** `packages/create-objectstack/src/templates/blank/objectstack.config.ts` imports `./src/objects/index.js` alone and declares `requires: ['automation']`. It is read-only for this card. On that road (the north-star road starts there), `os g view` now says "not wired" with the lines, and `os validate` still counts 0 until the starter wires its barrels. Reported, not edited. - **`packages/spec/prompts/create-new-project.md`** (read-only here) lists `flows/`, `dashboards/` and `reports/` in its project tree, but its config sample wires `objects`, `actions` and `apps` only. - **`cli.mdx` about line 741** (the "Your First App" fixture callout, outside this claim's ranges) says the walkthrough's `os generate` commands would make the summary gain "`my_app_customer` and a `Logic:` row". In the `create-objectstack` starter those scaffolds are not wired, and `os generate action approve` binds to no declared object. - **The pending `.changeset/20197-generate-object-namespace-prefix.md`** had two sentences this PR makes false. On the seat's answer (A), they are corrected in place, as the DELIBERATE CORRECTION section above describes; `check-empty-changeset` stays red for that class by design. - **`os validate`'s summary counts no skills** (`collectMetadataStats` has no skills member), so the chain pin holds the skill through the compiled artifact. - **Hand-wiring an empty barrel with `Object.values` hits TS2322** for the map-supported keys. The cause is `MetadataCollectionInput`'s map branch in `packages/spec`, read-only here. The template avoids it with `exportsOf`. - Two runtime/validate disagreements the boot measurement surfaced are handed to the seat in the dev report rather than fixed here: - `os validate` passes a views container whose `name` disagrees with its object key, which `os serve` refuses; - `defineStack`'s trigger-capability rule accepts `triggers` without `automation`, and the server then never runs the flow. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
… and pruned to everyone else (objectstack-ai#20337) Fixes objectstack-ai#20290 Clause-②: no This executes triage's grade on the card (comment 5859504238). Triage decided the carrier is the server, under maintainer ruling 5856774816 on objectstack-ai#20156 (letter B). Triage's words, verbatim: > **Carrier, decided here: the server.** Ruling B's own words decide it: 「Read-to-display is pruned per user; read-to-edit is whole for the editor」, and 「whoever can save it must see it whole, or a save drops entries silently」. A draft is a stored version, not a rendered one. So the plain read's `?state=draft` branch takes the same author exemption PR objectstack-ai#20284 gave the stored-version doors: whole for whoever may save the app, pruned for everyone else. ## What changed **The transport** (`packages/rest/src/rest-server.ts`) - The plain read `GET /meta/:type/:name` now chooses its gate policy by what it serves. Its `?state=draft` branch serves the pending draft row, which is a stored version, so it runs `RestServer.STORED_VERSION_DOOR_POLICY` (`{ arms: 'per-caller', app: 'author-exempt' }`). Every other read on that route keeps `{ arms: 'all', app: 'gate' }`, including the `?preview=draft` render. - There is no second predicate and no route test in the gate. `metaItemReadGate` already attaches `MetaReadGateCaller.mayWriteItem` whenever the policy is `author-exempt`, from `RestServer.metaSaveVerdict`: the admission of `PUT /meta/:type/:name`, spelled once by PR objectstack-ai#20284. The draft read inherits that. - **For an app:** - a caller the app's save door admits reads the stored draft whole; - every other caller who may open the app reads it pruned per caller, exactly as before; - an app the plain read refuses whole (an app-level `requiredPermissions` the caller lacks, or an unpublished app to a non-builder) is still refused, to an author too. - **Per-deployment gates:** the ADR-0057 D10 `requiresService` arms (app, nav entry, dashboard widget) and the nav servability gate no longer run on the draft read, for any caller. See Acceptance notes item 4 for the measurement and the reasons. - Unchanged: `NO_DRAFT` (404) when nothing is pending, the docs audience on `doc` and `book`, and the object mask. **The shared gate** (`packages/rest/src/meta-item-read-gate.ts`): docblock only. Three sentences listed the doors each policy member serves and named "the plain read" as a rendered door without an exception. They now name the draft branch. No code changed. **Tests** - The census `meta-alternate-door-read-gates.test.ts` gains the draft read as a door, `?state=draft`, of kind `stored`. Its cells sit beside `/layers`, `?layers=true` and `/diff` for every subject and caller (36 new cells). - `AUTHOR_EXEMPTION` names four doors now, with `draftCarrier: '5859504238'`. - The scope pins hold the exemption to exactly the author's partial `crm` cells: 4 changed cells and 8 whole refusals kept. - Each authenticated draft cell asserts that the protocol was asked for `state: 'draft'`. - New: `meta-draft-read-author-exemption.test.ts` runs the real stack: better-sqlite3 `:memory:`, the real `sys_metadata*` objects, a real `ObjectStackProtocolImplementation` and the real routes. The only stubs are the auth boundary and the `tenancy` service probe. - An author holding `manage_metadata` but not `finance.access` reads the draft whole: the withheld entries, a draft-only entry and one whose service is off. - A member reads it pruned per caller (the control). - **A draft save by that author keeps `nav_finance_ledger`.** The test runs the editors' round trip: `/layers` effective, merged with the stripped draft, saved back through `PUT ?mode=draft`. It then reads the persisted `sys_metadata` draft row. - It widens nothing: the author's draft answer equals what `/diff` already serves them for the same history version. - The plain read and `?preview=draft` still prune for the author. - A whole refusal (`payroll`) stays `403` on the draft read. **Docs and release notes** - `content/docs/ui/apps.mdx` names `?state=draft` among the doors that answer by who is asking, and states that those doors apply no per-deployment gate. Its gate-table row now says "the rendered `/meta` body". - The two pending release notes are corrected in place (see the section below). This PR's own note is `.changeset/20290-draft-read-author-exemption.md`, `@objectstack/rest` `patch`. ## Verification **Tests**, at head `bd1361ee6`. `git diff bd1361e 8055937 -- packages/` is empty: the only change since is the one `apps.mdx` table row. - Census plus the new real-stack file: 303/303. - `@objectstack/rest`, `vitest run --project local`: 203 files, 3704 passed, 1 skipped, 0 failed. - `test:repo`: 8/8. - `@objectstack/rest` `typecheck` (`tsc --noEmit` plus the test layer under `tsconfig.test.json`): exit 0. `tsc -p tsconfig.test.json --listFiles` includes both test files (204 test files in the program). - `@objectstack/runtime` is not touched and no rest export changed, so no runtime test is owed. The dispatcher does not serve `?state=draft`; see Acceptance notes item 5. **Ablations**, committed fix first, at head `bd1361ee6`. - Each mutation ran through `scripts/ablation-replace.mjs` in wrap mode, inside a script with its own EXIT, INT and TERM trap that restores `packages/rest/src/rest-server.ts` from `HEAD` by absolute path. - Each restore was proven twice: the blob equals `HEAD` (`d5ccd775bb3b`) and `git diff HEAD` is empty. - The subject is imported by relative path (`./rest-server.js`), so the mutations act on source and no `dist` is involved. - Each prediction was written down before its run. | mutation | landed (anchor, blob) | predicted | result | |:--|:--|:--|:--| | A: the exemption off on the draft read (`{ arms: 'per-caller', app: 'gate' }`) | 1 → 0, `d5ccd775bb3b` → `4b8f055b9a0e` | 4 red | **4 red**: the census `?state=draft app/crm × author` cell; real stack: author reads whole, the draft-save round trip, "widens nothing" | | B: the exemption for everyone (`mayWriteItem: true`) | 1 → 0, `d5ccd775bb3b` → `f45c0e2167e9` | 7 red | **7 red**: `app/crm × non-reader` on all four stored doors (`?state=draft` included), the fault-path `/layers` edge, the org-presentation edge, the real-stack member control | | C: per-deployment arms back on the draft read (`{ arms: 'all', app: 'author-exempt' }`) | 1 → 0, `d5ccd775bb3b` → `ad2e5f485f20` | 4 red | **4 red**: `?state=draft dashboard/ops` × reader, non-reader and author; the real-stack member control (`nav_org_directory`) | **Gates**, at head `8055937f5`. - `node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands` derived 91 families from merge base `10ea9eb2e`. Every command ran with its exit code captured before any pipe. - `--ran` reconciled the run: 91 derived, 91 run, 0 NOT-MEASURED, 0 UNRUN. - 90 exit 0. `node scripts/check-empty-changeset.mjs --base origin/main` exits 1 **by design** (next section). - `check:skill-examples`, `check:type-check-debt` and `check:dual-build-cjs-loads` first refused with exit 3, because the merge moved `packages/spec/src` and the client SDK was unbuilt. All three exit 0 once their named prerequisites were built: - `check:skill-examples`: 259 examples; - `check:type-check-debt`: 4 entries re-measured, none above its record; - `check:dual-build-cjs-loads`: 104 entry points across 66 packages. - Added by this lane: - `pnpm lint` (`eslint . --no-inline-config`, the whole repo): exit 0. It is a full run, not a narrowed one. - `pnpm --filter @objectstack/spec run check:liveness`: exit 0. - The board check `node scripts/check-issue-citations.mjs`, after merging `origin/main` at `10ea9eb2e`: exit 0 at the merge head, with 6 citations judged and all resolving. At the final head, `--base 10ea9eb`: exit 0, 5 of 5 resolve. `origin/main` then moved to `a78f731ad`, and `--base origin/main` against that unmerged tip exits 2. It counts 99 citations that objectstack-ai#20326 removed on `main` as if this change had added them. They sit in 21 files, none of them in this diff: a moving-ref reading, not a finding. ## A pending release note is corrected in place, so `Check Changeset` stays red `check-empty-changeset` names both notes: "present on the merge base and CHANGED by this PR". This is the **DELIBERATE CORRECTION** class. Its remedy text reads 「do NOT restore it -- say so on the PR and get it confirmed」 and 「this gate stays red either way」. Both notes are pending, not yet consumed by a Version Packages PR. Each said the plain read prunes for every caller, authors included, which this PR makes false for `?state=draft`: - `.changeset/20156-alternate-door-read-gates.md`, in the `/layers` / `?layers=true` / `/diff` bullet. - Before: "The plain read and `/published` prune for every caller, authors included." - After: "The plain read and `/published` prune for every caller, authors included, except the plain read's `?state=draft`: it serves the pending draft, a stored version, and answers as these three doors do." - `.changeset/20156-app-author-exemption.md`, in the "Unchanged" bullet. - Before: "Unchanged: the plain read and `/published` still prune for every caller, authors included." - After: "Unchanged: the plain read (its `?preview=draft` included) and `/published` still prune for every caller, authors included. The plain read's `?state=draft` is the exception: it serves the pending draft, a stored version, and answers as these three doors do." This PR's own note is `.changeset/20290-draft-read-author-exemption.md`. `skip-changeset` is not applied, because this PR publishes. ## Acceptance notes All readings below were taken on the real stack described above. The pre-fix readings are a one-shot probe at `de091b50e`, since removed. Its caller "author" holds `manage_metadata` only; "member" holds nothing; "finance author" holds `manage_metadata` and `finance.access`. `tenancy` is off. The app is published with `nav_leads`, `nav_finance_ledger` (`finance.access`) and `nav_org_directory` (`requiresService: 'tenancy'`). The finance author saved a draft that adds `nav_finance_forecast` (`finance.access`). **Item 1: the site, and the red re-measured through REST.** - The site is the uncached arm of `GET /meta/:type/:name`: `stateParam` sets `state: 'draft'` on `getMetaItem`, then the gate call ran `{ arms: 'all', app: 'gate' }`. - Before the fix, the author's `?state=draft` answered 200 with the draft's label and navigation `[nav_leads]`. `/layers` answered `[nav_leads, nav_finance_ledger, nav_org_directory]` on all three layers. **Item 2: what the draft read is.** - At `.objectui-sha` `f8a9d0fb` (a read-only clone), `MetadataClient.getDraft` sends `GET /meta/:type/:name?state=draft`, adding `&package=` when scoped, and maps 404 to `null`. - Both editors use it for their baseline, never `?preview=draft`: - `StudioDesignSurface.tsx`: about :1759-:1767 for the app (`{ ...eff, ...appDraftBody }`), and about :1885-:1893 for a nav leaf, whose type can be `dashboard`, `page`, `object`, `report` or `action`; - `ResourceEditPage.tsx`: about :1015-:1051 on load, :1509-:1518 after a draft save and :1686-:1699 after a publish. - With no draft pending, `?state=draft` answers `404` `{ error: "No pending draft exists for app/NAME.", code: "NO_DRAFT" }` (measured). It does not fall back to the active version: the protocol stops before the registry for a draft read. **Item 3: the premise, which holds on the stop condition as written.** - `/layers` does **not** serve the draft. `getMetaItemLayered` looks its overlay up with `state: 'active'` only, and the draft-only `nav_finance_forecast` was absent from every layer for every caller. - `/diff` **does**: - a draft save goes through `SysMetadataRepository.put` with `state: 'draft'`, which appends a full-body `sys_metadata_history` row (measured: version 1 is the active app, version 2 the draft with 4 entries); - before this PR, `/diff?from=0&to=2` served the author `[nav_leads, nav_finance_ledger, nav_org_directory, nav_finance_forecast]`, the co-author's draft whole, under ruling B's exemption on `/diff`. - So the exemption on the draft read discloses to an author nothing a ruled stored-version door does not already serve them whole. The test "it widens nothing" pins that permanently. - For a non-author the permission axis is unchanged: `nav_finance_*` is withheld before and after. Their only change is item 4's, from `[nav_leads]` to `[nav_leads, nav_org_directory]`, which equals what `/layers` and `/diff` already served the member before this PR. **Item 4: the arms.** - Before the fix, the draft read dropped `nav_org_directory` for **every** caller, the finance author who holds every entry's permission included (`[nav_leads, nav_finance_ledger, nav_finance_forecast]`). That is the same data-loss class: a save of the merged baseline deletes the entry. - The census shows the dashboard twin: `dashboard/ops` has its widget `w_org_kpi` bound to `tenancy`. The design surface loads dashboards through the same door. - Hence `STORED_VERSION_DOOR_POLICY`. The per-deployment arms need not stay for non-authors: - they withhold nothing from the caller (the `MetaReadGatePolicy` docblock); - the draft read is not a render door, since `?preview=draft` is, and it keeps `arms: 'all'`; - non-authors already read the per-caller-only answer on `/layers` and `/diff`. - Ablation C pins the choice. **Item 5: the dispatcher.** `packages/runtime/src/domains/meta.ts`'s item read passes `packageId`, `organizationId` and `previewDrafts` to `getMetaItem`, and never reads `state`. So on a host that serves only the dispatcher, `?state=draft` answers the active item under the rendered policy: never the draft, and never `NO_DRAFT`. It does not follow this rule because it does not serve this door. Left alone, as ordered, and reported for objectstack-ai#20320's family. This is a source reading at `bd1361ee6`, not measured through `dispatch()`. **Item 6:** see the section above. `content/docs/ui/apps.mdx` is corrected the same way. **Triage note 3: another read-to-display baseline saved back in objectui**, for the objectui seat. This is a source reading at `f8a9d0fb`, not measured at runtime. - `useMetadata().apps` is the list read, pruned per caller. - Two console paths publish an app built from that list, without `mode: 'draft'`: - `app-shell/src/hooks/useNavigationSync.ts` `saveApp`, via `NavigationSyncEffect` when a page or dashboard is created or deleted: `client.meta.saveItem('app', appName, { ...app, navigation: updated })`; - `apps/console/src/pages/system/AppManagementPage.tsx` `handleToggleActive` and `handleSetDefault`: `meta.saveItem('app', app.name, { ...app, ... })`. - A saving author who is withheld an entry, or an entry whose service is off here, would publish the app without it. - A server change here cannot reach these paths: the list read is read-to-display by ruling B, so the client must read what it saves from a stored-version door. **Deviation from the claim's file surface.** The claim admits `meta-item-read-gate.ts` "only if the policy type needs a member for the draft read". This PR changes no member there. It edits three docblock sentences that this change made false, and no code. The change is declared here for confirmation. **Observed, not in scope.** The draft reads carry no builder gate: a member who may open an app reads its pending draft (pruned per caller) through `?state=draft` and `?preview=draft`. ADR-0037's risk table plans 「confirm/add a builder/admin role gate on the dispatcher reads」. This PR does not change who may read a draft: the non-author answer is unchanged on the permission axis. It is reported to the seat. ## Not addressed here - objectstack-ai#20139 remains open: the bare-number query reads in `rest-server.ts`. - objectstack-ai#20320 remains open: the dispatcher's divergences. Item 5 above is a candidate row for it. - The objectui paths above remain for the objectui seat. --- _Generated by [Claude Code](https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
Fixes #20156
Clause-②: no
Executes maintainer ruling 5856774816 on the card (batch 227 item 1, letter B), confirmed in 5856866273. The ruling, verbatim:
This is the second half of the card. The first half landed as a
Part ofPR (the landing record is comment 5855055724 on the card). It left the partialappcells of the stored-version doors as a declared pending-decision exemption. This PR replaces that exemption with the ruled answer.What changed
The shared read gate (
packages/rest/src/meta-item-read-gate.ts)MetaReadGatePolicy.appbecomes'gate' | 'author-exempt', and'pending-decision'is retired.'author-exempt'serves the stored app whole to a caller whoseMetaReadGateCaller.mayWriteItemistrue. Every other caller gets'gate''s answer: the plain read's refusal, or the pruned app.requiredPermissionsthe caller lacks, or an unpublished app to a caller with neither Studio nor Setup access) is refused on every door, to an author as to anyone.docentries, exactly as the plain read does.The REST transport (
packages/rest/src/rest-server.ts)RestServer.metaSaveVerdict(ctx, rawType)is the admission ofPUT /meta/:type/:name, spelled once. The PUT door now calls it, and the exemption asks it too. That follows ruling item 1: the predicate ismetaWriteCapabilityVerdict, the save door's own answer, not a "platform administrator" reading.ctx.tenantId, as the door threads it.manage_org_presentationtherefore admits a view save but not an app save, so it does not make its holder an app author. A census edge pins that.STORED_VERSION_DOOR_POLICYis{ arms: 'per-caller', app: 'author-exempt' }. Only doors whose policy honours the exemption putmayWriteItemon the caller. It is a pure verdict over the already-memoised exec ctx and the static type registry, so it costs no read. The memoised ctx is copied, never written./diffused to act on a refusal verdict and discard a serve verdict's document, so they could not answer "pruned" at all./diffemits each bucket's values from the judged sides. It adds or drops no entry, and the comparison stays the protocol's over the stored bodies. This follows the redaction precedent one frame down (diffMetaItem: diff raw, emit projected values, ruling 5299845282 option B) and the ADR-0106 mask beside it.{ arms: 'all', app: 'gate' }and serves no stored-version door.Unchanged, per the ruling: the plain read and
/publishedprune for every caller, authors included./history,/audit, every whole-refusal cell, and thebook,docanddashboardarms are unchanged.Verification (head
9d955a369unless stated)Tests
packages/rest/src/meta-alternate-door-read-gates.test.ts: 261/261.authorcaller (manage_metadata, withoutfinance.accessorcrm_admin), so the plain read serves them only part ofcrm.crmpin the full stored version. The non-author cells forcrmpin the plain read's pruned navigation on every layer and in/diff's emitted values, with the same bucket paths as the raw diff./publishedstill prunecrmfor the author.payroll(403) andlaunchpad(404) on every door.PENDING_DECISION,isPendingDecisionand their docblock.AUTHOR_EXEMPTIONcites the ruling. Three pins replace them:/layers,?layers=trueand/diff, which is every stored-version door and no other;crmcells, and the six author whole-refusal cells stay refused;savesAppsequals whatPUT /meta/app/crmanswers them. The author gets 200 and the protocol is called once;readerandnon-readerget 403FORBIDDEN; the anonymous caller gets 401. For every refused caller, the protocol is never called.@objectstack/rest,vitest run --project local: 3655 tests, 3654 passed, 1 skipped, 0 failed. This ran at6780cd777, and the onlypackages/delta since is one docblock line inrest-server.ts(git diff 6780cd777 9d955a369 -- packages/).@objectstack/resttypecheck(tsc, plus the test layer undertsconfig.test.json): exit 0.@objectstack/runtimetypecheck: exit 0.domains/meta-item-read-gate-parity.test.ts: 77/77. Both ran atbad8baa63; the delta since is the same docblock line.Reverse verification and ablations
Each mutation used
scripts/ablation-replace.mjsin wrap mode inside a script that traps EXIT, INT and TERM. Each restore was proven: the blob equals HEAD andgit diff HEADis empty.serve(gated.app)for everyone)817024027bb2→6feda3867a6c?layers=true,/layersand/diff×app/crm × authormayWriteItem: true)5942f7eb57de→43523e878b86app/crm × non-readerstored-door cells, the fault-path/layerspin, and the org-presentation pin'pending-decision'put back into the runtime's policypackages/runtime/src/domains/meta.tsc47473b9dcdd→543aa6707030tsc: 1 error,TS2322 … 'pending-decision' … not assignable to type 'MetaReadGatePolicy'atmeta.ts(297,7). So the importer reads the rebuilt rest declarations.No test file stays behind: the ablations and the one-shot probe below were run and then removed.
Gates
node scripts/pm/dispatch-gates.mjs --commandsderived 92 families from merge basea9fb83ef0. Every command ran at9d955a369with its exit code captured before any pipe.--ranreconciled the run: 92 derived, 91 run, 1 NOT-MEASURED, 0 unrun.node scripts/check-empty-changeset.mjs --base origin/mainexits 1 by design; see the next section.pnpm check:dual-build-cjs-loads: NOT MEASURED. It exits 3 (PREREQUISITE NOT MET) because 42 packages have nodist/, and this gate needs the whole repo built. That is CI's Build Core run.pnpm lint(eslint . --no-inline-config, the whole repo): exit 0 in 121s. It ran on the tree that became9d955a369, so this is a full run, not a narrowed one.node scripts/check-issue-citations.mjs --base origin/main, after the merge: exit 0, 17 citations judged, 17 resolve.pnpm --filter @objectstack/spec run check:liveness: exit 0.check:system-context-censuswas red after the refactor:metaSaveVerdictholds the save door'sisSystemread now. Row 49 ofcontent/docs/permissions/system-context.mdxnow anchors it and names the read exemption it feeds. The declared count was regenerated by the gate's own--fix(87 → 88 symbols).check:skill-examplesandcheck:type-check-debtfirst refused on missing builds (exit 3). Both exit 0 at9d955a369once their named prerequisites were built.A pending release note is corrected in place, so
check-empty-changesetstays red.changeset/20156-alternate-door-read-gates.mdexists on the merge base and was not added by this PR. The gate classes this edit as the deliberate correction case. Its own remedy text says not to restore the note, because restoring it would republish a sentence this PR makes false. Instead, this PR says so here, for confirmation:approw of/layers,?layers=trueand/difffor a caller the plain read serves only part of the app", also changed..changeset/20156-app-author-exemption.md,@objectstack/restpatch. TheCheck Changesetjob counts only ADDED changesets, so the corrected note alone would not satisfy it.skip-changesetis not applied: this PR publishes.Acceptance notes
Dispatch item 4: the exported types are unreleased.
MetaReadGatePolicyandMetaReadGateCallerbecame@objectstack/restexports under the pending.changeset/20193-dispatcher-meta-read-gate.md(minor). That file is still onorigin/main89f87f234, checked just before this PR opened. The published@objectstack/rest@17.4.0tarball was also checked. Itsdist/index.d.tshasRestServer21 times (the positive control) andMetaReadGatePolicy|MetaReadGateCaller|createMetaItemReadGate0 times. Itsdist/index.jshascreateMetaItemReadGate|pending-decision0 times. So changing these members moves no published surface, which is whatpatchandClause-②: norest on (ruling item 4).Dispatch item 6: the pending release note. It is corrected in place (see the section above) and has not been consumed by a Version Packages PR. This PR's own changeset sits beside it.
Dispatch item 7: which objectui surfaces read these doors. Measured before the fix, in objectui source at the pinned
.objectui-shaf8a9d0fb0596, from a read-only clone:/diff: 0 source readers.MetadataClient.diffis defined but never called.?layers=: 0 source readers. objectui's own ratchet test forbids that spelling./layersfor anapphas two save-back readers, and both save throughclient.save, which isPUT /meta/:type/:name:app-shell/src/views/studio-design/StudioDesignSurface.tsx: the app navigation editor loads the layered view at :1760 and saves navigation at :1960;app-shell/src/views/metadata-admin/ResourceEditPage.tsx: the generic editor, since no bespoke editor is registered forapp. It loads at :1017 and saves at :1500.DesignerEditorWrapper, the reader the ruling named, is not in objectui source at the pin; only CHANGELOG entries mention it./layersreaders never load an app:PermissionMatrixEditor(permission),PackageOwdOverviewPanel(object),EmbeddedItemEditor(object sub-items), andStudioDesignSurfaceat :2880 (object) and :3931 (flow).The claim's file surface, amended in two places. Both edits correct published text this change would otherwise make false or red:
content/docs/ui/apps.mdx, about lines 359-373: the paragraph said the stored-version doors "do not yet prune … pending a decision". It now states the ruled answer.content/docs/permissions/system-context.mdx, row 49, plus the regenerated count.Out of scope, reported for the seat (not fixed here).
Authors can still lose entries through the draft read. Studio's app editors build their edit baseline as
{ ...layered.effective, ...draft }, where the draft comes fromclient.getDraft, the plain read with?state=draft. The editors save that result back:StudioDesignSurface.tsx: the merge is at :1760-1767 and the save at :1960;ResourceEditPage.tsx: the merge is at :1016-1056 and the save at :1500.The plain read prunes for authors too, and the ruling keeps it that way. So when an app has a pending draft, the draft's pruned
navigationoverrides the wholeeffectivefor an author who lacks an entry's permission, and the next draft save drops that entry.A one-shot probe of the real handler at
9d955a369measured the server half. For an author withmanage_metadatabut notfinance.access,GET /meta/app/crm?state=draftanswered 200 with navigation["nav_leads"](the protocol sawstate: draft)./layersanswered["nav_leads","nav_finance_ledger"].Where to fix it (objectui reading the draft through a stored-version door, or the server serving the draft layer there) is not this card's call.
Docs drift.
content/docs/plugins/adding-a-metadata-type.mdx, about lines 164-196, documentsDesignerEditorWrapperand a?layers=trueread. Neither exists in objectui source at the pin. No carrier.Observation, not measured. The layered view serves the protocol's
_diagnosticsbeside the layers. The plain read's pruned document carries the same_diagnostics, computed over the unpruned document, so the layered view discloses nothing the plain read does not. Whether any diagnostic message names a withheld entry was not measured.Not addressed here
#20237 remains open: the dispatcher's list branch. #20139 remains open: the bare-number query reads in
rest-server.ts. Both are queued behind this card.Generated by Claude Code