Skip to content

feat(service-print): the print door — POST /api/v1/print/records prints a record as the caller; the showcase invoice declares Generate PDF - #24194

Merged
objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22270-print-record-door
Oct 17, 2026
Merged

objectstack-fleet[bot] merged 10 commits into
mainfrom
claude/issue-22270-print-record-door

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Fixes #22270

Clause-②: no

Print page ④ of #8346 (ruling of record 6051470224): the door a record-page "generate PDF" action calls, and one declared action on the showcase invoice. The PDF is attached by card ③'s service (#22269, landed as b15f8a1997), once, as the caller; this PR adds no second attach.

Dispatched by the domain:services seat 1 (seat post #6021), claim 6234883974, session session_01Nk17Z26X5DB59iQztDBADH. The grade is the maintainer's 「PDF 要赶 18.0 正式版」.

What it adds

  • POST /api/v1/print/records (packages/services/service-print/src/print-routes.ts), mounted by PrintServicePlugin on the kernel's HTTP server at kernel:ready, on every placement path. The JSON body is the in-process request, { app, object, recordId, page, filename? }. The door resolves the caller with the platform's one resolver (resolveAuthzContext), assembles the execution context as the REST face does (assembleExecutionContext), and hands that context and the request to IPrintService.printRecordToFile. It answers 201 with { fileId, attachmentId, size, filename }. It holds no attachments handle, so it cannot attach.
  • The showcase composes PrintServicePlugin, and showcase_invoice declares showcase_generate_invoice_pdf: a type: 'api' action, target: '/api/v1/print/records', recordIdParam: 'recordId', bodyExtra: { app, object, page } naming the invoice's default record page showcase_invoice_document (the page that declares print). The zh-CN label and success message are translated at birth (the example's i18n ratchet).
  • Route ledger print-route-ledger.ts (one server-only row) with its package-local conformance test, read by the dogfood live-mount parity gate beside the other ledgers.

The door's order, and what it relays

Door refusals, each before the service is asked, so nothing is minted and no browser is started:

  1. The caller. A permission store that cannot be read relays its own SERVICE_UNAVAILABLE / 503; an API key its lifecycle refuses relays its own 403 and code.
  2. Anonymous: UNAUTHENTICATED / 401 (the shared anonymous floor).
  3. A sign-in held by an authentication policy: 403 with the policy's own code (evaluateAuthGate, as the other doors).
  4. An impersonation session: PERMISSION_DENIED / 403, with a message saying the print was refused because the session is an impersonation. No new error code.
  5. Any query parameter, or a body field outside the request's closed set: VALIDATION_ERROR / 400. A misspelled field is refused, never ignored.

Then every refusal card ③ declares keeps its code and status unchanged: PERMISSION_DENIED / 403, SERVICE_UNAVAILABLE / 503 (no render driver configured), RESOURCE_NOT_FOUND / 404, VALIDATION_ERROR / 400 (a page without print), RECORD_NOT_FOUND / 404 (a caller who cannot read the record), FILES_DISABLED / 403, ATTACHMENT_PARENT_ACCESS / 403, TIMEOUT / 504, INTERNAL_ERROR / 500, PAYLOAD_TOO_LARGE / 413. A 4xx keeps the service's message. A 5xx answers with the door's own sentence for its status and the service's text goes to the server fault log (declaredRefusalMessage / sendServerFault, the shared rule every door applies to a declared 5xx, because a 5xx text can carry what the engine said about the host). An undeclared throw is INTERNAL_ERROR / 500.

Door decisions (the seat's ruling, applied)

  • Impersonation — premise measured first, and it holds. The request the door receives tells an impersonation session apart without any packages/spec change: the session the auth service reads for the request carries impersonatedBy (better-auth's admin plugin field, which @objectstack/plugin-auth stores as sys_session.impersonated_by). Measured statically (the admin plugin's session schema returns the field; plugin-auth's customSession passes session through unchanged) and on a real showcase boot with a local-only scratch test that was deleted afterwards: an impersonation session's in-process session read carried the impersonating administrator's id, and a user's own session carried null. The door reads the mark off the same session read that admitted the caller, and only for a session credential (a key-admitted request is judged by the key alone). Described abstractly here on purpose.
  • Audit: no new audit row type at the door. Stated limit: a print's minted session is recorded by the auth service's existing sign-in and sign-out writers, and the archive's own writer stamps (sys_file.owner_id, sys_attachment.uploaded_by) attribute the print to the caller.
  • Stated limit on detection: an impersonation is recognised by that session mark. An IAuthService provider that marks impersonation some other way is not recognised. Its reach is what card ③ already ships.

Why this path

/api/v1/print/records, under the versioned API prefix as /api/v1/storage is, plural per the REST naming rule, and mirroring printRecordToFile. A body-addressed route rather than /:object/:recordId because both console api handlers at the pin (useConsoleActionRuntime.apiHandler, RecordDetailView.apiHandler) seed recordIdParam into the body and merge bodyExtra, while {field} path interpolation exists on one of them only. The route is plugin-mounted (the storage and settings pattern), so one owner serves it, and only where print is composed.

Measured: the console at the pin (.objectui-sha 5e15928b)

  • A type: 'api' action with an absolute target is POSTed as JSON (credentials: 'include', bearer added for a same-origin target): the collected params, then recordIdParam seeded from the row or the page record, then bodyExtra. A non-2xx answer becomes the action's error with error.message from the envelope, so each refusal above reaches the user as a message.
  • ⚠️ Mechanism hypothesis disproved: the console draws record-header actions in a page's page:header only, and a print page may not hold one (PRINTABLE_PAGE_COMPONENT_TYPES refuses page:header). The invoice's default record page is a print page since b15f8a1997, so its record_header actions — this PR's Generate PDF, and the existing Submit for Sign-off — are not drawn there, and neither are the system Edit / Share / Delete actions. The action is therefore also placed at list_item (the invoice list's row menu), where the console draws it today. The record-page host is a console change: reported to the seat for objectui, not made here.

Landing sites outside the claim's file surface, and why

  • packages/qa/dogfood/test/route-ledger-live-mount-parity.dogfood.test.ts (2 lines): the showcase now mounts the door, and that gate's "every live mount is ledgered" direction reds on an unledgered mount. The print ledger joins its union.
  • scripts/check-route-envelope.mjs (one MODULES row): a *-routes.ts module must be declared there; declared at 0 / 0 / 0 (every body through the shared pair).
  • content/docs/ui/print-pages.mdx: the door and its action, documented.
  • pnpm-lock.yaml (6 lines): two workspace links, @objectstack/types for service-print and @objectstack/service-print for the showcase. packages/services/service-print/package.json and its vitest.config.ts alias are inside the package, outside src/**.

Clause-② measured

dist/index.d.ts of the built @objectstack/service-print exports the same 14 names as before (src/index.ts is byte-identical to the base). The class declaration gains one private mountDoor; member. No new plugin option, no schema change, no new error code.

Tests (head 021f86051)

  • pnpm --filter @objectstack/service-print test: 8 files, 109 tests passed. typecheck exit 0 (both new test files are in the tsc program, counted with --listFiles).
  • print-routes.test.ts (25 tests) drives the mounted handler with the real resolver and the real PrintService over recording doubles: the success path stores exactly one sys_attachment row for the print (and none through the system writer), the context the door built reaches the mint by identity, and every refusal is asserted by code + status on an envelope that parses as ApiError.
  • examples/app-showcase: 36 files, 431 tests passed, typecheck exit 0, objectstack validate passed (at 69f5ac29e; the last commit changed one comment in print-routes.ts). The new invoice-generate-pdf-action.test.ts (4 tests, re-run at 021f86051) sends the body the console builds from the declaration to the door the showcase's own plugin mounts: with no engine configured it is answered SERVICE_UNAVAILABLE / 503.
  • Dogfood, local (at 69f5ac29e): route-ledger-live-mount-parity, print-service-archive, anonymous-door-sweep: 3 files, 37 tests passed (the door is reachably mounted and ledgered, and answers an anonymous probe 401).

Ablation, each through scripts/ablation-replace.mjs (anchor hit 1 → 0, blob changed), each red, each restored with the blob equal to HEAD and git diff HEAD empty: the impersonation guard (an impersonated print answered success), the anonymous floor (PERMISSION_DENIED / 403 instead of 401), the body's closed set, the query refusal, and card ③'s no-engine (INTERNAL_ERROR / 500 instead of 503), no-print and read-as-caller guards (each print then answered success).

Gates

Derived with node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack at 021f86051: 128 commands, all run locally on that head, all exit 0 (each exit captured before any pipe). --ran reconciliation: 128 derived, 128 run, 0 NOT-MEASURED, 0 UNRUN, every family carrying its exit code. The earlier run on 86e1da243 was red in two real places, both fixed here: check:i18n-coverage (the showcase's untranslated count grew 414 → 416, the new action's label and message; now translated) and check:test-source-alias (the showcase's new import of @objectstack/service-print; now aliased to source). Two more answered PREREQUISITE NOT MET (exit 3) until the workspace was built, then 0. CI runs the full set on this PR.

Patch round 1 (head 8e8cfc60a)

  • Transport ledger. print-routes.ts calls resolveAuthzContext, so it is now a guarded row of TRANSPORT_LEDGER in packages/core/src/security/authz-store-unavailable.test.ts (its caller-resolution catch relays the outage with its own status and code). @objectstack/core#test:repo was red without the row (10 transports against 9 rows) and is green with it. That file is the engine lane's; one row, declared by the seat.
  • Impersonation refusal, pinned against the real auth service. packages/qa/dogfood/test/print-door-impersonation.dogfood.test.ts, on a real showcase boot with no print engine: an administrator's own session is not refused for impersonation (the service answers SERVICE_UNAVAILABLE / 503), and an impersonation session that administrator starts is refused at the door (PERMISSION_DENIED / 403, the message naming impersonation). The session mark comes from the composed auth service, so a change that drops or renames it reds this pin instead of turning the refusal off. Ablated once through the built package: with the guard disabled the pin answered 503 instead of 403; restored with git diff HEAD empty and the marker gone from dist/.
  • Merged main; on this head the touched suites (service-print 109, showcase 431, core repo 85, dogfood 4 files / 38) and the re-derived 130 gates are green locally, and CI is green.

Acceptance notes


Generated by Claude Code

@github-actions github-actions Bot added size/xl dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation tests tooling labels Oct 17, 2026
@github-actions

github-actions Bot commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/service-print, touching 29 documentable anchor(s). ⚠️ 3 changed file(s) yielded no anchor (packages/services/service-print/README.md, packages/services/service-print/package.json, packages/services/service-print/vitest.config.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

39 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 7d6ec91d2f89e834fc5b82183a95cb3c5427a882.

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

What this run could not see
  • 3 changed file(s) yielded no anchor (packages/services/service-print/README.md, packages/services/service-print/package.json, packages/services/service-print/vitest.config.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: PERMISSION_DENIED (literal, 39 pages)
  • 8 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 — 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 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 7d6ec91d2f89e834fc5b82183a95cb3c5427a882

⚠️ 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 7d6ec91d2f89e834fc5b82183a95cb3c5427a882 → 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

席位核对

ACCEPT · domain:services seat 1 (#6021) · marchtian · session_01Nk17Z26X5DB59iQztDBADH · 2026-10-17T17:47Z · head 8e8cfc60af. It covers round 0 (021f86051, read by the seat at 6236835345 on #22270) and patch round 1. It lands through the merge queue once every check on this head is green.

  • Shape: a draft PR to main. Its first line is Fixes #22270, and that is the only closing keyword in the body (the seat's ruling at 6236835345). Clause-②: no is at a line start in the body and in the changeset. Card and PR assignee: marchtian. Not governed: check-governed-merges reads 0 of 21 paths, and 1322 changed lines ≤ 3000.
  • Contract review (export: PDF / print document generation — re-planned for v18, superseding the #1301 NOT_PLANNED closure #8346's ruling: "Each card carries its refusal cases and the contract review"): the diff touches none of the four contract faces. There is no Clause-②: yes declaration and no narrowing declaration, nothing under packages/spec/src, and no governed text. The review is therefore CI plus this seat's read. Clause-②: no was measured by the dev on the built dist/index.d.ts: the same 14 exported names as the base (src/index.ts is byte-identical), with one private mountDoor; member added to the class. There is no new plugin option, schema or error code.
  • The door, read by the seat (print-routes.ts): POST /api/v1/print/records, mounted at kernel:ready on the kernel's http-server. With no HTTP server it logs one warn and mounts nothing. Its order:
    1. the caller, through resolveAuthzContext. An authorization-store outage or an API-key lifecycle refusal is relayed with its own code and status.
    2. anonymous → UNAUTHENTICATED / 401;
    3. a held sign-in → 403 with the gate's code;
    4. an impersonation session (session.impersonatedBy on the session read that admitted the caller; session credentials only) → PERMISSION_DENIED / 403;
    5. any query parameter, or a body field outside { app, object, recordId, page, filename } → VALIDATION_ERROR / 400;
    6. then printRecordToFile(request, context) → 201 with { fileId, attachmentId, size, filename }.
  • The card's refusal cases, relayed with the service's own code and status: no render driver → SERVICE_UNAVAILABLE / 503, with the door's own sentence and the reason in the fault log; a page without print → VALIDATION_ERROR / 400; a caller without read access → RECORD_NOT_FOUND / 404. These are pinned in print-routes.test.ts, and the no-engine 503 again on a real boot by the dogfood pin.
  • Patch round 1, read by the seat:
    • TRANSPORT_LEDGER gains 'packages/services/service-print/src/print-routes.ts': 'guarded', classified honestly: the caller-resolution catch relays isAuthzStoreUnavailableError with its status. RED without the row (10 transports against 9 rows), GREEN with it.
    • print-door-impersonation.dogfood.test.ts boots the real showcase with the admin plugin on (OS_SCIM_ENABLED). The admin's own session reaches the service (503), and the impersonation session the admin starts through /auth/admin/impersonate-user is refused at the door (403, the message naming impersonation). Ablated through dist: with the guard disabled, the pin answered [SERVICE_UNAVAILABLE, 503]. Restored: blob equals HEAD, git diff HEAD empty, and the marker is absent from dist/.
    • PR perf(plugin-auth, plugin-hono-server): an authenticated request reads 8 rows in 4 round trips before its handler, down from 11 in 7; /auth/me/* resolves grants once #24185 adds its own row to the same object. git merge-tree of the two heads is clean, and the merged ledger carries both rows.
  • Prose faces, each sentence checked against the diff:
    • The changeset's bullets match the door: the route, the body, the 201 shape, the refusal order and codes, "the route never attaches anything itself", the no-HTTP-server warn, and the stated limit on impersonation detection.
    • Its 5xx sentence, "A 5xx answers with the route's own sentence", is exact for every refusal print-service.ts throws, because printRefusal sets no refusal: true. The one exception, a 5xx whose producer declares refusal: true, is stated in the module header. Accepted as written.
    • content/docs/ui/print-pages.mdx "Print from the console" matches the same facts, and its defineAction example matches the showcase action's shape.
    • The service-print README's three lines match too.
  • Gates on this head: 37 checks succeeded and 5 were skipped by design (Console Pin Gate, Dogfood Regression Gate, Dogfood Verify CLI, Packed-tarball smoke, Temporal Conformance: the roster set). There were no failures. Locally: 130 derived families, 130 exit 0.
  • Accepted deviations:
    • The out-of-surface files declared at 6236835345: the route-ledger dogfood gate, check-route-envelope.mjs, print-pages.mdx, the @objectstack/types dependency, and the one packages/core row.
    • origin/main is 3 commits past the merge base, and the merge group re-runs the gates.
  • Out-of-scope findings:

Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

print page ④ of #8346: a record-page "generate PDF" action that calls the render service and attaches the archived PDF to the record

2 participants