## Problem A policy ran over a file but the owner's **free usage was never consumed**. A policy run executes on a **background virtual thread** (`PolicyEngine.submit` → `asyncExecutor`), and Spring's `SecurityContextHolder` is thread-local — so the worker thread has no identity. When `PolicyExecutor` → `InternalApiClient.post` resolves the tool-call API key via `UserService.getCurrentUsername()`, it finds nothing and falls back to the **`INTERNAL_API_USER`** key. The loopback tool calls then authenticate as that system account, so `PaygChargeInterceptor` attributes the charge to *its* team (or none) — the real owner's free grant is untouched. Folder-watch / scheduled triggers are even further removed (fired from a background watch loop with no request context at all). The charging *mechanism* was fine (AUTOMATION, multipart, `openProcess`); only the **attribution** was wrong. ## Fix Propagate the acting identity onto the worker thread using the **audit-principal MDC key** that `UserService.getCurrentUsername()` already reads as its documented async fallback (the same mechanism used for other async jobs). No new plumbing through the executor. - **`runPolicy`** (stored policies — covers triggers *and* manual `runWith`) → bill the **policy owner**. `Policy.owner` is the username stamped at creation, so `getApiKeyForUser(owner)` resolves it. - **`submit`** (ad-hoc Automate/AI one-offs) → bill the **submitting user**, captured on the request thread (it doesn't survive the hop to the worker otherwise). With the principal set, `InternalApiClient` dispatches each tool call as that user → the interceptor resolves the right team → free grant draws / Stripe meters correctly. ## Tests `PolicyEngineTest`: - `runPolicyDispatchesToolCallsAsTheOwner` — asserts MDC `auditPrincipal` == the policy owner at the moment `InternalApiClient.post` is invoked. - `adHocRunDispatchesToolCallsAsTheSubmittingUser` — asserts it's the submitting user for an ad-hoc run. `:proprietary:test` + `:saas:test` + spotless green; coverage gates met. ## Heads-up (not in this PR) Once attributed, **automatic folder-watch / scheduled runs consume free grant (or bill) per file** — set up once, runs forever. That's automation-is-billable working as intended, but a set-and-forget policy can drain an allowance fast, so it may warrant a per-policy cap or a heads-up in the UI. Flagging for a product decision.
Stirling PDF - The Open-Source PDF Platform
Stirling PDF is a powerful, open-source PDF editing platform. Run it as a personal desktop app, in the browser, or deploy it on your own servers with a private API. Edit, sign, redact, convert, and automate PDFs without sending documents to external services.
Key Capabilities
- Everywhere you work - Desktop client, browser UI, and self-hosted server with a private API.
- 50+ PDF tools - Edit, merge, split, sign, redact, convert, OCR, compress, and more.
- Automation & workflows - No-code pipelines direct in UI with APIs to process millions of PDFs.
- Enterprise‑grade - SSO, auditing, and flexible on‑prem deployments.
- Developer platform - REST APIs available for nearly all tools to integrate into your existing systems.
- Global UI - Interface available in 40+ languages.
For a full feature list, see the docs: https://docs.stirlingpdf.com
Quick Start
docker run -p 8080:8080 docker.stirlingpdf.com/stirlingtools/stirling-pdf
Then open: http://localhost:8080
For full installation options (including desktop and Kubernetes), see our Documentation Guide.
Resources
Support
- Community Discord
- Bug Reports: Github issues
Contributing
We welcome contributions! Please see CONTRIBUTING.md for guidelines.
This project uses Task as a unified command runner for all build, dev, and test commands. Run task install to get started, or see the Developer Guide for full details.
For adding translations, see the Translation Guide.
License
Stirling PDF is open-core. See LICENSE for details.

