# Description of Changes
Fix Playwright failing test in SaaS (I think this is my third attempt
now so who knows if this will actually fix it for real this time, but
hopefully it does)
# Description of Changes
> [!warning]
> **Do not** squash this on merge. It should be merged via a merge
commit
Fixes conflicts in `pgvector_store.py`.
Also since codespell is failing, add comments to ignore the errors in
`sync_en_us_spelling.py`
---------
Co-authored-by: Ludy <[email protected]>
Co-authored-by: Anthony Stirling <[email protected]>
# Description of Changes
* Remove complex port selection logic from `engine.yml`. It's
inconsistent with the frontend & backend task files, and caused issues
with Docker, which have been worked around but would be simpler to just
get rid of the problem altogether
* Fix Ruff formatting of Python script
* Remove payg tests which are failing and have drifted too far from the
implementation to save directly
`PolicyController` was annotated `@PremiumEndpoint` (requires a
Pro-or-higher server license). Policies don't need a server-license
gate:
- On SaaS the server runs in Pro mode, so the check is always satisfied
anyway — it gates nothing in practice.
- Access is already governed by team scoping (#6632) plus the per-user /
guest gates.
So the annotation is dead weight and misleading. This removes it (and
its import) — a 2-line change.
## Verification
- `:proprietary:compileJava` succeeds; spotless clean. No other premium
gate on policy classes.
Guests (anonymous users on a login-enabled deployment) could open a
policy's setup/detail. Policies are an account feature, so a guest
clicking a policy should be nudged to sign up rather than opening it.
## Behaviour
A guest clicking a policy row — or a collapsed-rail icon — now
**re-summons the existing guest sign-up banner** ("You're using Stirling
PDF as a guest!…") and does **not** open the policy.
- `GuestUserBanner` listens for a `stirling:show-guest-banner` window
event and re-shows (even if previously dismissed; the render guard still
hides it for non-anonymous users).
- The policy sidebar dispatches that event on a guest click (cross-layer
via `CustomEvent`, same pattern as `payg:signupRequired`; a no-op on
builds without the banner).
- `usePolicyGuestBlocked()` gates it: `config.enableLogin === true &&
user.is_anonymous === true`.
- **Login-disabled single-user** deployments have an anonymous local
operator with full access → not gated.
## Verification
- Typecheck clean (proprietary + saas); eslint clean; sidebar tests
pass.
## Note
No dedicated guest unit test — the suite mocks `useAppConfig` at module
scope, and making it per-test controllable needs `vi.hoisted` plumbing
that risked the existing tests. Easy follow-up.
## Problem
We're getting 402s when an AI **agent** (chat) run hits the free
allowance / spending cap, but the frontend handles them poorly and never
pops the usage-limit modal.
The agent runs its tool calls **server-side** (loopback HTTP via
`PolicyExecutor`), so the 402 never reaches the `apiClient` interceptor
that pops the modal for direct calls. It was caught by the generic
tool-failure handler and flattened into a `CANNOT_CONTINUE` reason
string (`"The /api/v1/… tool failed: 402…"`), streamed as a `result`
event, and rendered as a scary chat bubble. This is the same gap the
policy auto-run path bridges (#6626) — one layer up.
## Fix
**Backend** (`proprietary`)
- `AiWorkflowResponse` gains `errorCode` + `errorSubscribed`.
- `AiWorkflowService` detects a downstream 401/402 entitlement sentinel
in its three tool-exec catch sites (`onToolCall`, `runPlan`,
`onConvertMarkdown`) and surfaces the structured code (+ `subscribed`)
on the terminal response instead of the raw failure text.
- Factored the 401/402 body extraction `PolicyEngine` already had into a
shared `DownstreamEntitlementError` util so the two server-side paths
can't drift.
**Frontend**
- New `usageLimitBridge` (`PAYG_LIMIT_REACHED_EVENT` +
`dispatchPaygLimitReached`) generalises the previously policy-only
bridge. Proprietary can't import the saas modal API (layering), so
server-side limit hits broadcast a window event the saas
`UsageLimitModalHost` opens the modal from. Migrated the policy path
onto it.
- `ChatContext` fires the matching modal (free → subscribe, subscribed →
raise cap) on the limit result **and** on a direct 402, replacing the
raw reason with a brief friendly line
(`chat.responses.usage_limit_reached`).
No Python engine changes — the charge/402 happens on the Java tool
endpoint that Java itself calls.
## Test plan
- [x] `:proprietary:compileJava` + `spotlessCheck` clean
- [x] `AiWorkflowServiceTest` + `PolicyEngineTest` green
- [x] eslint, proprietary + saas typechecks clean
- [ ] Manual: drive an agent run over the limit → brief line in chat +
the right modal (free vs cap)
> Note: proprietary test compilation is currently blocked on the
pre-existing `InitialSecuritySetupTest` 6-arg ctor break (unrelated,
tracked separately); verified locally by temporarily patching it.
add en-US changes to SaaS, previously merged into main. So this is
effectively a main -> SaaS PR also. It seems to be all additive.
Also take the 230 ish missing translations from en-GB over to en-US
using a script, and also make and english spellings American when adding
them to the en-US file, and fix any existing American spellings in the
en-GB file.
# Description of Changes
Search has got significantly worse since #6581, where I added all the
missing tags for tools that should have been there for months. Turns out
that the fuzzy matching search logic has always been way too permissive
to match words with Levenshtein distances way too far away from the
target word, so long searches include way too much stuff. The new tags
just exposed that underlying logic issue. This PR makes the Levenshtein
logic much stricter, so it is still tolerant to minor typos in tool
names, but doesn't match completely inappropriate strings.
Frontend follow-up to #6632 (team-scoped policies, editing gated to team
leaders on the backend). Brings the UI's edit gate in line.
## Problem
The policy config UI gated editing to `config.isAdmin`. On SaaS, org
users are never the single global admin, so **no one could open the
policy editor** — the same lockout #6632 fixed on the backend.
## Fix
`usePolicies` now allows a **team leader** to configure, falling back to
a global admin self-hosted:
```ts
canConfigure =
config != null && (!config.enableLogin || isTeamLeader || config.isAdmin === true);
```
- SaaS → `isTeamLeader` (from `useSaaSTeam()`) — team leaders can
configure; members get the read-only surface.
- Self-hosted → `config.isAdmin` (the core `useSaaSTeam` stub returns
`false`, so admins aren't locked out).
- Login disabled (single-user) → always allowed.
- The `config != null` guard keeps the gate closed until app-config
resolves, so edit controls never flash for users who can't use them.
The two locked-policy banners now read "Contact a team leader to change
this policy" (updated in the `t()` defaults and the `en-GB`
translations).
## Verification
- Typecheck clean (proprietary + saas); eslint clean.
- Tests pass: `usePolicies`, `PoliciesSidebar`.
## Problem
When the `EntitlementGuard` refuses a request with **402** (team is over
its free allowance / spending cap, or has no subscription to bill), the
handler never runs — so it must not charge. But it did: the guard (order
**1100**) ran *after* the charge interceptor (**1000**), so
`openProcess` had already written the charge before the 402, and
`afterCompletion` then billed it as "customer paid for the attempt" — a
ledger debit, and a **Stripe meter for a subscribed-over-cap team**.
## Fix
**Run the guard first** (order **900**, before the charge interceptor at
1000). Spring runs interceptors in ascending order on the way in and
**skips a later interceptor's `preHandle` (and `afterCompletion`)
entirely once an earlier one returns `false`** — so a refused request
short-circuits with its 402 *before* the charge interceptor runs at all.
A blocked request never opens a process, materialises inputs, or writes
a charge.
This replaces the earlier attribute-flag + `afterCompletion`-refund
approach with a simpler reorder (per review): the no-charge-on-block
guarantee is now **structural**, and it also avoids the wasted
open-then-refund churn (no temp-file write, no debit/refund pair) for
refused requests.
### Why the reorder is safe
- `EntitlementGuard` reads no `PaygChargeInterceptor` state and has **no
`afterCompletion`** (only `preHandle`), so reverse-order teardown is a
non-issue.
- The legacy `UnifiedCreditInterceptor` (default order **0**, and only
registered under the `legacy-credits` profile) still runs first, so any
legacy rejection wins.
- For *admitted* requests both interceptors still run (guard then
charge) — behaviour is unchanged; only refused requests now
short-circuit before the charge.
## Tests
`PaygWebMvcConfigTest` locks the `ENTITLEMENT_GUARD_ORDER <
INTERCEPTOR_ORDER` invariant (if it's ever reversed, refused requests
would bill again — this fails first). Existing `EntitlementGuardTest`
already proves the guard returns 402 on a degraded/billable request.
`:saas:test` + spotless green.
## Related (separate, in progress)
The **fail-cleanly + fire-the-modal** half (suppress the error toast,
trigger the subscribe/raise-cap modal via the existing
`subscribed`/`category` signal — catching the 402 centrally so direct
API usage is handled, and propagating the entitlement reason through the
async policy-run status) lands **with Ethan's modal** so we don't remove
the toast before there's a popup to replace it.
## Problem
Two AI surfaces slipped through PAYG unbilled:
1. **AI document tools** — `/api/v1/ai/tools/**`
(`PdfCommentAgentController`, `MathAuditorAgentController`) live in the
**proprietary** module, which can't depend on `saas` and so can't carry
the saas-only `@RequiresFeature`. They also lacked
`@AutoJobPostMapping`, so the charge interceptor's scope gate
short-circuited them **before** category resolution: **not charged, and
not even entitlement-gated** — whether called directly or dispatched by
the orchestrator.
2. **AI Create** — `/api/v1/ai/create` is JSON/session-based with no
file input, so the multipart charge path never fired. The old
per-generation charge ran through the now-dead legacy credit system, so
it currently charges nothing.
## Fix
- **`AiToolRoutes`** (new, saas) — single source of truth for the
`/api/v1/ai/tools/**` prefix. The proprietary controllers stay
untouched; the saas hot-path recognises them by path:
- **`PaygChargeInterceptor`**: brings these routes into scope and bills
them **AI** on a direct call. An orchestrator-dispatched call still
resolves to **AUTOMATION** first (the `X-Stirling-Automation` header is
checked before the path rule), so AI-tool-inside-a-workflow keeps
billing as automation.
- **`EntitlementGuard`**: gates them on **`AI_SUPPORT`**.
- This keeps the `proprietary → saas` layering intact (no backwards
dependency).
- **`JobChargeService.chargeStandalone(ctx, units)`** — charges a fixed
unit count for a non-file billable action, reusing the existing
free-grant split + shadow row + ledger debit + `close()`→meter path on a
standalone bookkeeping job (no lineage inputs, so nothing lineage-joins
it). **`JobService.open(ctx, docUnits)`** opens that bare job.
- **`AiCreateController.createSession`** — charges **one document per
session** at creation (best-effort; entitlement is already enforced
upstream by the class-level `@RequiresFeature(AI_SUPPORT)`). Follow-up
edits (`outline` / `reprompt` / `draft` / `template` / `stream`) carry
**no** charge — they have no charge hook, so "charge on create,
follow-ups free" falls out naturally.
Per the agreed scope: charge AI Create on create now; we can optimise
follow-up handling later. **AI workflow categorisation (AUTOMATION vs
AI) intentionally left as-is** (the orchestrator's automation header
dominates by design).
## Tests
- `PaygChargeInterceptorTest`: AI-tool route (no annotations) is in
scope + **AI** category; same route with the automation header →
**AUTOMATION**; a plain non-AI route still short-circuits.
- `EntitlementGuardTest`: AI-tool route is in scope + gated on
**AI_SUPPORT** (degraded team → 402; anonymous → 401 with `category:
AI`).
- `JobChargeServiceTest`: `chargeStandalone` charges + meters the paid
portion for a subscribed team, draws the free grant (no meter) for an
unsubscribed team, and rejects `BYPASSED`.
`:saas:test` + `:saas:spotlessCheck` green; coverage gates met.
## Follow-ups (not in this PR)
- Make AI Create follow-ups explicitly cheaper / chained if we want
(currently free by absence of a hook).
- Decide whether AI-tool-inside-a-workflow should bill as AI rather than
AUTOMATION.
## Problem
A **free team can invite a paid team's leader** to join. The leader
accepts, and:
- `acceptInvitation` moves them onto the inviting team and removes their
membership from the old team, but only deletes the old team if it's
**personal**.
- Their paid (non-personal) team is left **memberless but still
subscribed** — an orphaned Stripe subscription billing for a team nobody
is in.
## Root cause
Two gaps in `SaasTeamService.acceptInvitation`:
1. The pre-accept guard checks `hasPaidSubscription(acceptingUser)` →
`existsActivePaidSubscriptionForUser(supabaseId)`, keyed on
**`user_id`**. A team's plan is keyed on **`team_id`**
(`existsActiveSubscriptionForTeam`), so a paid team's *leader* isn't
caught and accepts freely.
2. The 'leave existing teams' loop deletes the membership directly and
only marks **personal** teams for deletion — it bypasses the last-leader
protection `leaveTeam` already enforces (`"Cannot leave as the last team
leader. Transfer leadership first."`), and never cleans up / cancels the
non-personal team.
There is no in-app subscription-cancel path (cancellation is
Stripe-portal/webhook driven), so nothing reconciles the orphan after
the fact — it has to be prevented.
## Fix
Add `assertCanLeaveCurrentTeamsToJoinAnother(user)`, called in
`acceptInvitation` before any membership changes. For each non-personal
team where the user is the **last leader**, the accept is rejected:
- team has an active subscription → *"Cancel the plan or transfer
leadership before joining another team."*
- otherwise → *"Transfer leadership before joining another team."*
This mirrors the protection `leaveTeam` already has and is
team/leadership-aware, closing the `user_id`-vs-`team_id` gap. Regular
members and teams with another leader are unaffected.
## Verification
- `ENABLE_SAAS=true ./gradlew :saas:compileJava` — passes.
- Manual: with a paid team leader, accepting an invite to another team
should now be rejected with the message above; verify a non-leader
member can still accept.
## Note
This prevents *new* orphans. Any teams already orphaned by this bug
(memberless, still subscribed) would need a one-off reconciliation —
happy to follow up with a query/cleanup if useful.
## 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.