Skip to content

feat(service-print): print a record on any print page of its object, not only its default record page - #26662

Merged
objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-qp0ohr
Oct 27, 2026
Merged

objectstack-fleet[bot] merged 3 commits into
mainfrom
claude/project-thread-qp0ohr

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by Leehom · project thread

Fixes #26583

Clause-②: yes (widening)
Metadata-protocol: none

No authorable key is added or changed (no packages/spec/** in the diff; the request body already carries page), so per triage 6440154784 this card grades p2.

维护者速读

  • 改了什么:服务端打印(@objectstack/service-print)现在可以把记录打印在该对象的任意一个声明了 print 的记录页上,不再只限默认记录页。默认记录页照旧走记录路由;其它打印页走控制台已发布的具名记录页路由 …/record/ID/page/PAGE。
  • 为什么改:以前要打印报价,就得把报价的日常记录页改成打印文档,销售每天都会失去状态路径、亮点区、详情/相关标签页和动态(Print quote: a print page for crm_quote and a print_quote action on service-print, the archived PDF setting the quote to presented (REQ-0008 phase 1) hotcrm#2312 因此停着)。维护者裁决 A(6439306670)。
  • 风险与回滚:低。只放宽一条服务端检查、换一个 URL;页面不是该对象的记录页、或没声明 print 仍然 400 拒绝,未知页名仍然 404。回滚即 revert 本 PR。
  • 先看:packages/services/service-print/src/print-service.ts(refuseUnlessPrintPage、新的 isRecordPageOf 与 printPageUrl);packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts。
  • 两分钟验证:pnpm --filter @objectstack/service-print exec vitest run src/print-service.test.ts,预期 50/50 通过,其中「not the default record page prints on the console’s named record-page route」一条。
  • 需要你做的:无,席位落地。

What changes

PrintService.printRecordToFile (and so POST /api/v1/print/records), step ③ "the page":

  • Accepted: any page that is a record page of the request's object and declares print. The third refusal on main, "is not the default record page of OBJECT (isDefault: true)", is gone.
  • The URL: the object's default record page still renders on the console's record route, CONSOLE/apps/APP/OBJECT/record/ID. Any other print page renders on the console's named record-page route, CONSOLE/apps/APP/OBJECT/record/ID/page/PAGE (RECORD_NAMED_PAGE_ROUTE, ui/packages/app-shell/src/console/AppContent.tsx:280), every segment encoded on its own (printPageUrl).
  • Two loud refusals remain, both VALIDATION_ERROR / 400, before anything is read, minted or launched: the page is not a record page of the object, or it declares no print. An unknown page name stays RESOURCE_NOT_FOUND / 404.

The predicate both sides use. The console's named route renders the named page only when isRecordPageOf(named, objectName) holds (ui/packages/app-shell/src/views/RecordDetailView.tsx:787–792), and that function is p.type === 'record' && p.object === objectName (ui/packages/react/src/hooks/usePageAssignment.ts:94–98); any other name gets a not-found state, never a fallback. The server's check is now that exact reading (isRecordPageOf in print-service.ts, docblock names the console's), so the server prints exactly the set the console draws, no wider. It is a local copy because service-print cannot import @object-ui/react, and adding a shared reading to @objectstack/spec is outside this lane.

content/docs/ui/print-pages.mdx: the "A print page that is not the default is refused" sentence is rewritten to say a record prints on any print page of its object (and on which route), the example page drops isDefault: true, and the refusals table row reads "not a record page of the object (type: 'record', that object), or declares no print". The repo carries no translation of this page (find content -name 'print-pages*' → the en file only).

Changeset: @objectstack/service-print patch, both declaration lines line-initial.

Why

To print a quote, an app had to make the quote's everyday record page its printed document, and reps lost the status path, highlights, Details/Related tabs and the activity feed on every quote (objectstack-ai/hotcrm#2312, REQ-0008). The console's named record-page route (objectui#12715) already draws a named record page for a record and refuses any other name; only the server half refused. Ruling 6439306670, letter A.

Tests

Red first on main (c739de755c), then green at head:

Suite On main At head
service-print src/print-service.test.ts (tests only changed) 3 failed / 47 passed: the named-route accept, the encoded named URL, the typeless default page 50 / 50
dogfood print-service-named-page.dogfood.test.ts + print-service-archive.dogfood.test.ts (stand-in browser, real showcase boot) named-page leg red: "The page 'showcase_project_brief' is not the default record page of 'showcase_project' (isDefault: true)…" 13 / 13
dogfood print-service-render.dogfood.test.ts (real console bundle + real Chromium, both placements, OS_PAGE_REACT_ENABLED=on, see Acceptance notes) the 2 named-page legs red with the same refusal; the other 6 green 8 / 8

The main legs ran with print-service.ts restored from c739de755c (the dogfood imports the service from src/), restored afterwards by git checkout HEAD -- and proved by git diff HEAD empty and the blob hash matching HEAD.

Unit rows (print-service.test.ts):

  • 'a print page that is not the default record page' moved from the refusal table to an accept: it renders …/record/inv_1/page/invoice_document and archives invoice_document-inv_1.pdf.
  • The default page still renders the plain record route (pinned), and a name carrying / and a record id carrying a space are encoded as one segment each.
  • Refusal rows kept or added: a page of another object, a page with no print (default and non-default), a default type: 'home' page, a non-default type: 'app' print page, and a page with no type (default and non-default).

Dogfood (new print-service-named-page.dogfood.test.ts, fixture in print-named-page.fixture.ts): showcase_project keeps its working default record page showcase_project_detail (slotted, no print); a project brief that declares print is saved beside it through the metadata save door (protocol.saveMetaItem). A project prints on the brief through the named route and is archived on the project as the requester; the working page (no print) and the brief asked for an invoice (another object) are refused 400 with nothing minted.

What the real render put on paper. A one-off copy of the render leg wrote both PDFs out; pdftotext -layout:

  • the brief, printed on the named route: Project brief / Brief / PROJECT NAME STATUS / Compliance Audit On Hold / START DATE END DATE / Oct 12 Nov 26 / 1/1, so the console drew the brief for that project, not a not-found state and not the default page;
  • the invoice, on the record route: ObjectStack Showcase Ltd · 示例有限公司 / Invoice / INV-RENDER-local Draft …, unchanged.

Other local runs at head: pnpm --filter @objectstack/service-print test 8 files / 114 tests passed; pnpm --filter @objectstack/service-print typecheck exit 0 (its include is src, so the test files are compiled); pnpm --filter @objectstack/dogfood typecheck exit 0; eslint --no-inline-config on the touched service-print and dogfood files exit 0.

Acceptance notes

  • Clause-② measured on the diff. The accept set widens: a request naming a non-default print page, refused 400 on main, now prints. One shape moves the other way, stated rather than hidden: a page body in the registry with no type at all that is the default record page and declares print was accepted on main (the old check read defaultRecordPageObject, which treats an absent type as 'record') and is now refused 400, because the console's isRecordPageOf reads type exactly. On main such a print did not print that page: the console's record route filters with the same isRecordPageOf, so it drew the synthesized default page instead. No authoring door produces that body (defineStack parses with PageSchema's declared default, and the metadata save door and stored-row hydration fill it, metadata: a page saved without type is stored and served without it, so PageSchema's declared type default (record) never reaches a consumer that reads the served body #20101 withDeclaredPageTypeDefault); only a plugin calling registry.registerItem with a raw body can. I declared yes (widening); if the seat reads that one shape as a narrowing, the arm is the seat's to change.
  • The render dogfood is red on a default environment, at main too, for a reason outside this card. Without OS_PAGE_REACT_ENABLED, the console's policy is script-src 'self' 'nonce-…' 'wasm-unsafe-eval' (no 'unsafe-eval', packages/cli/src/utils/console.ts:851–852, off unless set), and the print driver's page.waitForFunction(PRINT_READY_PREDICATE, …) (chromium-driver.ts:303) passes a string, which Chromium refuses to evaluate: all 6 legs failed, including the untouched invoice leg, with The print engine failed at the local executable …: page.waitForFunction: EvalError: Evaluating a string as JavaScript violates the following Content Security Policy directive …. CI skips this file (no console bundle), so main has not seen it. Reported to the seat as a defect for its own card; the readings above were taken with OS_PAGE_REACT_ENABLED=on.
  • examples/app-showcase/src/ui/pages/invoice-document.page.ts:12–14 still says the invoice's print page is its default record page "because the console renders a record on its default record page, so that is the page a record prints on". That reason is no longer the only option after this PR (the page may stay the default by choice). It is a comment in a private package, outside the claim's file surface: left for the seat.

Verification

Head 25f758a5e. Local subset derived by node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on this diff (89 commands), each run with its exit code captured before any pipe, reconciled with --ran:

✓ dispatch-gates --ran: 89 derived famil(ies) accounted for — 88 run, 1 NOT-MEASURED (1 DERIVED from a recorded exit 3).
  • 88 exit 0. Two of them first exited 3 on their prerequisite and were re-run after meeting it: check-plugin-teardown-shape.mjs --self-test (positive control 621a4876 fetched into the shallow clone; then 48 cases pass), and spec check:skill-examples (after building @objectstack/client-react; then 266 prose examples type-check).
  • NOT MEASURED: pnpm check:dual-build-cjs-loads, reason: it needs every package's dist (no packages/plugins/knowledge-memory/dist …) and this container built only the dogfood closure. CI runs it.
  • Not local, CI's: pnpm lint repo-wide (eslint on the touched files: exit 0), test:repo (the diff touches no vitest.repo-tests.json project), the full dogfood battery (no ! and no (narrowing) arm).
Every local command and its exit code
node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-adr-links.mjs :: exit 0
node scripts/check-adr-links.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-doc-route-spelling.mjs --advisory :: exit 0
node scripts/check-doc-route-spelling.mjs --self-test :: exit 0
node scripts/check-docs-section-name.mjs :: exit 0
node scripts/check-docs-section-name.mjs --self-test :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-issue-citations.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs :: exit 0
node scripts/check-plugin-teardown-shape.mjs --self-test :: exit 0
node scripts/check-section-landing-index.mjs :: exit 0
node scripts/check-section-landing-index.mjs --self-test :: exit 0
node scripts/check-tenant-audit-census.mjs :: exit 0
node scripts/check-tenant-audit-census.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.mjs --self-test :: exit 0
node scripts/docs-audit/check-affected-docs.mjs :: exit 0
node scripts/docs-audit/check-drift-comment.mjs :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --filter @objectstack/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0
pnpm --filter @objectstack/spec run check:docs :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:skill-examples :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:corpus-claim-drift :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:doc-anchors :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:doc-frontmatter :: exit 0
pnpm check:docs-audit-scope :: exit 0
pnpm check:docs-redirects :: exit 0
pnpm check:docs-single-h1 :: exit 0
pnpm check:docs-spec-enumerations :: exit 0
pnpm check:docs-transcript-drift :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 3
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:issue-citations :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:objectql-double-limit :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-object-tenancy-census :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:published-readme-links :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:react-page-adapter-contract :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:role-word :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:slot-lookup :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:vendor-version-stamps :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0

Human Minimum

  • 1. Schemas start as Zod; packages/spec holds no runtime logic. (No packages/spec change.)
  • 2. Every name I added uses the casing for its kind.
  • 3. No formatter ran; eslint passes on the touched files (repo-wide pnpm lint is CI's).
  • 4. One issue, one branch off an up-to-date main, one pull request.
  • 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset.
  • 6. packages/spec touched: n/a, not touched.
  • 7. No hand edits to content/docs/references/, packages/*/CHANGELOG.md or content/docs/releases/.
  • 8. Title, body and comments are in English (the 维护者速读 block is the lane's required Chinese summary).

Generated by Claude Code

…on the console's named record-page route

A record prints on any record page of its object that declares `print`. The
default record page keeps the console's record route; any other print page
renders on `.../record/ID/page/PAGE` (RECORD_NAMED_PAGE_ROUTE). The page check
reads a record page the way the console's `isRecordPageOf` does (type
'record' and that object), so the server prints exactly the pages the named
route draws. Docs and a changeset follow.

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

github-actions Bot commented Oct 27, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-print, touching 5 documentable anchor(s).

13 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/api/error-catalog.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/api/error-handling-client.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/api/error-handling-server.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/automation/hooks.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/automation/jobs.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/automation/webhooks.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/data-modeling/drivers.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/permissions/system-context.mdx (via printRecordToFile (symbol, a method of class PrintService))
  • content/docs/protocol/kernel/error-handling.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/protocol/objectql/types.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/ui/forms.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/ui/print-pages.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))

⛔ 5 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17/17-1.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/releases/v17/17-5.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/releases/v17/17-6.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/releases/v17/17-7.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))
  • content/docs/releases/v18/18-2.mdx (via VALIDATION_ERROR (literal, a string literal in refuseUnlessPrintPage))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 55 of 207 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 — 4 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 079deea822a0bba4d7cfff58acb55eaaad58add0 → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 220dfce3d4eccba6ffb43686df31eb3177432c9a — the merge of head 7c5ee8b24eb602bec66f40b3c0113734c95b7cba into base 079deea822a0bba4d7cfff58acb55eaaad58add0, 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 220dfce3d4eccba6ffb43686df31eb3177432c9a && git checkout 220dfce3d4eccba6ffb43686df31eb3177432c9a
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 079deea822a0bba4d7cfff58acb55eaaad58add0 7c5ee8b24eb602bec66f40b3c0113734c95b7cba && git checkout -B drift-repro 079deea822a0bba4d7cfff58acb55eaaad58add0 && git merge --no-ff 7c5ee8b24eb602bec66f40b3c0113734c95b7cba

node scripts/docs-audit/affected-docs.mjs --json 079deea822a0bba4d7cfff58acb55eaaad58add0

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

…not because only the default prints

Co-authored-by: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEkQ5kGuMFreHR7gCGkpxL
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: TIER_DEFAULT
Head-sha: 25f758a5e43c80bb0ba4e0b7754f23cc6745f5cc
Local-runs: none

PR #26662 (card #26583, ruling 6439306670 letter A, claim 6440220432, dev report 6441342414), reviewed on the declaration face at 2026-10-27T12:12Z. Inputs: the card body and its five comments, the PR body and file list, git diff origin/main...25f758a5e (7 files, +329/−26, merge-base c739de755c), cited code read with git show 25f758a5e:PATH, and the head's check-runs. Rendered by the seat's subagent.

Scope (TIER_DEFAULT, whole diff): .changeset/26583-print-named-page.md, content/docs/ui/print-pages.mdx, packages/qa/dogfood/test/print-named-page.fixture.ts, packages/qa/dogfood/test/print-service-named-page.dogfood.test.ts, packages/qa/dogfood/test/print-service-render.dogfood.test.ts, packages/services/service-print/src/print-service.test.ts, packages/services/service-print/src/print-service.ts. No packages/spec/**, no governed path, no ADR: the committed tree carries no schema face and no rules face, so the tier is the one the Clause-②: yes line alone owes.

Gate verdicts on this head, as read at 2026-10-27T12:12Z (47 check-runs): 21 success, 10 skipped, 14 cancelled, 2 failure. The two failures are the aggregates TypeScript Type Check and Lint & Repo Gates, whose only failed step is "Verify every … lane succeeded", started 2026-10-27T12:04Z, one minute after the dev pushed 7c5ee8b24e to the branch at 2026-10-27T12:03Z: the workflow's concurrency cancel took the lanes (Test Core 1–6/8, Build Docs, every Type Check · lane, Lint · ESLint, Lint · docs & code guards, Lint · CI, release & engine gates, Lint · scoped · slot lookup & corpus) and the aggregates then reported the missing lanes as failure. No lane failed on its own. Green on this head: Build Core, Check Changeset, Governed Surface Queue Guard, PR-body guards, Lint · PM dispatch-gates, Lint · scoped · tooling self-tests, Dogfood Regression Gate, Spec property liveness, Check PR Size, Check Documentation Links, Flag docs affected by code changes, Test Core 7/8 and 8/8, Plan Test Core shards, Auto Label, filter, Test UI. So the derived type-check, lint and test-core families have NO conclusion on 25f758a5e; on the newer head 7c5ee8b24e (same diff plus a 4+/3− docblock in examples/app-showcase/src/ui/pages/invoice-document.page.ts) they read 30 success, 10 skipped, 5 in_progress, 0 failures at the same minute. The dev's local run of the derived subset at 25f758a5e: 88 of 89 families exit 0, check:dual-build-cjs-loads not measured (CI's). The seat adopts this record on 7c5ee8b24e once its lanes finish, or re-renders; nothing in this record depends on the one-comment delta.

① Derived judgments

Every accept-set and public-surface change the diff implies, read in the cited code:

  1. The page refusal narrows to two arms — right. refuseUnlessPrintPage (print-service.ts:379–400) keeps RESOURCE_NOT_FOUND/404 for an unknown name, then VALIDATION_ERROR/400 unless isRecordPageOf(page, req.object), then 400 unless page.print is set; the third arm on main (defaultRecordPageObject(page) !== req.object → 400 "is not the default record page") is deleted. This is exactly ruling A's "two loud refusals". The check still runs before the record read, the mint and the browser (step ③ of printRecordToFile, print-service.ts:302–304; the function now returns the page the URL is built from). The 400 message gains the parenthesis "(type: 'record', object: 'X')"; code and status are unchanged, so no wire vocabulary moves.

  2. Server predicate equals the console's — right, and no wider. Server: isRecordPageOf(page, object) = page.type === 'record' && page.object === object (print-service.ts:187–189), asked only after a non-object page was refused 404 and after validateRequest (:436–446) made req.object a non-empty string. Console: isRecordPageOf in ui/packages/react/src/hooks/usePageAssignment.ts:94–98 is the same two comparisons behind a null/empty guard the server has already satisfied; RecordDetailView.tsx:780–792 asks it of the page found by name in useMetadata().pages for the named route and sets namedPageState = 'refused' otherwise, with no fallback (:794–808: a refused or pending name draws neither the resolved page nor the synthesized default). Both sides read one store: the console's list is getMetaItems('page') → readFlattenedMetaItems → engine.registry.listItems('page') served raw (protocol.ts:9368–9380, 9442–9460; no type fill on the serve side, the only fills are the save door :20924 and stored-row hydration :6429), and the server reads engine.registry.getItem('page', name) on the objectql service (print-service-plugin.ts:85). So the set the server accepts is the set the console's named route draws: a type: 'record' page bound to the request's object. Note for the seat (unchanged by this PR, not a finding): on the plain record route the console picks the object's default by defaultRecordPageObject over its served order (usePageAssignment.ts:205), so under the two-defaults boundary the hook itself documents (objectstack#23213) the paper could be the other default; the ruling keeps the record route for the default page, and main had the same exposure.

  3. The render URL — right. printPageUrl (print-service.ts:205–210): CONSOLE/apps/{app}/{object}/record/{recordId}, each segment encodeURIComponent-ed, is the whole URL when defaultRecordPageObject(page) === req.object (after check 2 that is isDefault === true exactly); otherwise /page/{encodeURIComponent(req.page)} is appended. That is the console's RECORD_NAMED_PAGE_ROUTE = ':objectName/record/:recordId/page/:pageName' (AppContent.tsx:280, mounted at :1449 on RecordDetailView, beside :objectName/record/:recordId at :1443). The unit rows pin both routes and the encoding (inv%201, doc%2F1, print-service.test.ts:408–428); an explicit isDefault: false takes the named route (:420), which the console also draws. The filename stays {page}-{recordId}.pdf.

  4. Removed accept arm on main had no hidden width. main's first check was page.object !== req.object and its third required defaultRecordPageObject(page) === req.object (isDefault: true, type record-or-absent). So main already refused a type: 'app'/'home' page and a non-default page; the head's added refusal rows (print-service.test.ts:280–288: another object, no print default and non-default, type: 'home' default, type: 'app' non-default, no type default and non-default) describe the new set correctly. The one shape main accepted that the head refuses is the typeless default print page; judged in ③.

  5. Docs — right. content/docs/ui/print-pages.mdx loses "A print page that is not the default is refused", says which route each page renders on, the example InvoiceDocumentPage drops isDefault: true (now a non-default print page beside a working page, which is the capability), and refusal row 3 reads "not a record page of the object (type: 'record', that object), or declares no print". The repo has no translation of this page (content/docs/ui/print-pages.mdx is the only print-pages*). The duplicated row number 3 in the refusal table predates the PR.

  6. Dogfood — right, with one CI limit named. print-service-named-page.dogfood.test.ts saves the brief through protocol.saveMetaItem (the real save door), keeps showcase_project_detail as the default working page with no print, and asserts the named URL, the archive as the requester, the 400 for the working page and the 400 for the brief asked of showcase_invoice, nothing minted on either. print-service-render.dogfood.test.ts adds a real-Chromium leg on the named route; that file is skipped in CI (no console bundle), so its new leg is measured only by the dev's local run (OS_PAGE_REACT_ENABLED=on, PDF text "Project brief / Brief / PROJECT NAME STATUS …"). The stand-in file runs in the Dogfood Regression Gate, green on this head.

  7. Changeset and declarations — right (② below). No new export, no new key, no request-body change: PrintRecordRequest already carries page.

② Semver level

.changeset/26583-print-named-page.md: '@objectstack/service-print': patch, with Clause-②: yes (widening) and Metadata-protocol: none line-initial in the changeset body and in the PR body. The diff publishes from one released package, @objectstack/service-print (src/print-service.ts); the docs, dogfood tests and changeset publish nothing. Metadata-protocol: none is measured right: no packages/spec edit, no key added, refused or re-meant, no default changed; print and isDefault keep their meaning and the request already names its page. Clause-②: yes is right: the public door printRecordToFile / POST /api/v1/print/records answers 201 where it answered 400. The arm is (widening) on every authoring door (③ flag 3 holds the one raw-body shape and leaves the re-arm to the seat). The level follows AGENTS.md:1091–1093: none takes patch, and a non-protocol change, widening or breaking, ships patch; so patch is the right bump, and minor would be wrong. Check Changeset is green on this head.

Clause-②: yes (widening) — measured on the diff; the raw-body shape in ③ flag 3 is the seat's to re-arm.

③ Boundary flags

open_questions: none in the dev report. Three out_of_scope_findings and one dev-acceptance note, each answered:

  1. CSP / unsafe-eval on a default deployment (class a, public door). Outside this PR: chromium-driver.ts:303 passes PRINT_READY_PREDICATE as a string to page.waitForFunction, which the console's default policy (packages/cli/src/utils/console.ts:851–852, no 'unsafe-eval' unless OS_PAGE_REACT_ENABLED) refuses, so every server print fails on a default environment at main too (the untouched invoice leg included). Not caused or widened by this diff, and not fixable inside the claim's file surface without touching the driver. Escalated to the seat for its own card (the dev reported it; it also blocks hotcrm#2312 on a default deployment even after this PR lands). It does not change this verdict.

  2. examples/app-showcase/src/ui/pages/invoice-document.page.ts:12–14 says the invoice print page is the default "because … that is the page a record prints on". Answered by the dev after this head: 7c5ee8b24e (pushed 2026-10-27T12:03Z) rewrites that docblock, 4+/3−, no code change. It is outside the claim's file surface but on no governed path and publishes nothing (examples/ is private). The seat adopts this record on that head or re-renders; this record names 25f758a5e only.

  3. The typeless default print page — measured; one door class exists, and the seat decides the arm. The shape: a registry page body with no type, isDefault: true, object: X, print set, asked for an X record. main accepted it (its third check read defaultRecordPageObject, which treats an absent type as 'record', packages/spec/src/ui/default-record-page.ts:67–73); the head refuses it 400 (isRecordPageOf reads type exactly; pinned at print-service.test.ts:287–288). Doors that cannot produce it, each filling PageSchema's declared default (page.zod.ts:1943, PAGE_TYPE_DEFAULT): defineStack (stack.zod.ts:5341ff parses with ObjectStackDefinitionSchema.safeParse and returns result.data; pages: z.array(PageSchema) at :725), so every os build artifact and os validate; the artifact loader (packages/metadata/src/plugin.ts:1321, ObjectStackDefinitionSchema.parse); the metadata save door (protocol.ts:20924, withDeclaredPageTypeDefault); stored-row hydration (protocol.ts:6429 convertStoredItem, which every stored-row registerItem at :17861, :18344, :18908 reads through); and the file-system metadata sync, which registers no pages at all (objectql/src/plugin.ts:3806, metadataTypes = ['object','view','app','flow','hook']). Doors that CAN: (a) a manifest built in code and handed to manifest.register() / ql.registerApp() without defineStack (objectql/src/plugin.ts:813, :1138) → registerMetadataCollections (engine.ts:8726, :9014) → registry.registerItem('page', raw), where the registry's page seam (registry.ts:5307–5323) judges misplacedDefaultPageRefusal and defaultRecordPageObject and parses nothing — the spec docblock itself names "the pages a package registers without a parse"; (b) the metadata facade register('page', name, raw) (metadata-facade.ts:105–122, no parse); (c) a raw registry.registerItem (the dev's case); (d) an authored package install, which stores the manifest "exactly as handed" (protocol-package-install.ts:228 parses for declaration only; registry.installPackage at registry.ts:6122ff registers no page items) and whose pages reach the registry at the next boot through manifestService.register() → registerApp (runtime/src/domains/packages.ts:2658), raw. For every one of those doors the served list shows the console the same typeless body (readFlattenedMetaItems serves registry.listItems raw), so the console's record route never drew that page on main (its object lookup filters with isRecordPageOf, usePageAssignment.ts:168, and synthesized the default instead) and its named route refuses it: the 201 main answered put a different document on paper. In-repo code-registered default record pages (sys_user, sys_organization, sys_approval_request) all carry type: 'record' and declare no print, so no shipped page is in the shape. Reading: the set of requests that print the page they name only widens; the set of requests answered 201 loses one shape, reachable only through a raw-body door no author of a definePage/defineStack/os build/Studio-save page passes, and whose 201 was a misprint. The dev declared yes (widening) and said so in the PR body. Escalated: if the seat reads the 201→400 flip on the raw-body shape as a narrowing, the PR is re-armed (narrowing) and moves to the narrowing face at CONTRACT_REVIEW_TIER, which this record does not serve; making the server read an absent type as 'record' instead would make it wider than the console and print a not-found state or a synthesized page, so that is not the fix.

  4. print-service-render.dogfood.test.ts is CI-skipped, so the named-route real render is pinned only by the dev's local reading; the stand-in dogfood and the unit rows pin the URL and refusals in CI. Accepted as is: the file was CI-skipped before this PR for the same reason.

  5. Head moved during review. The PR head is 7c5ee8b24e since 2026-10-27T12:03Z; this record names 25f758a5e, whose type-check, lint and test-core lanes were cancelled by that push (gate section above). The seat adopts on the newer head when its five in-progress runs conclude, or re-renders.

Seat questions, one line each:

  • Predicate: yes — the server's isRecordPageOf is the console's two comparisons over the same registry (usePageAssignment.ts:94–98, asked at RecordDetailView.tsx:780–792), behind guards the server has already satisfied; it accepts exactly what the named route draws, no wider.
  • Narrowing: a typeless page CAN enter the registry through raw-body doors — a code-built manifest via manifest.register()/ql.registerApp(), the metadata facade's register('page', …), a raw registry.registerItem, and an authored install manifest re-registered at boot — while every parsing door (defineStack, artifact load, save door, stored-row hydration) fills the default; on that shape main printed the console's synthesized default page, not the page named, so the seat decides whether a misprinting 201 turned 400 re-arms the PR.
  • URL: confirmed — …/apps/APP/OBJECT/record/ID/page/PAGE with every segment encodeURIComponent-ed, matching RECORD_NAMED_PAGE_ROUTE; the default page (isDefault: true) keeps …/apps/APP/OBJECT/record/ID, both pinned in the unit rows.
  • Declarations: Clause-②: yes right, (widening) measured on every authoring door with flag 3 escalated, Metadata-protocol: none right (no spec key, value or default moves), and patch is the bump AGENTS.md:1091–1093 assigns to a non-protocol change.

Implemented-by: claude/project-thread-qp0ohr
Reviewed-by: session_01R1q2xdtYgU6FKrZ7LgjPCC

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对 · ACCEPT · domain:server seat 1 · os-project-manager · session_01R1q2xdtYgU6FKrZ7LgjPCC · 2026-10-27T12:22Z

Head 7c5ee8b24e, report 6441342414 (on #26583), claim 6440220432, contract review 6441673748 (TIER_DEFAULT, declaration face, PASS on 25f758a5e).

item reading
Shape Draft, base main, Fixes #26583 under ruling 6439306670 letter A. 8 files: print-service.ts and its unit test, two dogfood tests and a fixture, print-pages.mdx, a patch changeset for @objectstack/service-print, and one docblock in examples/app-showcase/src/ui/pages/invoice-document.page.ts. Clause-②: yes (widening) and Metadata-protocol: none appear in both the body and the changeset. No key is added, so the card reads p2, as the PR body says.
Fix refuseUnlessPrintPage keeps the 404 for an unknown page and two 400s: not a record page of this object, and no print. The default page renders the plain record route; any other print page renders …/apps/APP/OBJECT/record/ID/page/PAGE (RECORD_NAMED_PAGE_ROUTE), each segment encoded. The server's isRecordPageOf is the console's own predicate (type === 'record' && object === OBJECT) over the same registry, so the server prints exactly the pages the named route draws.
Head delta 7c5ee8b24e adds only the showcase docblock fix (4+/3−) on top of the reviewed 25f758a5e. The seat widened the claim surface by that comment, at the dev's own out-of-scope note: the old text said the default page is the only page a record prints on, which this PR makes untrue. The contract review does not depend on it.
Clause-② boundary (seat call) The review escalated one shape: a page body with no type, marked isDefault: true and declaring print, can reach the registry through a raw code path (a code-built manifest, registry.registerItem, the metadata facade) though every authoring door fills type's schema default. main answered 201 for it but rendered the plain record route, where the console filters with the same isRecordPageOf and draws the synthesized default page, so that PDF was not the requested page. The head answers 400. The seat keeps yes (widening): the declared page contract always carries type (PageSchema defaults it), the refused body is outside that contract, and what it refuses was a misprint, not a print.
Evidence CI green on head 7c5ee8b24e (37 success, 10 skipped, 0 failed). Governed check: NOT governed, 362 changed lines. Red on main, green at head: 3 unit rows and 3 dogfood legs, including a real Chromium render. 89 derived gates: 88 exit 0, 1 NOT MEASURED (check:dual-build-cjs-loads, which CI runs). The cancelled lanes on 25f758a5e were the concurrency cancel from the docblock push, not failures.
Out of scope Every server print fails under the console's default CSP (the driver waits with a string predicate that needs 'unsafe-eval'). This is not this PR's: it fails the same on main's untouched invoice leg. Filed as #26664. hotcrm #2312 needs both.
Landing 「按照 pm-dispatch 规则复核过的都授权」 (Leehom, 2026-10-25T11:35Z): ready and auto-merge through the relay, once every check on this head is green or an expected skip.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 27, 2026 12:23
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 27, 2026 12:23
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 27, 2026
Merged via the queue into main with commit 5dcbdb4 Oct 27, 2026
48 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-qp0ohr branch October 27, 2026 12:46
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/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

service-print: print a record on a named print page, so an app need not make its everyday record page the printed document (hotcrm REQ-0008 quote print)

2 participants