docs(ui): state the one-app-per-package rule where an app author reads it, and cite the ADR that says so - #17309
Conversation
`content/docs/ui/audience-based-interfaces.mdx` is the page about serving several audiences, and its worked example is already the correct shape — one `crm` app whose navigation is gated by `requiredPermissions`. It never said the shape was mandatory, so a reader designing for three business audiences saw a page about gating, not a page about a cap, and reasonably reached for one app per audience. `defineStack` then refuses the second app at load, after the architecture and part of the implementation are written. Say it: a package of `type: 'app'` exposes exactly one App; more audiences are navigation groups inside that one app. `ADR-0019` names two records in this repo — `0019-app-as-consumer-unit` (D3 = the one-app rule) and `0019-approval-as-flow-node` (D3 = deprecating `ApprovalProcessSchema`) — and `stack.zod.ts` cites the bare number for both, so an author following the throw's own citation is as likely to land on the wrong record as the right one. The three citations of the app-cap rule in that file now name the record: the throw string, the `validateSingleApp` docblock and the `StackSingleAppViolationError` docblock. The approvals citations are left bare; repo-wide disambiguation is a separate scope. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Measured, not assumed: `packages/spec/package.json` `files[]` publishes both `dist` and `src/**/*.zod.ts`, and after a build the new citation appears in four published bundles (`dist/index.js`, `dist/index.mjs`, `dist/browser/index.js`, `dist/browser/index.mjs`) with the old spelling at zero. Published bytes move, so `skip-changeset` does not apply. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
📓 Docs Drift Check1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to list — not a clean bill of health. This check sees only pages that NAME a derived anchor: one that documents this change in prose, or enumerates it in an authoring dialect, names none and stays invisible to it on every run. What this run could not see
Coarse fallback — 134 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 2679c112a058826b4c24136cef2ab7f2dd919b0e && git checkout 2679c112a058826b4c24136cef2ab7f2dd919b0e
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 8c9bd8f51fe56bd32fa90a23aefda20c45ec496e 7a135e875b3f8a1a7a7fd331e768559110a9f7a8 && git checkout -B drift-repro 8c9bd8f51fe56bd32fa90a23aefda20c45ec496e && git merge --no-ff 7a135e875b3f8a1a7a7fd331e768559110a9f7a8
node scripts/docs-audit/affected-docs.mjs --json 8c9bd8f51fe56bd32fa90a23aefda20c45ec496e |
Addresses items 1 and 3 of #16565. This PR does not close that card — item 4 (an author-time
packages/lintrule) is deliberately not taken, and item 2 ships as a separate governed PR. #16565 stays open; no closing keyword is used here.Clause-②: no — the diff puts no new key on any published payload: one docs sentence, one See-also entry, and three citation strings inside an existing function and two docblocks. No export is added, no schema member appears, no payload shape moves. This agrees with the claim comment on #16565. The measured
patchgrading stands.Companion, kept out of this diff on purpose:
claude/issue-16565-one-app-rule-ui-skill(item 2).skills/**is a governed surface and a mixed diff is governed whole, so it is a separate draft PR for manual merge.What an author reads today
defineStackrefuses a second app in atype: 'app'package, loudly and immediately. The rule is right. The problem is that nothing an app author reads states it, so the refusal is discovered after the architecture is designed against a shape the platform will not accept.content/docs/ui/audience-based-interfaces.mdxis the sharpest miss: it is the page about serving several audiences, and its worked example is already the correct pattern — onecrmapp whose navigation is gated byrequiredPermissions. It demonstrates the rule and never claims it, so a reader designing for three business audiences sees a page about gating, not a page about a cap.Re-derived measurements
Every zero below has a same-corpus positive control taken in the same pass, on
origin/mainatb90aff81f2.The
ADR-control matters on its own: the page cites ADR-0047 and ADR-0066 four times between them, so the zero forADR-0019is a reading about that number and not an artefact of a page that cites no decisions.The citation is ambiguous, and the evidence is inside one file
ADR-0019names two records here —docs/adr/0019-app-as-consumer-unit.md(D3 = the one-app rule) anddocs/adr/0019-approval-as-flow-node.md(D3 = deprecatingApprovalProcessSchema). Both exist, both have a D3, and the collision is already frozen oncheck-adr-anchors.mjs's shrink-onlyKNOWN_NUMBER_COLLISIONS.Anchored by content,
packages/spec/src/stack.zod.tscited the bare number for both records:flowsfield docblock, "approvals are no longer a top-level collection"guidance.approvalsguidance.approvalProcessesvalidateSingleAppdocblockSTACK_SINGLE_APP_VIOLATIONmessageStackSingleAppViolationErrordocblockOne file, one bare number, two different records, both with a D3. An author following the refusal's own citation was as likely to reach the wrong record as the right one.
Decision on the two docblocks — stated, not silent
The card names the throw. Two further citations of the same rule sit in the same file: the
validateSingleAppdocblock immediately above the message, and theStackSingleAppViolationErrordocblock. (The second was not in the dispatch's measurement; it is a third app-cap site, found by content.)All three are disambiguated. Fixing only the message would leave the paragraph that produces it saying
ADR-0019bare two lines higher — the same fork, re-created for the next reader, and fixing one docblock but not the other would be the same fault at one remove. The three sites are one rule, in one file; that is the occurrence this card is about.The three approvals-side citations are deliberately left bare. Disambiguating those is the other half of the collision and belongs to a repo-wide sweep, not here.
Only the message tail moved.
An 'app' package must define at most one app, but found N (...)is byte-identical, so the two suites that pin it (stack.test.ts,stack-refusal-envelopes.test.ts, both matching onat most one app) are unaffected. The rule, the refusal's condition anddefineStack's behaviour are unchanged.Changeset: measured, not defaulted
skip-changesetdoes not apply. Measured after a real build of@objectstack/spec:Published bytes move on two independent routes, so this carries a real changeset, graded patch: a user-visible message string improves, no API shape moves, no behaviour changes.
Verification
Gate family derived from the diff rather than recalled —
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, reconciled afterwards with--ran:Three gates first returned
PREREQUISITE NOT MET(exit 3 twice, pluscheck:skill-examplesrefusing on an unbuiltclient-react). Exit 3 is NOT MEASURED, neither green nor red, so each was driven to a real verdict after the missing builds — all three then exit 0. The six roster gates whose allowlists sit under a path in this diff were run explicitly rather than read as silent.pnpm lintwas run whole rather than narrowed, so no narrowing needs declaring. Build and test ran throughscripts/pm/os-verify-lock.sh; verdicts read from itsVERDICT command-exitlines, never a bare$?.Acceptance notes (not filed as cards)
ADR-0019still means the app cap inpackages/spec/src/api/error-code-ledger.zod.ts,examples/app-showcase/src/coverage.ts,examples/app-showcase/src/security/permission-sets.tsanddocs/adr/0130-release-artifact-as-co-ownership-boundary.md. Left alone:check-adr-anchors.mjs's header records that slug-qualifying the three frozen collisions is separate work "amortised as those files are touched", which is exactly the disposition applied here.docs/adr/0130-release-artifact-as-co-ownership-boundary.mdnamesdocs/adr/0019-app-as-consumer-unit.mdtwice in one parenthesis, where the second path looks like it was meant to be something else. A one-line doc nit in a file this PR does not touch.skills/objectstack-ui/rules/navigation.md, which is at its token ceiling (2273/2273, headroom 0) — the same budget wall the companion PR reports.🤖 Generated with Claude Code
https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU
Generated by Claude Code