Skip to content

print page ③ of #8346: a render service with one headless-Chromium driver renders a print page under the requesting user's principal and archives the PDF as a sys_file (M2) #22269

Description

@objectstack-fleet

Blocked-by: objectstack-ai/objectui#11958

Filed by the domain:spec seat 3 (seat post #18883, session_01RPo7FUd6bSnAfkWMAKi848). This is card ③ of the split that #8346's ruling of record 6051470224 orders (letter B′, maintainer 「8346 B′」). ⛔ Not a claim.

The ruling (verbatim, the clauses this card carries)

M2, the archive: a render service with one engine driver, headless Chromium, which renders the same page under the requesting user's principal (a short-lived credential; no system principal) and stores the PDF as a sys_file attached to the record, downloadable and usable as an email or flow attachment; a deployment without the driver configured reports it loudly at startup, it does not fail silently at the first request. ⛔ No in-process layout driver: a page model needs a browser to render.
③ the render service, the Chromium driver and the service-storage in-process archive entry (domain:services)

What it builds on

What this card does

  1. A render service with one driver, headless Chromium. It renders a bound print page for a record into a PDF.
  2. The requesting user's principal, through a short-lived credential. ⛔ No system principal: a user can never archive what they could not read.
  3. The archive: the PDF is stored as a sys_file attached to the record, through service-storage's in-process archive entry. It is downloadable, and usable as an email or flow attachment.
  4. Loud at startup: a deployment without the driver configured says so at boot, not at the first request.
  5. Refusal cases pinned: no driver; a page that does not declare print; a caller who cannot read the record.

⛔ No in-process layout driver (pdfmake class), per the ruling's Not-taken list.

Order

Card ① (#22158) landed through PR #22193, so this card is workable now. It may be built in parallel with ②; the output should match ②'s on-screen print rendering.

Reader who acts: the domain:services execution seat. The lane is named by the ruling; priority:p2 and target:v18 are inherited from #8346. The contract review is owed, per the ruling's "each card carries its refusal cases and the contract review".

Activity

  1. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 3 · 2026-10-08T11:27Z
    Session: session_01WkL6Eijt432S1Y7ekb6ovQ
    Account: os-bill (the seat's linked user as GET /user answers it; the card's assignee)
    Branch: claude/issue-22269-print-render-design
    Worktree: objectstack-issue-22269
    Domain: domain:services
    Seat: domain:services#1 (seat post #6021)

    Round 1 is measurement and design only. There is no production change and no PR. The only write is the os-dev-report comment. The seat picks the build shape from the report, or takes it to the maintainer where the ruling leaves it open, then claims the build round.

    Ruling of record: #8346 B′ (6051470224), card ③. The clauses are quoted in this card's body.

    To measure, at origin/main c8bb3c8d:

    Container & model: M, mode:subagent, model: opus (a measurement round; the contract review is owed at the build round, per the card)
    Clause-②: no

    • This round writes nothing to the tree. The build round's arm is decided from this report.
      Responsibility: this repository: no server-side render of a print page exists; #8346 ruling B′ card ③ asks for one | none: browser printing (card ②) only | every user who archives or attaches a document PDF of a record; target:v18
      Thread-read: none
      Serial constraints cleared: a read-only round.
  2. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
     "issue": 22269,
     "status": "needs_decision",
     "branch": "claude/issue-22269-print-render-design",
     "pr": null,
     "session": "session_01WkL6Eijt432S1Y7ekb6ovQ",
     "premise_still_valid": true,
     "summary": "Measurement and design round, complete; no production change, no commit beyond the empty branch at c8bb3c8d, no PR. The card's premises hold on objectstack c8bb3c8d: card 1 is on the base (merge-base --is-ancestor 326a90a4 c8bb3c8d exit 0; PagePrintSchema at packages/spec/src/ui/page.zod.ts:934), no server-side render exists (0 printToPDF / page.pdf / puppeteer / chromium.launch / pdfmake sites in packages non-test, control: 14 playwright/chromium hits in examples/app-showcase), service-storage has no in-process archive entry, and plugin-auth has no short-lived self-session mint. One premise is only half true: 'It may be built in parallel with card 2'. The driver, the credential and the archive can; the page binding cannot, because objectui at the .objectui-sha pin a58626c88 has no print route and no reader of PageSchema.print (0 hits vs control 7). A headless Chromium render to PDF is measured feasible here (playwright-core 1.63.0 already in the lockfile, and raw CDP). Status is needs_decision because eleven build-shape choices touch public contracts (credential seam, door, codes, driver library, config, composition, archive seam, size cap, principal kinds, the card 3/4 boundary, staging); each carries the four axes and a recommendation. Clause-2 for the build round is yes (widening) under every option set (new published package, new AuthManager method, new service-storage export); under the recommended shape only one optional IAuthService member lands in packages/spec.",
     "measurements": {
      "1_page_the_driver_renders": {
       "print_route_at_pin": "NONE at objectui a58626c88 (the .objectui-sha pin; blobless shallow fetch into the scratchpad, read-only). Readers of the print declaration (page.print / PagePrint / paperSize / repeatTableHeaders / pageNumbers) in packages+apps non-test: 0; control '.interfaceConfig' reads: 7. The console route table (packages/app-shell/src/console/AppContent.tsx:1064-1130) has page/:pageName (PageView) and :objectName/record/:recordId (RecordDetailView) and no print route; the only '/print/a3' strings are ActionRunner test fixtures (packages/core/src/actions/__tests__/ActionRunner.test.ts:389). Card 2 is objectstack-ai/objectui#11958, pm:on-hold, Restart-when: an installable @objectstack/spec exports PagePrintSchema (split record 6058277685 on #8346). Its body: NOT MEASURED (objectui API and issue page answer 403 'GitHub access to this repository is not enabled for this session').",
       "url_for_page_and_record": "No URL renders a NAMED page bound to a record at the pin. PageView (/apps/APP/page/PAGE, packages/app-shell/src/views/PageView.tsx:50-204) builds context { params } from the query string only, with no record context. RecordDetailView (/apps/APP/OBJECT/record/ID) renders the object's FIRST type:'record' page by declaration order (packages/react/src/hooks/usePageAssignment.ts:114-161); the hook takes opts.pageName, but RecordDetailView.tsx:501 calls usePageAssignment(objectName) without it, and wraps the page in record chrome (rail, discussion, approvals fallback). The console mounts at /_console (packages/cli/src/utils/console.ts:54), so the build round's URL is ORIGIN/_console/ plus card 2's print route. Request to card 2 (cross-card interface): a chrome-less route outside /apps/APP keyed by (object, recordId, page), plus a mounted-and-loaded marker. objectui already exposes the ADR-0054 C5 readiness predicate window.__objectui.idle / whenIdle() (packages/app-shell/src/observability/settleSignal.ts:1-20), but idle is also true before the first request, and PageView shows a spinner while the page type loads (PageView.tsx:77-89), so a print-root marker is still owed.",
       "how_the_route_authenticates": "A better-auth session. The console injects 'Authorization: Bearer TOKEN' from localStorage key auth-session-token on /api/ paths (objectui packages/auth/src/createAuthClient.ts:17, 233-256) and falls back to the cookie session on get-session (credentials include, :483-516); the server's bearer() is always on (plugin-auth auth-manager.ts:3038-3053). A same-origin browser context that carries the session cookie is therefore authenticated without any console change.",
       "what_the_build_round_waits_on": "The page-binding stage waits on this chain: (a) an installable @objectstack/spec exporting PagePrintSchema. npm latest is 17.7.0, published 2026-10-06, before #22193 merged; there is no next tag. A publish is the maintainer's act (Prime Directive 15). Then (b) objectui#11958 lands. Then (c) this repo bumps .objectui-sha and regenerates the SDUI manifest (node scripts/gen-sdui-manifest-node.mjs).",
       "server_side_render_without_console": "Not inside the ruling. 'renders the same page' and the card's 'so the PDF matches what the console prints' need objectui's page renderer, the only renderer of the page-block vocabulary (packages/spec/liveness/page.json _note: 'Renderers live in objectui'). A server-side block renderer would be a second renderer of the same vocabulary, the in-process layout driver class the ruling's Not-taken list excludes. Buildable without card 2: the driver, the credential, the archive and the refusals, tested against a fixture page."
      },
      "2_the_driver": {
       "libraries_measured": {
        "playwright-core": "Already in pnpm-lock.yaml at 1.63.0 (lock :8265, :14843; a transitive of @playwright/test, a devDependency of examples/app-showcase, lock :276-278). Zero dependencies, Apache-2.0, 14M installed, published 2026-09-04 (older than two weeks), engines node 20 or newer. It bundles Chromium revision 1243 (Chrome 153), yet drove the container's Chrome 141.0.7390.37 through executablePath. That version skew passed in this probe; it is not guaranteed.",
        "cdp_direct": "No library: Node 22.22's global WebSocket plus child_process. The probe spawned Chromium with --remote-debugging-port=0, read the DevTools URL from stderr, then ran Target.createTarget, attachToTarget, Page.setDocumentContent and Page.printToPDF. Zero dependencies, but the protocol client is ours to own: lifecycle, crash, timeouts, cookies, readiness.",
        "puppeteer-core": "Not in the lockfile, so it would be a new dependency. 25.12.0 brings 6 direct dependencies (ws, chromium-bidi, devtools-protocol, @puppeteer/browsers, typed-query-selector, webdriver-bidi-protocol) and engines node 22.12.0 or newer, while the repo declares node 22.0.0 or newer (root package.json engines): a floor conflict.",
        "repo_rules": "AGENTS.md has no new-dependency rule. pnpm overrides live only in pnpm-workspace.yaml; no license gate exists (lint.yml has no license step). A dependency on a published package ships to every consumer of that package, so the driver belongs in a package only print deployments install."
       },
       "configuration_and_startup_detection": "Proposed: env OS_PRINT_CHROMIUM_PATH (AGENTS.md 9, the OS_DOMAIN_NAME config-value shape; 0 existing OS_PRINT_ hits) and a plugin option chromiumPath, the option taking precedence. start() resolves the path. Unset: ONE warn naming the consequence and the remedy. Set but not executable: a warn naming the path and the cause, checked with fs.access X_OK plus a version probe (/opt/pw-browsers/chromium --version answered 'Chromium 141.0.7390.37' in 31 ms on a shared box). Healthy: an info line with the version. warn, not error: this is a functional degradation, not a durability loss (AGENTS.md, Degradation log levels). A status() read reports configured / version / reason, so card 4 hides its action rather than discovering absence through a refusal, and no machine-readable surface advertises a render the deployment cannot do (Route and surface ownership 3 and 4). A second absence is owed loudness too: a driver configured on a runtime that serves no console (OS_DISABLE_CONSOLE=1 or no dist, packages/cli/src/utils/console-route-ledger.ts:126). Absent binary as playwright reports it: 'browserType.launch: Failed to launch chromium because executable doesn't exist at /nonexistent/chrome'.",
       "probe": "One headless render of a static print page per path, under bash scripts/pm/os-verify-lock.sh (OS_VERIFY_LOCK_SLOT=dev-22269, NODE_OPTIONS=--max-old-space-size=3072); scratchpad only, never committed. VERDICT line: 'VERDICT command-exit 0 · held the lock 2s · waited 294s (4m54s) · SHARED-BOX SECONDS'. playwright-core: %PDF-1.4, 34637 bytes, 3 pages, MediaBox [0 0 594.95996 841.91998] (A4 portrait taken from @page size, with preferCSSPageSize), launch 954 ms, render 341 ms. pdftotext shows 'Page 1 / 3' through 'Page 3 / 3' from an @page @bottom-right margin box with counter(page)/counter(pages), and the thead repeated on all 3 sheets. CDP direct: %PDF-1.4, 13519 bytes, 1 page, MediaBox [0 0 841.91998 594.95996] (A4 landscape from @page), render 210 ms. Absolute milliseconds are shared-box readings. Every print key card 1 declares that maps to @page or table CSS (paperSize, orientation, margins, pageNumbers, repeatTableHeaders) is honoured by headless Chromium. repeatHeader/repeatFooter (running regions) were not probed."
      },
      "3_short_lived_credential": {
       "what_plugin_auth_offers_today": "bearer() always on; sessions default to 7 days with updateAge 1 day (auth-manager.ts:1908-1909). admin impersonation is excluded: it acts as a different principal. jwt plus oauthProvider exist only when the OIDC/MCP provider is enabled, and verifyMcpAccessToken accepts only the MCP resource audience (auth-manager.ts:6607-6700). sys_api_key (packages/core/src/security/api-key.ts) is principal-bound with expiry, but the console's AuthGuard asks better-auth /get-session, which resolves sessions, not osk_ keys (read from code, not run). There is no one-time-token plugin and no API that mints a session for a user outside a better-auth endpoint; the only internalAdapter.createSession callers are admin-impersonate-endpoint.ts:228 and impersonation-bearer-rotation.ts:251. ExecutionContext carries userId, tenantId, locale, principalKind (human|agent|service|guest|system), isSystem and authGate, but NO session token (packages/spec/src/kernel/execution-context.zod.ts:24-318). Forwarding the caller's own token is therefore impossible for a flow-originated render, and a long-lived token would not be short-lived anyway.",
       "smallest_new_surface": "One in-process AuthManager method, never HTTP-mounted, that mints a better-auth session for the CALLER's own userId, carrying the caller's tenantId as activeOrganizationId, with a TTL of about 120 s, and returns the signed session cookie plus the signed dont-remember cookie under better-auth's own names. Names vary with cookiePrefix and useSecureCookies (auth-manager.ts:2699-2706), which is why the mint must live inside plugin-auth, next to the secret. It is revoked in a finally (internalAdapter.deleteSession). impersonatedBy stays null: the same user, not impersonation. It refuses a missing userId, isSystem, usr_system and non-human principals.",
       "measured_traps_the_build_must_pin": "(1) better-auth 1.7.3 createSession writes expiresAt AFTER the override spread (node_modules better-auth/dist/db/internal-adapter.mjs:261-277), so a short TTL holds only with overrideAll=true. (2) /get-session refreshes any session where expiresAt - expiresIn + updateAge is at or before now (dist/api/routes/session.mjs:180) and rewrites expiresAt to now + 7 days (:200). A 120-second session meets that condition at once, so the console's boot get-session would silently turn it into a 7-day session, unless the signed dont-remember cookie is present (:170) or refresh is disabled. The in-repo precedent uses exactly this pair: admin-impersonate-endpoint.ts:228-250 calls createSession(target, true, { expiresAt }, true) and sets the dont-remember cookie. Pins owed: expiresAt at or below the TTL after a /get-session on both the cookie lane and the bearer lane; the row deleted after the render, including on failure; the caller's userId and active organization (the console keeps a per-user active org in localStorage and the server refuses a non-member set-active, objectui packages/auth/src/ActiveOrganizationStorage.ts header)."
      },
      "4_the_archive": {
       "exposed_in_process_today": "StorageServicePlugin registers storage and file-storage as IStorageService, a bytes-only contract: upload, download, delete, exists, getInfo, plus optional presign and chunked members (packages/spec/src/contracts/storage-service.ts:229-395; storage-service-plugin.ts:310-312). StorageMetadataStore.createFile(rec, { organizationId }) is an exported class (metadata-store.ts:390-454), but the plugin's store instance is a closure inside registerStorageRoutes, so only the HTTP upload doors create sys_file rows (storage-routes.ts:545, 668).",
       "sys_file_shape": "Fields: id, key, name, mime_type, size, scope (attachments), bucket, acl, status (pending|committed|deleted), owner_id, and ref_object/ref_id/ref_field for field references (objects/system-file.object.ts). organization_id is registry-injected and stamped by threading the acting tenantId, never put in the payload (metadata-store.ts:32-48, 108-131). createFile runs under the store's explicit system opt-in, because no member grant exists on sys_file (metadata-store.ts:133-148, #21908). That is the existing pattern for every upload; owner_id carries the user.",
       "attached_to_a_record": "The link is a sys_attachment join row (packages/platform-objects/src/audit/sys-attachment.object.ts: parent_object, parent_id, file_id, file_name, mime_type, size, uploaded_by). Its insert is gated: the caller must READ the parent, answering 403 ATTACHMENT_PARENT_ACCESS, or the not-visible PERMISSION_DENIED (#21755) (attachment-access-hooks.ts:15-40); uploaded_by is server-stamped. plugin-audit's enable.files gate answers 403 FILES_DISABLED for a parent object without enable.files: true (audit-writers.ts:2006-2024).",
       "size_limits": "None enforced. The storage settings key max_upload_mb (default 100) has zero readers; see out_of_scope_findings.",
       "reaping": "A pending sys_file is reaped with its bytes after 7 days (system-file.object.ts:181 retention onlyWhen pending; attachment-lifecycle.ts:476-479 deletes the bytes). A COMMITTED attachments-scope file that was never joined is not tombstoned, so it strands (attachment-lifecycle.ts:113).",
       "missing": "An in-process entry that takes bytes, writes the sys_file, and attaches it under the CALLER's ExecutionContext, so the parent-read gate and the files gate fire, and stamps owner and organization from that context. Order: pending sys_file, then attach, then commit. A failure at any step leaves only a pending row, which the 7-day reaper reclaims together with its bytes."
      },
      "5_the_home": {
       "package": "New packages/services/service-print (@objectstack/service-print), plugin PrintServicePlugin (com.objectstack.service.print). Why not an existing package: storage is an always-on capability (packages/spec/src/kernel/platform-capabilities.ts:338-350) and plugin-auth ships everywhere, so either would push the 14M driver onto every deployment.",
       "adds": [
        "service id 'print': a non-CoreServiceName slot with no ServiceSlotContracts row until a consumer outside the package exists (core-service-contracts.ts: 'An entry is a claim')",
        "PrintServicePlugin, PrintServicePluginOptions { chromiumPath, renderTimeoutMs, credentialTtlSeconds }",
        "package-local IPrintService { status(): PrintDriverStatus; renderRecordToFile({ page, object, recordId, fileName? }, context: ExecutionContext): { fileId, attachmentId, size } }",
        "env OS_PRINT_CHROMIUM_PATH",
        "plugin-auth: one public AuthManager method (the mint); AuthManager is public through index.ts:16 'export * from ./auth-manager.js'",
        "service-storage: an in-process archive entry (a second registered service plus its exported type)",
        "dependency: playwright-core 1.63.0 pinned on service-print only",
        "no REST route in card 3 (recommended; see OQ2), no metadata key (print exists since card 1), no new error code (recommended; see OQ3), no requires token (recommended; see OQ6)"
       ],
       "packages_spec": "Under the recommended shape, ONE optional member on IAuthService (packages/spec/src/contracts/auth-service.ts). That is a spec-lane part, and the build round is Clause-2: yes. Clause-2 is yes even with zero spec edits: 'public surface = what a built package's entry declarations reach' (.claude/skills/pm-dispatch/references/execution-duties.md:67-68), and the new package, the AuthManager method and the storage export all widen published surfaces. A new error code would also be yes ('new codes are always yes', execution-duties.md, the error-code row).",
       "registry_touches_for_a_new_public_package": ".changeset/config.json fixed group 69 to 70 (check-changeset-fixed.mjs), the prose counts held by check-lockstep-package-count (--fix: content/docs/protocol/backward-compatibility.mdx x2, scripts/publish-smoke-pack.mjs), content/docs/plugins/packages.mdx, and pnpm-lock.yaml importer. Type-check coverage: the package arrives with a typecheck script (check:type-check-coverage)."
      },
      "6_the_refusals": {
       "door": "Recommended (OQ2): card 3 has no HTTP route; the door is the in-process service method renderRecordToFile, which throws ADR-0112-shaped errors carrying code and status. Card 4's route or action relays them through the REST error envelope. Order: principal, then driver, then page, then record, then files, all before any session is minted or Chromium is launched; each pin asserts that the driver, the mint and the archive were never called.",
       "no_driver": "SERVICE_UNAVAILABLE / 503: in the standard catalog (packages/spec/src/api/errors.zod.ts:53-137; HttpStatusErrorCodeMap 503 at :183-197). The message names OS_PRINT_CHROMIUM_PATH. The boot warn plus status() carry the permanent absence; the code is the backstop.",
       "page_without_print": "VALIDATION_ERROR / 400 (standard), details naming the page and the key print. Also: a page whose object differs from the record's object answers VALIDATION_ERROR / 400; an unknown page answers RESOURCE_NOT_FOUND / 404 (standard; the code metadata-protocol already uses, protocol.ts:3601-3614).",
       "caller_cannot_read_record": "RECORD_NOT_FOUND / 404 (standard), through @objectstack/core recordNotFoundError (packages/core/src/utils/record-not-found.ts:54), read caller-scoped BEFORE any render. This follows the existence-non-disclosure precedent of refuseDeniedSubjectLoad (packages/runtime/src/action-execution.ts:1866-1900: 'never a 403, never a new denied code').",
       "beyond_the_card": "FILES_DISABLED / 403 is already registered under @objectstack/plugin-audit (error-code-ledger.zod.ts:1112-1114), so pre-check it to avoid a wasted render. A system, service or guest principal answers PERMISSION_DENIED / 403 (standard).",
       "ledger": "Every code above already exists. The service-storage ledger entry (error-code-ledger.zod.ts:501-516) holds ATTACHMENT_PARENT_ACCESS among others. The recommended shape needs zero ledger edits."
      },
      "7_size": {
       "S1_service_storage_archive_entry": "~570 lines / 5 files: src/record-archive.ts (new, ~180), storage-service-plugin.ts (+~25), index.ts (+~6), src/record-archive.test.ts (new, ~350: pending then attach then commit; FILES_DISABLED; unreadable parent; system context refused; organization and owner stamping; a failed attach leaves only a pending row), changeset (minor). Independent of card 2.",
       "S2_plugin_auth_principal_session_mint": "~520-600 lines / 4-7 files: src/principal-session-mint.ts (new, ~150), auth-manager.ts (+~30), test (new, ~320: the refresh trap on both lanes, the TTL, revoke, refusals, cookie names under cookiePrefix and __Secure-), changeset. Under OQ1-A add packages/spec/src/contracts/auth-service.ts (+~25), its test (+~15) and the api-surface regen (spec lane). Independent of card 2.",
       "S3_service_print_package": "~1,700-1,900 lines / ~20 files: scaffold (package.json, tsconfig.json, tsconfig.test.json, vitest.config.ts, LICENSE, README, ~150), src/index.ts, print-service-plugin.ts (~180: config, startup probe, status), chromium-driver.ts (~200: launch, cookie context, navigate, readiness wait, pdf with preferCSSPageSize, timeouts, close in finally), print-service.ts (~220: the refusal chain, then mint, render, archive, revoke), types.ts (~60); tests: refusals (~300), startup loudness (~150), real-Chromium driver (~150, opt-in by env; the skip shape to be checked against check:expected-skips); plus the registry touches (fixed group, lockstep prose, packages.mdx, lockfile), changeset. Depends on S1 and S2.",
       "S4_page_binding_and_e2e": "~500-700 lines / 6-8 files: print-url.ts (card 2's route; origin from the runtime base URL; Chromium host mapping onto loopback for Host-routed tenant consoles, packages/cli/src/commands/serve.ts:5665-5690), the readiness wait on card 2's marker plus window.__objectui.idle, the .objectui-sha bump plus the SDUI manifest regen, a dogfood test that boots showcase with the console, renders a seeded print page for a record and asserts the PDF MediaBox and the sys_attachment row, and a docs page. Waits on card 2 at the pin.",
       "total": "~3,300-3,800 changed lines across the stages. Every stage, and S1 through S4 combined, stays under the 5,000-line landing class (AGENTS.md multi-agent 7c).",
       "lands_alone": "S1 and S2 each land alone and green, but each is a seam whose only consumer is S3 (see OQ11). S3 cannot honestly ship a renderRecordToFile without S4: with no console URL, it would declare a capability the runtime does not deliver. So S3 and S4 land together once card 2 is at the pin."
      }
     },
     "recommended_build_shape": "Four stages on one card. S1: service-storage archive entry, an in-process service with the order pending, attach under the caller, commit. S2: plugin-auth principal-session mint (createSession with dontRememberMe and overrideAll, the two signed cookies, TTL about 120 s, revoke in finally), declared as an optional IAuthService member (spec lane). S3 plus S4: @objectstack/service-print with playwright-core 1.63.0, OS_PRINT_CHROMIUM_PATH or the plugin option, loud at start(), status(), and the refusal chain principal, driver, page/print, record (RECORD_NOT_FOUND), files. It renders card 2's chrome-less print route on the same origin, with the minted cookies, preferCSSPageSize: true (the print keys are mapped ONCE, by card 2's CSS; the driver never maps them), and waits on card 2's marker plus window.__objectui.idle; the PDF goes to S1. No HTTP route, no metadata key, no new error code, no requires token in card 3. The contract review is owed at the build round (ruling).",
     "open_questions": [
      {
       "question": "OQ1 Credential seam: where is the short-lived principal-session mint declared?",
       "options": [
        "A: an optional IAuthService member in packages/spec/src/contracts/auth-service.ts, implemented by AuthManager; service-print resolves the auth slot typed by the ledger",
        "B: an AuthManager-only method; service-print depends on @objectstack/plugin-auth and casts the auth slot (precedent: service-sms imports @objectstack/plugin-auth/rate-limit-storage)"
       ],
       "axes": {
        "business_need": "Both serve the same single measured consumer (card 3). No second consumer is measured; card 4 calls card 3, not auth.",
        "long_term": "A is contract-first (AGENTS.md 12): the auth slot is already contracted (core-service-contracts.ts auth: IAuthService), and #4127 records slot methods called around the contract surviving bugs invisibly. B couples a service to one provider's concrete class; another IAuthService provider fails only at run time.",
        "ai_error_proofing": "With A an AI writing a second consumer finds the capability on the contract and the type system checks it. B hides it behind a cast, the pattern the ledger exists to remove.",
        "startup_focus": "B is smaller and stays out of the spec lane. A adds one optional member, a spec-lane review and an api-surface regen."
       },
       "recommendation": "A: the slot is already contracted, so extending its contract is the narrow, typed route. The cost is one spec-lane part in S2."
      },
      {
       "question": "OQ2 Door: does card 3 mount an HTTP route, or only the in-process print service?",
       "options": [
        "A: in-process only; card 4 owns the route or action (its body names 'the server call (objectstack)' as its landing site)",
        "B: card 3 also mounts a print render route now"
       ],
       "axes": {
        "business_need": "No caller of a route exists until card 4, which is blocked by this card.",
        "long_term": "One route, one owner. Card 4 picks the action type (ActionType api or flow, packages/spec/src/ui/action.zod.ts:558), so a route minted now may be the wrong shape.",
        "ai_error_proofing": "An unadvertised route with no action metadata is a surface an AI may call or document directly.",
        "startup_focus": "A is smaller; implementation-first."
       },
       "recommendation": "A, with the refusals pinned at the service boundary (code and status). Card 4 adds the door-level pins."
      },
      {
       "question": "OQ3 Refusal codes: standard catalog only, or a service-print ledger entry?",
       "options": [
        "A: standard only (SERVICE_UNAVAILABLE 503, VALIDATION_ERROR 400, RESOURCE_NOT_FOUND 404, RECORD_NOT_FOUND 404, PERMISSION_DENIED 403) plus the registered FILES_DISABLED; zero ledger edits",
        "B: new codes such as PRINT_DRIVER_NOT_CONFIGURED (precedents SMS_SERVICE_REQUIRED and EMAIL_SERVICE_REQUIRED) and PRINT_PAGE_NOT_PRINTABLE"
       ],
       "axes": {
        "business_need": "No consumer branches on a print code today; card 4 is unbuilt. The one real branch, permanent absence versus a transient 503, is carried by status() before any request.",
        "long_term": "The catalog is preferred over synonyms (the #8211 admission gate). A new code is permanent wire vocabulary.",
        "ai_error_proofing": "Fewer codes. Availability is read where it is decided, not inferred from an error.",
        "startup_focus": "A needs no ledger edit and no spec touch."
       },
       "recommendation": "A. Re-open B for the driver code only if card 4 measures a client that must branch on the error itself."
      },
      {
       "question": "OQ4 Driver library",
       "options": [
        "A: playwright-core 1.63.0 (already in the lockfile, zero dependencies)",
        "B: CDP directly (zero dependencies, ~300-400 lines of our own protocol client)",
        "C: puppeteer-core 25.12.0 (new; 6 dependencies; node floor 22.12 against the repo's 22.0)"
       ],
       "axes": {
        "business_need": "All three rendered, or are known to render, the same PDF. One driver is ruled.",
        "long_term": "A tracks Chromium protocol drift upstream. B makes us own that drift. C's node floor conflicts with the declared engines.",
        "ai_error_proofing": "A known library API means less bespoke protocol code for an AI to maintain wrongly.",
        "startup_focus": "A adds no new package to the lockfile."
       },
       "recommendation": "A, pinned exact on service-print only. The start() probe logs the Chromium version, because the measured skip from Chrome 153 to 141 passed here but is not guaranteed."
      },
      {
       "question": "OQ5 Driver configuration surface",
       "options": [
        "A: local executable only (OS_PRINT_CHROMIUM_PATH plus the plugin option)",
        "B: also a remote browser endpoint (connectOverCDP to a pooled Chromium)"
       ],
       "axes": {
        "business_need": "No named deployment needs B. Cloud's runtime shape is NOT MEASURED (no cloud checkout in this session).",
        "long_term": "B can be added later as a second transport of the same engine.",
        "ai_error_proofing": "One knob, refused loudly at start().",
        "startup_focus": "Implementation-first."
       },
       "recommendation": "A."
      },
      {
       "question": "OQ6 Composition",
       "options": [
        "A: explicit plugins entry in the host config",
        "B: a requires print capability token (spec PLATFORM_CAPABILITY_TOKENS and PROVIDERS, serve.ts CAPABILITY_PROVIDERS, cloud's loader)"
       ],
       "axes": {
        "business_need": "No stack declares print yet.",
        "long_term": "Route and surface ownership 2 favours explicit composition. B widens a spec vocabulary that is effectively permanent.",
        "ai_error_proofing": "A is visible at the call site.",
        "startup_focus": "A needs no spec change."
       },
       "recommendation": "A in card 3. Card 4 decides whether stacks need the token."
      },
      {
       "question": "OQ7 Archive seam",
       "options": [
        "A: service-storage registers a second in-process service with a package-local contract, consumed by type import",
        "B: an optional member on IStorageService (spec)",
        "C: service-print writes sys_file and sys_attachment itself"
       ],
       "axes": {
        "business_need": "The ruling names 'the service-storage in-process archive entry'.",
        "long_term": "IStorageService is the adapter-level bytes contract that the Local and S3 adapters implement, so B puts a record-level operation at the wrong layer. C duplicates the upload door's invariants (#12745 organization stamping, owner stamping, key layout, reap states).",
        "ai_error_proofing": "A keeps one owner of sys_file writes.",
        "startup_focus": "A needs no spec change."
       },
       "recommendation": "A. A new slot starts package-local; extending a contract is reserved for a slot that is already contracted (see OQ1)."
      },
      {
       "question": "OQ8 Size cap for an archived PDF",
       "options": [
        "A: enforce max_upload_mb in the archive entry only",
        "B: a new maxPdfBytes option",
        "C: no cap in card 3, bounded by the render timeout and the print page's own bounds (record:line_items caps at 500 rows, PR #22193)"
       ],
       "axes": {
        "business_need": "No measured oversized PDF. max_upload_mb is unread everywhere (finding).",
        "long_term": "A would enforce one declared key on one door while the upload doors still ignore it: a partial enforcement.",
        "ai_error_proofing": "No new knob.",
        "startup_focus": "C adds nothing."
       },
       "recommendation": "C. The max_upload_mb family goes to its own card (out_of_scope_findings)."
      },
      {
       "question": "OQ9 Admitted principal kinds",
       "options": [
        "A: human only; agent, service, guest and system refused PERMISSION_DENIED 403",
        "B: also an agent acting onBehalfOf a human, rendered as that human"
       ],
       "axes": {
        "business_need": "The ruling says 'the requesting user's principal'. No agent caller is measured.",
        "long_term": "B is a delegation decision outside this ruling.",
        "ai_error_proofing": "A is fail-closed.",
        "startup_focus": "A."
       },
       "recommendation": "A. A principal behind an authGate (ADR-0069 password expiry or enforced MFA) is refused too; its code is to be measured at build."
      },
      {
       "question": "OQ10 Card 3 versus card 4 on 'the attachment'",
       "options": [
        "A: card 3's archive entry attaches (card 3 body item 3); card 4 is the action, the door, the button and the listing",
        "B: card 4 attaches (card 4 body: 'It attaches the resulting sys_file to the record')"
       ],
       "axes": {
        "business_need": "Both bodies claim the same act today.",
        "long_term": "One owner: the attach must run under the caller inside the same unit as the pending-to-commit order.",
        "ai_error_proofing": "A single place where the parent-read gate fires.",
        "startup_focus": "A needs no extra seam between the cards."
       },
       "recommendation": "A. The seat amends #22270's body."
      },
      {
       "question": "OQ11 Staging",
       "options": [
        "A: S1 and S2 land now (each tested; consumer S3 named), S3 plus S4 together once card 2 is at the pin",
        "B: hold everything for one PR after card 2 (~3,500 lines)"
       ],
       "axes": {
        "business_need": "The chain to card 2 includes a maintainer publish, so the delay is not bounded by this lane.",
        "long_term": "A lands two seams ahead of their consumer; B avoids unpulled seams.",
        "ai_error_proofing": "Both keep the render path off until it can deliver.",
        "startup_focus": "Ruled and carded work, not speculation."
       },
       "recommendation": "A, provided the seat accepts S1/S2 as seams with a named, ruled consumer. Otherwise B."
      }
     ],
     "out_of_scope_findings": [
      "class: b · reach: named producer: packages/services/service-settings/src/manifests/storage.manifest.ts:63-70 renders the storage 'Limits' group (presigned_ttl, session_ttl, max_upload_mb 'Max upload size (MB)' default 100) in Setup's storage settings, and an admin save persists them · evidence: StorageServicePlugin.applySettings (packages/services/service-storage/src/storage-service-plugin.ts:530-574) reads only the adapter keys by name; presignedTtl and sessionTtl come only from constructor options (:96-101, :487-488); non-test readers outside service-settings on objectstack c8bb3c8d: max_upload_mb 0, presigned_ttl 0, session_ttl 0, against controls in the same manifest local_root 8 and s3_bucket 4; objectui pin 0, but its control was also 0, so that side is uninformative · Seam: settings-manifest:storage.max_upload_mb,presigned_ttl,session_ttl → runtime: consumer: none · dedupe words: max_upload_mb, presigned_ttl, storage settings limits, upload size limit not enforced, storage.manifest"
     ],
     "tests": "No code change, so no package test or typecheck was owed or run. Readings: (1) the render probe under os-verify-lock: 'VERDICT command-exit 0 · held the lock 2s · waited 294s (4m54s)', with outputs as in measurements.2.probe; pdftotext found 3 lines matching 'Page N / 3' and 3 repeated thead rows. (2) merge-base --is-ancestor 326a90a4 c8bb3c8d exit 0, self-proving with no control leg needed. (3) grep counts, each with a control: print readers at the objectui pin 0 against interfaceConfig 7; server PDF render sites 0 against showcase playwright 14; settings Limits readers 0 against local_root 8 and s3_bucket 4; OS_PRINT_ 0. (4) registry reads: npm @objectstack/spec latest 17.7.0, published 2026-10-06, no next tag; puppeteer-core 25.12.0 deps and engines; playwright-core 1.63.0 with zero dependencies. (5) better-auth 1.7.3 source lines cited in measurements.3. Ablation: none, no code. Tree read: objectstack c8bb3c8d (worktree /home/user/objectstack-issue-22269, shallow) and objectui a58626c88 (scratchpad blobless clone).",
     "mcp_calls": "0",
     "api_writes": "1: this os-dev-report comment, POST /repos/objectstack-ai/objectstack/issues/22269/comments, sent by scripts/pm/post-stamped.mjs through the fleet relay (repository_dispatch to objectstack-ai/objectstack, transport chosen by fleet-write/dispatch.mjs --route as dispatch). Plus one git op, not REST: git push -u of the empty branch claude/issue-22269-print-render-design at c8bb3c8d (new branch, exit 0).",
     "deviations": [
      "Card 2's body (objectui#11958) is NOT MEASURED: objectui REST and the issue page answer 403 'GitHub access to this repository is not enabled for this session'. Its state comes from the split record 6058277685. add_repo was not used, so that no credentials were minted for a read-only round.",
      "Probe timings are shared-box readings: the lock excluded only other locked runs; it waited 294 s and held 2 s.",
      "Cloud's tenant runtime (Host-routed console, objectos-runtime loader) is NOT MEASURED, because there is no cloud checkout. The only reading is serve.ts:5665-5690.",
      "Clause-2 framing: the dispatch reads 'lands in packages/spec' as making the round Clause-2: yes. That condition is sufficient, not necessary. The build round is yes under every option set by the public-surface criterion (execution-duties.md:67-68). Routing the non-spec parts ('a hit is spec-lane work', execution-duties.md:101) is the seat's call.",
      "Fields measurements, recommended_build_shape, deviations, and axes inside open_questions extend the report template, because the dispatch asks for them."
     ]
    }
  3. objectstack-fleet commented on Oct 8, 2026

    @objectstack-fleet
    ContributorAuthor

    Seat review: design round 1 · seat domain:services#1 (#6021) · session_01WkL6Eijt432S1Y7ekb6ovQ · 2026-10-08T11:58Z

    This reads the dev's report (6059301379) against #8346's ruling B′ (6051470224).

    Measured, adopted:

    Seat rulings on the open questions (the four axes are in the report):

    • OQ1 (the credential seam): A. The mint is declared as an optional IAuthService member, implemented by AuthManager. The auth slot is already contracted, so extending its contract is the typed route. That part is packages/spec, so spec-lane work, and the build round is Clause-②: yes with an at-tier contract review.
    • OQ2 (the door): A. In-process only. Card ④ owns the route or action. The refusals are pinned at the service boundary with code and status.
    • OQ3 (codes): A. Standard codes plus the registered FILES_DISABLED; no ledger edit.
    • OQ4 (the driver): A. playwright-core, pinned exact on the new package only. start() logs the Chromium version.
    • OQ5: A. A local executable only (OS_PRINT_CHROMIUM_PATH or the plugin option), loud at start(), with a status() read.
    • OQ6: A. Explicit composition; no requires token in card ③.
    • OQ7: A. service-storage registers the in-process archive entry with a package-local contract: pending, then attach under the caller, then commit.
    • OQ8: C. No cap in card ③. The unread max_upload_mb family is filed on its own card.
    • OQ9: A. Human principals only. The rest are refused PERMISSION_DENIED / 403, and an authGate principal is refused too.
    • OQ10: A. Card ③'s archive entry attaches. Card ④ is the action, the door and the listing. A pointer is posted on print page ④ of #8346: a record-page "generate PDF" action that calls the render service and attaches the archived PDF to the record #22270, whose owner amends its body.
    • OQ11 (staging): B. The build waits, whole, until card ② is at the pin. S1 and S2 would ship seams with no consumer for a wait this lane cannot bound (a maintainer publish, then objectui), and one of them is a session-minting entry. The four axes favour not shipping a credential surface ahead of its caller.

    The build round, when unblocked: four stages on this card.

    1. S1, the archive entry.
    2. S2, the mint, with its spec-lane part declared then.
    3. S3 + S4, @objectstack/service-print, the page binding and the .objectui-sha bump, together.

    Pins owed, from the report: the better-auth refresh trap on both lanes, the TTL, revoke in finally, and the refusal order before any mint or launch.

    Release: session session_01WkL6Eijt432S1Y7ekb6ovQ · reason: the design round is done, and the build waits on card ② at the pin · destination: pm:blocked, with the body gaining Blocked-by: objectstack-ai/objectui#11958 in this act. The next claimant re-reads this review and the report.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions