Skip to content

docs(ui): state the one-app-per-package rule where an app author reads it, and cite the ADR that says so - #17309

Merged
baozhoutao merged 2 commits into
mainfrom
claude/issue-16565-one-app-rule-authoring-surface
Sep 10, 2026
Merged

docs(ui): state the one-app-per-package rule where an app author reads it, and cite the ADR that says so#17309
baozhoutao merged 2 commits into
mainfrom
claude/issue-16565-one-app-rule-authoring-surface

Conversation

@baozhoutao

@baozhoutao baozhoutao commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

Addresses items 1 and 3 of #16565. This PR does not close that card — item 4 (an author-time packages/lint rule) 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 patch grading 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

defineStack refuses a second app in a type: '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.mdx is the sharpest miss: it is the page about serving several audiences, and its worked example is already the correct pattern — one crm app whose navigation is gated by requiredPermissions. 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/main at b90aff81f2.

content/docs/ui/audience-based-interfaces.mdx   (BEFORE)
  'exactly one app'                                       0
  'only one app'                                          0
  'at most one app'                                       0
  ADR-0019                                                0
  positive control  requiredPermissions                   4   -> right page
  positive control  'ADR-'                                4   -> the page DOES cite ADRs
  negative control  'zzz-nonsense-control-zzz'            0

The ADR- control matters on its own: the page cites ADR-0047 and ADR-0066 four times between them, so the zero for ADR-0019 is 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-0019 names two records here — docs/adr/0019-app-as-consumer-unit.md (D3 = the one-app rule) and docs/adr/0019-approval-as-flow-node.md (D3 = deprecating ApprovalProcessSchema). Both exist, both have a D3, and the collision is already frozen on check-adr-anchors.mjs's shrink-only KNOWN_NUMBER_COLLISIONS.

Anchored by content, packages/spec/src/stack.zod.ts cited the bare number for both records:

site (content-anchored) record meant before
flows field docblock, "approvals are no longer a top-level collection" approval-as-flow-node bare
guidance.approvals approval-as-flow-node bare
guidance.approvalProcesses approval-as-flow-node bare
validateSingleApp docblock app-as-consumer-unit bare
the STACK_SINGLE_APP_VIOLATION message app-as-consumer-unit bare
StackSingleAppViolationError docblock app-as-consumer-unit bare

One 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 validateSingleApp docblock immediately above the message, and the StackSingleAppViolationError docblock. (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-0019 bare 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 on at most one app) are unaffected. The rule, the refusal's condition and defineStack's behaviour are unchanged.

Changeset: measured, not defaulted

skip-changeset does not apply. Measured after a real build of @objectstack/spec:

packages/spec/package.json files[]  ->  ["dist", ..., "src/**/*.zod.ts", ...]
  src/stack.zod.ts matches src/**/*.zod.ts        true   -> the edited file is itself published

grep over the published dist/:
  'app-as-consumer-unit'                             4    dist/index.js, dist/index.mjs,
                                                          dist/browser/index.js, dist/browser/index.mjs
  'separate packages (ADR-0019 D3)'  (old spelling)  0
  positive control 'must define at most one app'    28
  negative control 'zzz-nonsense-zzz'                0

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:

Run reconciliation - 96 derived, 96 run, 0 NOT-MEASURED, 0 UNRUN.

Three gates first returned PREREQUISITE NOT MET (exit 3 twice, plus check:skill-examples refusing on an unbuilt client-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 --filter @objectstack/spec test        470 files / 13233 tests passed
pnpm --filter @objectstack/spec typecheck   OK (test layer compiles; debt ledger held)
pnpm lint                                   exit 0  (repo-wide, eslint . --no-inline-config)
node scripts/check-skills-token-ratchet.mjs exit 0  (this diff moves no skill file)

pnpm lint was run whole rather than narrowed, so no narrowing needs declaring. Build and test ran through scripts/pm/os-verify-lock.sh; verdicts read from its VERDICT command-exit lines, never a bare $?.

Acceptance notes (not filed as cards)

  • Bare ADR-0019 still means the app cap in packages/spec/src/api/error-code-ledger.zod.ts, examples/app-showcase/src/coverage.ts, examples/app-showcase/src/security/permission-sets.ts and docs/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.md names docs/adr/0019-app-as-consumer-unit.md twice 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.
  • The one-app rule is also absent from 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

`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
@github-actions github-actions Bot added size/s documentation Improvements or additions to documentation tooling labels Sep 10, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

1 anchor(s) derived from 1 changed package(s); no hand-written page names any of them, so this run has nothing to listnot 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
  • the SDK route bridge reached 60 of 215 client-bound route-ledger rows — the other 155 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 155: 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; 100 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.

Coarse fallback — 134 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 8c9bd8f51fe56bd32fa90a23aefda20c45ec496epackageMentionDocs.

Which tree this was computed on

This run read content/docs from 2679c112a058826b4c24136cef2ab7f2dd919b0e — the merge of head 7a135e875b3f8a1a7a7fd331e768559110a9f7a8 into base 8c9bd8f51fe56bd32fa90a23aefda20c45ec496e, 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 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

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

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/s tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants