Skip to content

fix(rest): the stored-version doors serve an app whole to whoever may save it, and pruned to everyone else - #20284

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20156-app-author-exemption
Sep 27, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-20156-app-author-exemption

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #20156
Clause-②: no

Executes maintainer ruling 5856774816 on the card (batch 227 item 1, letter B), confirmed in 5856866273. The ruling, verbatim:

Ruled: B — author exemption, in ADR-0106 D4's shape. On the three stored-version doors (/layers, ?layers=true, /diff) a caller who may write the app reads the full stored version; every other caller who may open the app reads exactly what the plain read gives them, pruned.

This is the second half of the card. The first half landed as a Part of PR (the landing record is comment 5855055724 on the card). It left the partial app cells 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.app becomes 'gate' | 'author-exempt', and 'pending-decision' is retired. 'author-exempt' serves the stored app whole to a caller whose MetaReadGateCaller.mayWriteItem is true. Every other caller gets 'gate''s answer: the plain read's refusal, or the pruned app.
  • The exemption is a caller property (ADR-0106 D4). The caller carries the verdict, and the policy says only whether a door honours it. The gate tests no route.
  • An app the plain read refuses whole (an app-level requiredPermissions the 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.
  • An exempt author skips the docs-audience entry arm, because that arm only prunes entries. A non-author on a stored-version door now resolves it, as the plain read does. It fails closed on a book-read fault by dropping doc entries, exactly as the plain read does.

The REST transport (packages/rest/src/rest-server.ts)

  • RestServer.metaSaveVerdict(ctx, rawType) is the admission of PUT /meta/:type/:name, spelled once. The PUT door now calls it, and the exemption asks it too. That follows ruling item 1: the predicate is metaWriteCapabilityVerdict, the save door's own answer, not a "platform administrator" reading.
  • The exemption asks the whole save-door question, not a capability name: the type is folded at the boundary and the organization is ctx.tenantId, as the door threads it. manage_org_presentation therefore 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_POLICY is { arms: 'per-caller', app: 'author-exempt' }. Only doors whose policy honours the exemption put mayWriteItem on 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.
  • The two doors now serve what the gate serves. The layered view and /diff used to act on a refusal verdict and discard a serve verdict's document, so they could not answer "pruned" at all.
    • The layered view replaces each layer with the gate's document, after judging every layer.
    • /diff emits 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.
  • No runtime change. The runtime dispatcher runs only { arms: 'all', app: 'gate' } and serves no stored-version door.

Unchanged, per the ruling: the plain read and /published prune for every caller, authors included. /history, /audit, every whole-refusal cell, and the book, doc and dashboard arms are unchanged.

Verification (head 9d955a369 unless stated)

Tests

  • The census, packages/rest/src/meta-alternate-door-read-gates.test.ts: 261/261.
    • It has a new author caller (manage_metadata, without finance.access or crm_admin), so the plain read serves them only part of crm.
    • The author's three stored-door cells for crm pin the full stored version. The non-author cells for crm pin the plain read's pruned navigation on every layer and in /diff's emitted values, with the same bucket paths as the raw diff.
    • The rendered doors are controls: the plain read and /published still prune crm for the author.
    • The author is refused payroll (403) and launchpad (404) on every door.
  • Retired: PENDING_DECISION, isPendingDecision and their docblock. AUTHOR_EXEMPTION cites the ruling. Three pins replace them:
    • the exemption names exactly /layers, ?layers=true and /diff, which is every stored-version door and no other;
    • the cells whose answer it changes are exactly the author's three partial crm cells, and the six author whole-refusal cells stay refused;
    • its predicate is the save door: each caller's declared savesApps equals what PUT /meta/app/crm answers them. The author gets 200 and the protocol is called once; reader and non-reader get 403 FORBIDDEN; 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 at 6780cd777, and the only packages/ delta since is one docblock line in rest-server.ts (git diff 6780cd777 9d955a369 -- packages/).
  • @objectstack/rest typecheck (tsc, plus the test layer under tsconfig.test.json): exit 0.
  • @objectstack/runtime typecheck: exit 0. domains/meta-item-read-gate-parity.test.ts: 77/77. Both ran at bad8baa63; the delta since is the same docblock line.

Reverse verification and ablations

Each mutation used scripts/ablation-replace.mjs in wrap mode inside a script that traps EXIT, INT and TERM. Each restore was proven: the blob equals HEAD and git diff HEAD is empty.

mutation where landed result
A: the exemption forced off (serve(gated.app) for everyone) the gate anchor 1 → 0, blob 817024027bb2 → 6feda3867a6c 261 run, 3 red: exactly ?layers=true, /layers and /diff × app/crm × author
B: the predicate forced on for everyone (mayWriteItem: true) the transport anchor 1 → 0, blob 5942f7eb57de → 43523e878b86 261 run, 5 red: the three app/crm × non-reader stored-door cells, the fault-path /layers pin, and the org-presentation pin
C: the retired 'pending-decision' put back into the runtime's policy packages/runtime/src/domains/meta.ts anchor 1 → 0, blob c47473b9dcdd → 543aa6707030 runtime tsc: 1 error, TS2322 … 'pending-decision' … not assignable to type 'MetaReadGatePolicy' at meta.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 --commands derived 92 families from merge base a9fb83ef0. Every command ran at 9d955a369 with its exit code captured before any pipe. --ran reconciled the run: 92 derived, 91 run, 1 NOT-MEASURED, 0 unrun.

  • 90 exit 0.
  • node scripts/check-empty-changeset.mjs --base origin/main exits 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 no dist/, and this gate needs the whole repo built. That is CI's Build Core run.
  • Added by this lane:
    • pnpm lint (eslint . --no-inline-config, the whole repo): exit 0 in 121s. It ran on the tree that became 9d955a369, so this is a full run, not a narrowed one.
    • The board check 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-census was red after the refactor: metaSaveVerdict holds the save door's isSystem read now. Row 49 of content/docs/permissions/system-context.mdx now 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-examples and check:type-check-debt first refused on missing builds (exit 3). Both exit 0 at 9d955a369 once their named prerequisites were built.

A pending release note is corrected in place, so check-empty-changeset stays red

.changeset/20156-alternate-door-read-gates.md exists 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:

  • The note: the bullet that began "One case is unchanged in this release: an app the plain read would serve a caller only in part…", which said the partial app cells were "pending a decision". The note's closing sentence, "…except the app row of /layers, ?layers=true and /diff for a caller the plain read serves only part of the app", also changed.
  • What changed under it: this PR executes the ruling, so both sentences now state the ruled answer. A caller who may save the app reads it whole on those doors; every other caller reads it pruned. If the note shipped unchanged beside this PR's own changeset, the release notes would contradict themselves.
  • This PR's own changeset: .changeset/20156-app-author-exemption.md, @objectstack/rest patch. The Check Changeset job counts only ADDED changesets, so the corrected note alone would not satisfy it.
  • skip-changeset is not applied: this PR publishes.

Acceptance notes

Dispatch item 4: the exported types are unreleased. MetaReadGatePolicy and MetaReadGateCaller became @objectstack/rest exports under the pending .changeset/20193-dispatcher-meta-read-gate.md (minor). That file is still on origin/main 89f87f234, checked just before this PR opened. The published @objectstack/rest@17.4.0 tarball was also checked. Its dist/index.d.ts has RestServer 21 times (the positive control) and MetaReadGatePolicy|MetaReadGateCaller|createMetaItemReadGate 0 times. Its dist/index.js has createMetaItemReadGate|pending-decision 0 times. So changing these members moves no published surface, which is what patch and Clause-②: no rest 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-sha f8a9d0fb0596, from a read-only clone:

  • /diff: 0 source readers. MetadataClient.diff is defined but never called.
  • ?layers=: 0 source readers. objectui's own ratchet test forbids that spelling.
  • /layers for an app has two save-back readers, and both save through client.save, which is PUT /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 for app. It loads at :1017 and saves at :1500.
  • A non-author who loads the pruned app cannot save it, because that door refuses them with the same verdict the exemption asks. No reader saves under a non-author identity.
  • DesignerEditorWrapper, the reader the ruling named, is not in objectui source at the pin; only CHANGELOG entries mention it.
  • Other /layers readers never load an app: PermissionMatrixEditor (permission), PackageOwdOverviewPanel (object), EmbeddedItemEditor (object sub-items), and StudioDesignSurface at :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 from client.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 navigation overrides the whole effective for 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 9d955a369 measured the server half. For an author with manage_metadata but not finance.access, GET /meta/app/crm?state=draft answered 200 with navigation ["nav_leads"] (the protocol saw state: draft). /layers answered ["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, documents DesignerEditorWrapper and a ?layers=true read. Neither exists in objectui source at the pin. No carrier.

  • Observation, not measured. The layered view serves the protocol's _diagnostics beside 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

… 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>
…veVerdict, which the stored-version read doors ask too

Claude-Session: https://claude.ai/code/session_01UYBdGBzWSrAMzpW8ah3GbP
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l documentation Improvements or additions to documentation tests tooling labels Sep 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/rest, touching 27 documentable anchor(s).

20 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json 89f87f23440d61f8fcd10b2c2991c663a917181e.

⛔ 8 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 15 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 89f87f23440d61f8fcd10b2c2991c663a917181e → packageMentionDocs.

Which tree this was computed on

This run read content/docs from aacf0c7ba0db3d16428246bc1ed82f8241b0b98d — the merge of head 9d955a369a86c13a7047d2852fba3a9bda85f728 into base 89f87f23440d61f8fcd10b2c2991c663a917181e, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 89f87f23440d61f8fcd10b2c2991c663a917181e → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 9d955a369a86c13a7047d2852fba3a9bda85f728

① Derived judgments

  • The exemption predicate is "who may save this app": correct. RestServer.metaSaveVerdict(ctx, rawType) (packages/rest/src/rest-server.ts:3525) calls metaWriteCapabilityVerdict({ isSystem, systemPermissions, canonicalType: canonicalMetaUrlType(rawType), activeOrganizationId: ctx?.tenantId, operation: 'save' }). That is byte-for-byte the argument set the PUT door built inline at the merge base. The read doors attach it as mayWriteItem in metaReadAudienceSources (:3336), from the same memoised resolveExecCtx result and the same req.params.type.
  • It is the PUT door's FULL caller admission: correct. After the verdict, the handler (:7244 onward) resolves the protocol (501 if there is no saveMetaItem), unwraps the body, and reads If-Match, resolveMetaWriteActor, ?force/?package/?mode=draft and organizationIdForMetaWrite(canonicalType, ctx?.tenantId). None of these refuses on a caller property.
    • NOT_OVERRIDABLE is saveMetaItem's item/scope refusal, identical for every admitted caller.
    • manage_org_presentation is already folded into the verdict (an org-overridable type plus an active org, packages/metadata-core/src/meta-write-capability.ts), and app is not org-overridable. The census presenter edge drives the real PUT: view 200, app 403.
    • endpoints.item switches PUT, /layers (inside the item !== false block) and /diff (registerPerItemRoute) together, so it cannot turn an exempt reader into a refused saver.
  • The save door's own behaviour is unchanged: correct. The diff replaces the inline call with RestServer.metaSaveVerdict(ctx, req.params.type): the same inputs, and the same refusal envelope (403 FORBIDDEN, verdict.message). There is no unannounced write-path change.
  • The exemption applies only on the three stored-version doors, and only to app: correct.
    • mayWriteItem has exactly three sites at head: the interface (meta-item-read-gate.ts:65), the app arm (:1264) and the transport (rest-server.ts:3336).
    • The transport supplies it only when policy.app === 'author-exempt' (:3250), and only STORED_VERSION_DOOR_POLICY (:3502) carries that.
    • The plain read and /published run { arms: 'all', app: 'gate' } (:7072, :8673). /history and /audit run eventDoorRefusal's { arms: 'per-caller', app: 'gate' } (:3407).
    • The doc, book and dashboard arms never read mayWriteItem.
  • No author is served what the plain read refuses WHOLE: correct. In the app arm, the gated.app == null refusals run before serve(gateInputs.authorExempt ? document : gated.app) (:1300): permission answers 403, and unpublished answers absent to a non-builder (ADR-0045 §3). In the census, author × payroll is 403 and author × launchpad is 404 on all six doors, and the scope pin enumerates those six cells as stillRefused.
  • The layered-view mechanism fix is correct, and neutral for other types.
    • serveMetaItemLayered judges every present layer first, then assigns served (:3949-3959).
    • serve(document) returns the same reference for doc, book, dashboard (the per-caller skip), object and the default arm (meta-item-read-gate.ts:1312-1361), so the replacement is a no-op for them. The ADR-0106 mask runs afterwards, unchanged.
    • For app, a caller the plain read serves in full gets filterAppForUserWithReason's copy (census reader × crm: every layer toEqual(stored)).
    • A residual nuance, not a defect: that filter also drops declared-empty group entries, so non-authors now get that tidy on stored doors, as the plain read already gives them.
  • The /diff mechanism fix is correct: it discloses less and reports no phantom deletions.
    • diffEmittedFrom (:3459) maps each bucket in place, substituting only value/from/to from the judged sides. Bucket paths are preserved: the census asserts bucketPaths(res.body) === bucketPaths(raw).
    • For unpruned sides, diffSides sets to[path] = e.value and so on, so the emitted values are identical.
    • A non-author loses the withheld entries' values on both sides. A changed entry can now show equal pruned values, which hides a change but never reports a deletion.
    • object is not gatesPerCaller, so its diff path is untouched.
  • The non-author fault path on stored doors is new, but identical to the plain read. resolveNavDocAudience is now resolved for non-authors on stored doors, which it was not under pending-decision. A book-read fault drops doc entries (fail closed), which is the plain read's own composite answer. An edge pin covers it on /layers.
  • The runtime dispatcher is untouched, compiles, and behaves the same. packages/runtime/src/domains/meta.ts:297 RENDERED_DOCUMENT_POLICY = { arms: 'all', app: 'gate' } is a member of the new union. The gate reads ctx.mayWriteItem only under author-exempt, which the runtime never passes. git grep finds no pending-decision anywhere at head.
  • The census reddens in every direction.
    • Widened to /published for an author: the pruned row requires navIds === plain and no secret.
    • Widened to a non-author: non-reader × crm × stored doors requires no secret (PR ablation B: 5 red).
    • Widened to doc/book: the author rows are refused, with secrets forbidden.
    • Narrowed: the exempt cells require the secrets present and toEqual(stored) / toEqual(raw diff) (ablation A: 3 red).
    • savesApps is checked against the registered PUT handler with the real metaSaveVerdict: author 200 with saveMetaItem called once; reader/non-reader 403 FORBIDDEN; anonymous 401; the protocol is never called on a refusal.
    • The case count, 1+36+3+216+5 = 261, matches the file.

② Semver level

patch for @objectstack/rest with Clause-②: no is correct, as ruling item 4 set.

  • Published surface: the @objectstack/rest@17.4.0 tarball, read from the registry, has RestServer 21 times in dist/index.d.ts and MetaReadGatePolicy|MetaReadGateCaller|createMetaItemReadGate 0 times. No file under dist/ mentions pending-decision or author-exempt, and dist/index.js has 0 hits. The types enter the surface only through the still-pending .changeset/20193-dispatcher-meta-read-gate.md (@objectstack/rest: minor, on origin/main). So the combined release first publishes 'gate' | 'author-exempt', and nothing released moves.
  • Narrowing: non-authors whom the plain read serves only part of an app now get it pruned on the three doors, where before they got it whole. That pulls a door back to a gate the contract already declares (apps.mdx "the entry is never served"; ADR-0046 §6.7; ADR-0045 §3). It is the same class three reviews upheld on PR fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it #20190 as outside clause ②. No authorable accept set narrows, so no (narrowing) + BREAKING marker is owed.

③ Boundary flags

  • THE DELIBERATE CORRECTION: .changeset/20156-alternate-door-read-gates.md (on the merge base, M in this PR). Two spans are rewritten.
    • Span 1, the bullet.
      • Before (7 sentences): "One case is unchanged in this release: an app the plain read would serve a caller only in part. That is an app the caller may open, but with navigation entries withheld from them by requiredPermissions or the documentation audience. On /layers, ?layers=true and /diff, such a caller still receives the stored app with those entries included, as before. No other caller does: an app the plain read refuses whole is refused on these doors too. What these doors should answer a partial caller is pending a decision, because both ways of matching the plain read restrict authors. Pruning the stored app would delete the withheld entries when Studio's designer saves back what it loaded. Refusing it would lock out any author who lacks one entry's permission, platform admins included."
      • (a) Sentences 1, 3 and 5 became false through this PR's behaviour change: the case changed, non-authors are pruned, and the decision is ruled. Sentences 2, 4 and 6 are true, but they are orphaned rationale or definitions that the new text keeps. Sentence 7 argues a rejected option and describes nothing in the release.
      • After, judged one by one:
        1. "For an app the caller may open, /layers, ?layers=true and /diff answer by who is asking." (b) True: serve(authorExempt ? document : gated.app) under STORED_VERSION_DOOR_POLICY.
        2. "A caller who may save the app (the one PUT /meta/app/:name admits) receives the full stored version, including the navigation entries that requiredPermissions or the documentation audience withhold from them: Studio's designer saves back what it loads, so a pruned load would delete those entries." (b) True: mayWriteItem = metaSaveVerdict(...).allowed, the PUT door's admission; census author cells are toEqual(stored).
        3. "Every other caller receives the app without the entries withheld from them, left out as the plain read leaves them out, on every layer and on both sides of a diff." (b) True: the per-caller arms (requiredPermissions, docs audience) run on each layer and via diffEmittedFrom. The per-deployment arms stay off, as the preceding bullet of the same note says.
        4. "The plain read and /published prune for every caller, authors included." (b) True: { arms: 'all', app: 'gate' } at :7072 and :8673; the census author × crm cells for the plain read and /published are pruned.
        5. "An app the plain read refuses whole is refused on these doors to an author too." (b) True: the refusals precede the exemption, and six author whole-refusal cells are pinned.
    • Span 2, the closing sentence.
      • Before: "…now receives the plain read's answer, except the app row of /layers, ?layers=true and /diff for a caller the plain read serves only part of the app." (a) False now: only savers are excepted.
      • After: "…now receives the plain read's answer, except that a caller who may save an app reads it whole on /layers, ?layers=true and /diff." (b) True of the combined release (fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it #20190's pull-backs plus this exemption).
    • (c) The rest of the note and the new changeset: nothing is false or contradictory.
      • These hold at head: the frontmatter; the doc/book/object/dashboard bullets; /history//audit; "dashboard is unchanged on every door except /published"; and "a caller the plain read serves in full gets the same answers as before".
      • The new .changeset/20156-app-author-exemption.md (@objectstack/rest: patch) agrees with the corrected note on every point, including that manage_org_presentation does not qualify (app is not org-overridable).
      • Observation only: the earlier bullet's clause "they show the stored version, which is what an author edits" is scoped to the per-deployment gates, and the corrected bullet refines it. It is not contradictory.
    • Deliberate correction of .changeset/20156-alternate-door-read-gates.md confirmed: 6 sentence(s) judged.
  • content/docs/ui/apps.mdx (:359-373): every changed sentence is true at head:
    • "/published twin … to every caller, the app's authors included" (app: 'gate' on both);
    • "the one PUT /api/v1/meta/app/NAME admits: manage_metadata, or a system context" (the verdict is isSystem or manage_metadata, and manage_org_presentation is excluded for app);
    • "left out … on every layer and on both sides of a diff";
    • "lifts no whole refusal".
  • content/docs/permissions/system-context.mdx row 49: true. metaSaveVerdict holds an isSystem read (ctx?.isSystem === true), so a system context is exempt on the three doors. The anchor is added, and the count is regenerated from 87 to 88. Lint & Repo Gates is green on head, so the census gate accepted it.
  • Files outside the claimed surface: none. All 7 files are inside claim 5857413915 as amended in place: the gate, the transport, the census, the two changesets, apps.mdx and system-context.mdx. No runtime, metadata-core or spec file is touched.
  • PR body vs diff: every mechanism claim is verified above: the copied ctx; the exemption gated on the policy, not the type; the layer replacement after judging all layers; diff emission preserving entries; PENDING_DECISION/isPendingDecision retired; the tarball's 0 hits; no runtime change. The first line is Fixes #20156, then a bare Clause-②: no. The objectui reader census and the ablation numbers are reported but not reproducible here (no repo attach was permitted), and they contradict nothing at source.
  • CI on head (35 check-runs, all completed): 32 success; 2 skipped (Console Pin Gate and Packed-tarball smoke (opt-in), both rostered expected skips); 1 failure, Check Changeset.
    • The job log is not readable through the proxy (403).
    • By scripts/check-empty-changeset.mjs's diff-shape rule, the merge-base diff carries exactly one M row under .changeset/ (the pending note) and one A row with non-empty frontmatter. So the only refusal it can raise is the foreign-changeset rule on the pending note: the DELIBERATE CORRECTION class this record confirms.
    • There is no other red.
  • Out-of-scope draft finding, server half: CONFIRMED at source.
    • GET /meta/:type/:name?state=draft sets state: 'draft' on getMetaItem (rest-server.ts:6988-7002), then runs metaItemReadGate(..., { arms: 'all', app: 'gate' }) with visible = verdict.document (:7060-7068).
    • No mayWriteItem is attached on that door. So an author lacking an entry's permission reads the draft pruned, while /layers serves it whole.
    • It is not this PR's required change; the carrier is the seat's decision.
  • The ruling's confidence gap: the dev measured objectui's readers before the fix. It reports two /layers app readers, both of which save through PUT (the exemption's own predicate), and no /diff or ?layers= reader. This was reported, not tolerated.

Implemented-by: claude/issue-20156-app-author-exemption
Reviewed-by: session_01UYBdGBzWSrAMzpW8ah3GbP

Independence: INDEPENDENT AGENT (fed the card, the rulings and the PR only; not the dispatch order or the seat's conclusions)

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Check Changeset is red by design on this head: a confirmed DELIBERATE CORRECTION

domain:cli execution PM seat #6024 · session session_01UYBdGBzWSrAMzpW8ah3GbP · written 2026-09-27T18:58Z

  • The gate: Check Changeset (.github/workflows/pr-automation.yml, job changeset-check, which runs scripts/check-empty-changeset.mjs --base "$MERGE_BASE").
  • The reason: this PR modifies .changeset/20156-alternate-door-read-gates.md, a PENDING release note that PR fix(rest): one per-caller read gate for GET /meta/:type/:name and every door beside it #20190 added. The note's statement that partial app callers are "unchanged … pending a decision" is false once this PR executes ruling 5856774816, so the PR corrected it in the same stroke. That is the gate's DELIBERATE CORRECTION class. Its own remedy text reads 「do NOT restore it -- say so on the PR and get it confirmed」 and 「this gate stays red either way」.
  • The confirmation: the at-tier contract review on this same head (9d955a36), record 5858783654, names the note and judges each of its 6 rewritten sentences: 「Deliberate correction of .changeset/20156-alternate-door-read-gates.md confirmed: 6 sentence(s) judged.」 Under landing-operations.md, that record is the confirmation.
  • Why it does not block the queue: pr-automation.yml triggers on pull_request only, with no merge_group, and Check Changeset is not a required context. Every other check on this head is success or a rostered expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review September 27, 2026 19:01
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Sep 27, 2026
Merged via the queue into main with commit 2dccb7d Sep 27, 2026
36 of 37 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/issue-20156-app-author-exemption branch September 27, 2026 19:19
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
…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>
akarma-synetal pushed a commit to akarma-synetal/framework that referenced this pull request Sep 28, 2026
… 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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/l tests tooling

Projects

None yet

2 participants