Skip to content

fix(cli): a multi-package artifact's flat src/docs rides the package that owns its manifest - #22403

Merged
objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22190-flat-docs-owner-package
Oct 9, 2026
Merged

objectstack-fleet[bot] merged 5 commits into
mainfrom
claude/issue-22190-flat-docs-owner-package

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #22190

Clause-②: no

What this does

os build wrote a multi-package artifact's flat src/docs/*.md to the artifact's top level. Such an artifact keeps its metadata in packages[] only (ADR-0130 D4, 2026-09-22 addendum), so these docs were the only items left at the top level. On every boot, the metadata door's residual sweep (packages/metadata/src/plugin.ts) registered them under the artifact's manifest.id and warned that no package body declares them. The warning's remedy could not be followed in an ADR-0130 layout.

The producer now conforms, as triage directed (6054545749). placeCollectedDocs (new, packages/cli/src/utils/collect-docs.ts) is the one call compile.ts uses to place collected docs:

  • each package's own src/PKG/docs/ set goes onto that package's body, through attachPackageDocs as before;
  • the stack's own flat src/docs/ set goes onto the body of flatDocsOwner(stack), after that package's own directory docs. That owner is the one packages[] entry whose id equals the artifact's manifest.id. If no entry, or more than one, carries that id, or the manifest has no id, the flat set stays on the top level as before;
  • inline top-level docs never move.

packages/metadata is not touched. The residual sweep stays as it is.

Zone 2 hypotheses, measured

verdict evidence
H1: the flat set is top-level by #18431's ruling Not a fork. The ruling's clause 1 (maintainer, #18431 comment 5715694492) reads: "The flat src/docs/ of a single-package stack keeps attaching where it does today (top level = the one package)". It says nothing about a multi-package stack. The top-level placement for multi-package stacks came from the implementation (df0c856e, two days before the 2026-09-22 emitter flip). Later seat prose (the #19246 body, the metadata attribution test's comment) restated it as a ruling, but no maintainer record says so. Clause 2 (the namespace a stack-level doc is linted against) is unchanged here: flat docs are still judged by stack.manifest.namespace. ruling text quoted; git show df0c856e
H2: the owner is decidable from the artifact Holds. On the card-shaped fixture (composeStacks([service, app], { manifest: 'preserve' }), app source in src/sales/, service in src/service/), manifest.id is app.example.acme, and exactly one entry (index 1) carries it. When no entry or two entries carry the id, nothing moves, and both cases are pinned. The collector has no duplicate-id refusal of its own (artifactPackages does not check for one), so the two-entry case is pinned rather than assumed unreachable. flatDocsOwner pins
H3: single-package byte identity Holds. Same fixture (inline doc, flat doc, an uncollected src/sales/docs/), built by base 117d34de and by this branch: sha256 710ab6c6acf915ac265c0fe0f05cca61e3418b85f84ace5f901e08000e000aef both times. Build stdout is identical once timings are stripped, and the uncollected-directory warning appears in both. one-off measurement
H4: the WARN stops with no metadata change Holds, at boot. See the boot reading below. os dev --fresh before and after
H5: other readers of top-level docs[] Enumerated with git grep. The runtime reads collections generically (resolveArtifactCollections merges the top level with the bodies; registerApp and the metadata door register per body), and books resolve by doc packageId. Each reader answers the same owner and count. Two answers changed, and both now match what a single-package artifact and a src/PKG/docs/ doc already answer (listed below). API diff below

Boot-level reading (integration layer, run locally)

Card-shaped fixture, objectstack dev --fresh --seed-admin --no-watch, authenticated reads. Base build is 117d34de; the fix build is this branch.

reading base this branch
[MetadataPlugin] … top-level metadata item(s) that none of its 2 package bodies declare WARN 1 0
artifact top-level keys manifest,packages,docs manifest,packages
GET /api/v1/meta/doc docs, with owners acme_service_runbook→service, acme_faq→app, acme_guide→app, setup_overview (4) the same 4, same owners
GET /api/v1/meta/book/app.example.acme/tree and the service package's book identical identical
GET /api/v1/meta/doc/acme_guide lock / editable / resettable none / true / false full / false / true
GET /api/v1/packages, app package docs [] ["acme_faq","acme_guide"]

The last two rows changed. In both, the flat docs now read the way package docs already did: the service's src/service/docs doc reads lock: full on both builds, and a single-package artifact's flat doc reads lock: full (measured on the control fixture). Before this change, docs registered by the residual sweep were missing from the owning package's installed record, so they read back as unpackaged and freely editable even though their provenance said package. The changeset says so.

Pins

  • packages/cli/src/utils/collect-docs.flat-docs-owner.test.ts (new, unit tier, so it runs on every PR): premise guard; flatDocsOwner for the unique, no-match, duplicate-id and no-id cases; placement (flat docs on the app body, carrying their markers); control: src/service/docs stays on the service body; the owner's own directory docs come first and are not dropped; inline top-level docs stay. The door: the real MetadataPlugin booted on the placed artifact in artifact-only mode logs 0 warnings and serves the same docs, owners and versions. The lit control is the same artifact with the flat set put back on the top level, built from the collection rather than from the placement: it draws exactly 1 warning from the same door. The test counts warnings and never reads their text.
  • packages/cli/test/build-package-docs-attachment.e2e.test.ts (nightly, integration): the command-level twin. It used to pin the old placement (the core body carried no docs and the top level carried pkgdocs_index). It now pins the new placement, plus a no-owner fixture (the manifest id names no entry) that keeps the top level.
  • packages/cli/test/validate-build-gate-parity.test.ts: the closed roster's artifact-assembly row now names placeCollectedDocs, the name compile.ts now calls. The row's reason is unchanged.

Ablation, run from the committed state through scripts/ablation-replace.mjs. The mutation is flatDocsOwner's owners.length === 1 changed to === -22190, so no owner is ever found. On-disk proof: anchor ×1→×0, blob 5db191c3f456→02e9d463bdfd. The unit file reads 5 failed | 10 passed (15): every placement and door case fails. The service control, the lit control and all no-owner and single-package controls stay green. The e2e file reads 1 failed | 3 passed (4), with only the owned-placement case failing. Restore: blob equals HEAD 5db191c3f456, and git diff HEAD is empty. The subject resolves from source through a relative import, so no rebuild was needed.

Verification (union run after the last commit, HEAD 027bc7fa4)

  • Gates: node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands (no paths, run at 027bc7fa4) derived 65 families. The dispatch's list of 64 plus pnpm check:cli-test-child-env, which the derivation adds for the packages/cli/test edits. All 65 exit 0. --ran reconciliation: 65 derived, 65 run, 0 NOT-MEASURED, 0 UNRUN (a derived zero: every row recorded its exit code). An earlier pass read check:dual-build-cjs-loads and check:i18n-coverage as PREREQUISITE NOT MET (exit 3: dist missing for packages outside the cli closure). After turbo run build --filter=!@objectstack/docs, both read OK in the union: 107 published require entry point(s) across 66 package(s) load and OK (13 config(s), 621 baselined untranslated string(s), none new).
  • pnpm lint (the full eslint . --no-inline-config, not narrowed): exit 0, no findings, at 027bc7fa4.
  • pnpm --filter @objectstack/cli typecheck: exit 0. tsc --noEmit passes, and check:test-typecheck: OK holds 3 file(s) / 28 error(s) / 6 pinned signature(s) unchanged.
  • cli unit layer (vitest run --project unit): 271 files / 3983 tests passed. Measured at 925e0742; git diff 925e0742 027bc7fa4 is one docblock comment in collect-docs.ts. Re-run at 027bc7fa4 for the touched surface: unit (the three collect-docs files, validate-build-gate-parity, non-array-packages-readers) 5 files / 137 tests passed; integration, nightly tier (build-package-docs-attachment, build-docs-step-count, build-multi-package-artifact) 3 files / 16 tests passed. The integration layer was run locally only for these files, because the diff touches one of them. The rest of that layer is left to CI.

Acceptance notes

  • The dev config mirror (serve.ts) is not changed, because it is outside this card's file surface. It still puts the flat set on the config's top level. Measured on this branch with os serve objectstack.config.ts --dev and no compiled artifact: GET /api/v1/meta/doc serves acme_service_runbook and setup_overview only. The two flat docs are not served at all, and nothing warns. serve.ts is byte-identical to base, so this behaviour predates this change. It is reported for the seat to file, not fixed here. os dev compiles first and serves from the artifact, so it is not affected.
  • packages/metadata/src/plugin-artifact-packages-attribution.test.ts (domain:engine, fenced): the docsArtifact docblock says that shape "is on every such artifact the compiler produces". That is now true only when no package owns the manifest. The test still passes because it builds its fixture by hand. Carrier: the next PR to touch that file.
  • packages/cli/test/normalized-call-sites.test.ts: the serve.ts :: config.docs row's why says the flat set joins the top level "as collectAndLintDocs returns it for the artifact's top level". For a multi-package build that is no longer where os build puts it. Carrier: the fix to the serve.ts mirror above.
  • packages/cli/test/build-docs-step-count.e2e.test.ts's header quotes finalBundle.docs = docsResult.docs. The behaviour it describes (single-package fixtures) is unchanged, but the quoted line no longer exists. Carrier: none.
  • An artifact that is already built keeps its shape, and the warning, until it is rebuilt.

Generated by Claude Code

claude added 5 commits October 9, 2026 01:37
…that owns its manifest

placeCollectedDocs makes every docs placement os build performs: the
per-package sets onto their bodies as before, and the stack's own flat
src/docs onto the body of the packages[] entry whose id is the
artifact's manifest.id when exactly one entry carries it. A stack with
no packages[] keeps them on the top level, byte for byte.

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

github-actions Bot commented Oct 9, 2026

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

18 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 11d119ab1868a658c5f43538f3026a56438b2ed6.

⛔ 5 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 — 28 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 11d119ab1868a658c5f43538f3026a56438b2ed6 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 3d5f3d756a3a9596ad4eb0849c9d8b373a6fa3c1 — the merge of head 027bc7fa48d794dbafdcff18214415f3ad4bb5cf into base 11d119ab1868a658c5f43538f3026a56438b2ed6, 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 3d5f3d756a3a9596ad4eb0849c9d8b373a6fa3c1 && git checkout 3d5f3d756a3a9596ad4eb0849c9d8b373a6fa3c1
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 11d119ab1868a658c5f43538f3026a56438b2ed6 027bc7fa48d794dbafdcff18214415f3ad4bb5cf && git checkout -B drift-repro 11d119ab1868a658c5f43538f3026a56438b2ed6 && git merge --no-ff 027bc7fa48d794dbafdcff18214415f3ad4bb5cf

node scripts/docs-audit/affected-docs.mjs --json 11d119ab1868a658c5f43538f3026a56438b2ed6

⚠️ 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 11d119ab1868a658c5f43538f3026a56438b2ed6 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

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