Skip to content

UN-4168 [DEV] Bring UN-4108-zid up to main - #2345

Open
athul-rs wants to merge 45 commits into
UN-4108-zidfrom
UN-4168-oss-merge-main-2026-10-13
Open

athul-rs wants to merge 45 commits into
UN-4108-zidfrom
UN-4168-oss-merge-main-2026-10-13

Conversation

@athul-rs

Copy link
Copy Markdown
Contributor

What

  • Fast-forwards OSS UN-4108-zid to main at 9b3efba7. The branch is 45 commits behind and has no commits of its own, so there is no merge commit.

Why

  • Cloud PRs into the cloud UN-4108-zid branch now test against OSS UN-4108-zid (cloud UN-4430), not OSS main. Without this, cloud CI for the epic would run against OSS code 45 commits old, while the cloud branch itself carries main.

How

  • The PR head is main's current commit.

Can this PR break any existing features. If yes, please list possible items. If no, please explain why. (PS: Admins do not merge the PR without this section filled)

  • No. It changes only the feature branch, and the result is identical to main.

Database Migrations

  • Whatever main already carries; nothing new.

Env Config

  • None.

Relevant Docs

  • Cloud docs/zid/implementation-plan.md, branching model: OSS UN-4108-zid is the cloud branch's twin.

Related Issues or PRs

  • UN-4168, epic UN-4108. Merge first; the OSS UN-4116 role-vocabulary PR is stacked on it.

Dependencies Versions

  • None.

Notes on Testing

  • git rev-list --count origin/main..UN-4168-oss-merge-main-2026-10-13 is 0 and origin/UN-4108-zid..origin/main is 45.
  • Pre-raise review (standard-review rubric, unstract plugin v0.18.1, 15 Oct): APPROVE. The head is a commit on main; every commit in range is an already-merged PR.

Screenshots

Checklist

I have read and understood the Contribution Guidelines.

🤖 Generated with Claude Code

…2285)

Docker Hub minio/minio and minio/mc were deleted upstream (MinIO is
source-only now), breaking e2e and integration CI on every branch.
quay.io/minio is MinIO's own public registry, frozen at
RELEASE.2025-09-07 with the same digests we were already pulling.
Pinned by digest so a tag deletion cannot break us again.


Claude-Session: https://claude.ai/code/session_015n6ff8JSpGFypkcuUtSD9y

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…'s creator (#2274)

* UN-3853 [FIX] Attribute platform-key-created resources to the key's creator

A platform API key authenticates as a service account, and every resource
create path granted the OWNER membership row to that machine identity. Service
accounts are filtered out of every owner surface (HasMembersMixin), so such a
resource ended up with no human owner: invisible to its creator in list views,
manageable only through the org-admin fallback, and rendered in "Owned By" as a
synthetic @platform.internal address dressed up as a colleague.

Record the key's creator as owner instead — the same successor
delete_api_user_for_key already hands ownership to when a key is deleted, now
applied at creation rather than only at deletion. The service account loses
nothing: permission classes and for_user() short-circuit on is_service_account.

Where no human can be named (the key's creator has since been deleted), the
resource stays deliberately ownerless and the table labels it "Platform key"
rather than naming a machine.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHnDZZWGhsevUdwgMyR2ai

* UN-3853 [FIX] Name the real owner on the deployment and pipeline cards too

The ticket asks for Owned By to be correct on every resource type. The API
deployment and ETL pipeline card views were still wrong: OwnerFieldRow read
created_by_email only, and their serializers never exposed owner_emails — so
those cards named the audit creator, which on a platform-key create is the
service account. The backend fix alone could not reach them.

Expose owner_emails on both serializers (their querysets already prefetch
memberships__user, so it costs no extra query), and move the owner-label rule
into one resolveOwnerDisplay helper shared by the table and the cards. The two
had already drifted on both the source field and the "Me" rule — the card said
"Me" to any owner, which is the co-owner bug the table's comment warns about.
Cards now match the table: "Me" tracks the displayed owner.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JHnDZZWGhsevUdwgMyR2ai

* UN-3853 [FIX] Cover platform-key resource ownership with tests

`owner_user_for` had no coverage. Adds the resolver's own branches (normal
user early-returns with no query, service account resolves to the key's
creator, a deleted creator or a missing key leaves the resource ownerless)
and one case per OSS resource that grants an OWNER row on create: workflow,
prompt studio, ETL pipeline, API deployment, connector, adapter.

The resource cases drive the real URLconf and middleware chain with a key
minted in the test, so the service-account swap that caused the bug is
exercised rather than simulated. Verified by mutation: reverting each call
site to the raw request user fails the matching case.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

* UN-3853 [FIX] Do not grant ownership to a departed organization member

F1: owner_user_for granted an OWNER membership row to the key's creator
without checking they are still a member of the key's organization.
_is_resource_owner grants on any surviving OWNER row with no live-membership
check, which is why cleanup_user_org_access purges those rows on departure;
minting a new one afterwards reopened that rejoin backdoor for the key's
lifetime. AddOwnerSerializer already refuses both non-members and service
accounts, so these sites were the only OWNER writes without the gate.

The check reads _base_manager: the default manager is org-scoped by
UserContext, which is unset outside a request, so the plain manager would
return empty and silently strip every resource of its owner.

F6: the fallback arms now log, naming the key id (not the key itself).
F7, F8: the docstring claimed service accounts are filtered from "every"
owner surface and that the residual resource "stays deliberately ownerless".
Neither held -- owner_memberships() and is_owner() apply no such filter, and
a real OWNER row is written that the last-owner guard counts as live.

Tests: the fixtures created a key creator with no OrganizationMember row, a
user who could never have created a key through IsOrganizationAdmin. They
now build the member production guarantees, and two cases pin the new guard
(creator left the org; creator belongs to a different org). Both fail when
the guard is neutered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC

* UN-3853 [FIX] Show the owner on a freshly created deployment, and cover the gaps

F5: the create response is a seven-field summary carrying none of the owner
fields the list renders, and the modal spliced it straight into the table, so
a just-created API deployment showed no owner until the next fetch. It now
refetches; the response is still handed to the code modal, which is the only
thing that needs the API key. The edit path already merged over the existing
row, so it kept its owner fields and is unchanged.

F3: reverting the seventh grant site (create_tool_from_import_data, reached by
project-transfer/ and sync-prompts/ with create_copy) left the whole backend
suite green -- six cases covered seven sites. A case now pins it, and fails
when that site is reverted.

F9: the comment called the "Platform key" branch reachable only via a deleted
creator. With no backfill it is also every resource created through a key
before this change; the accurate statement already sits on the fallback above.

F11: resolveOwnerDisplay decides the Owned By cell on every list and card and
had no test -- the only suite rendering it asserts nothing about that cell.
Six cases now cover its four branches; they fail when the platform-key
suppression or the "Me" rule is broken.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC

* UN-3853 [FIX] Backfill existing platform-key resources and document the field

F10: resources created through a platform key before this change hold an OWNER
row naming the service account, which every owner surface filters out, so they
show no owner and only an org admin can manage them. Nothing moved them -- the
only re-pointer runs from delete_api_user_for_key. A data migration now hands
each to the same successor owner_user_for picks, under the same membership
rule: a creator who has left the org is skipped rather than handed a fresh
OWNER row, which would reopen the rejoin backdoor. Where the creator already
holds a row on that resource the stronger role is kept and the service
account's is dropped, so the unique triple is never violated.

Verified on a disposable database seeded with all three cases -- live creator,
departed creator, and a creator already holding a VIEWER row: forward applies,
reverse unapplies, and a re-apply is a no-op.

F4: APIDeploymentSummary subclasses the list serializer and declares no fields
of its own, so owner_emails reached the published platform-key contract with no
description. Stripping it there would only desynchronise the spec from the
response, and the UI reads the same endpoint, so the field stays and the
docstring now documents what a caller receives. Spec regenerated.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC

* UN-3853 [FIX] Move the owner grant onto HasMembersMixin

F12: resolving the acting user was a rule every create site had to remember,
applied at seven sites here and four in cloud. That is how the agentic
sample-project site came to be missed. HasMembersMixin is already on every one
of these models, so grant_owner() lives there and each site calls it -- the
rule now has one enforcement point, and all seven ownership cases fail when it
is broken, instead of one case per site.

Also drops seven imports of platform_api from the business apps, for an
operation permissions/ owns; the resolver import is now lazy and in one place.

Out of scope, checked: sharing_helpers grants VIEWER rather than create-time
OWNER, and platform_admin's two onboarding grants take the human from signup
rather than request.user, so no bearer session reaches them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC

* UN-3853 [FIX] Apply the membership rule on the key-deletion and backfill paths

Found by adversarial verification of the previous commits.

F21: closing the create path left delete_api_user_for_key still handing every
OWNER row to the key's creator with no membership check, so deleting a key
re-granted a departed creator the rows the create path had just refused --
the same rejoin backdoor, one step later. The membership question is now
live_key_creator(), asked by both paths instead of stated twice, and
transfer_ownership already short-circuits on None. Two cases pin it.

F22: migration 0006 declared no edge to UN-2202's *_absorb_shared_users
backfills, which write an OWNER row from created_by -- the service account on
a key-created resource. Six of the eight sorted before 0006 by alphabetical
accident and workflow_v2.0022 sorted after it, so workflows would have been
backfilled ownerless and left that way. The edges are now declared;
workflow_v2.0022 moves from plan index 289 to 183, immediately before 0006.

F23: the create-modal refresh called the fetch with its own defaults, so it
dropped any active search and reset to page 1 -- and a never-run deployment
sorts into the tail, so it was usually not on the page it landed on. It now
uses handleListRefresh, which the hook documents for exactly this and which
the sibling delete and co-owner callbacks already use.

Also corrects two comments this branch introduced: the owner-display pointer
named a fallback that states something else, and the lazy-import note named a
cycle that does not exist (the real constraint is import-time model loading).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Tm83ikxv1euWWQQGLMBcjC

* UN-3853 [FIX] Name the backfill's model bindings like the rest of the tree

Sonar flagged all three `apps.get_model` bindings in the backfill (python:S117).
`0005_add_reconciliation_task` already uses the `*_model` form, so this matches
the migration the repo most recently reviewed rather than the older PascalCase
one in `0004_pg_periodic_tasks`. The quoted model names are untouched -- those
are lookup keys, not identifiers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

* UN-3853 [FIX] Cut the comment prose down to what stays true

The comments had grown into mechanism retellings and change history -- why a
site was missed, what the two views used to disagree on, which surface filters
what. That rots as soon as the code moves and costs every later reader.

Keeps the contract (what a function returns, why a lazy import, why
_base_manager) and drops the narration. No code changed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

* UN-3853 [FIX] Keep the service account authorized and the audit trail intact

Review findings from #2274.

Moving the OWNER row off the service account broke two gates that authorize by
OWNER membership and have no service-account bypass, so a workflow provisioned
through a platform key failed its own execution: `validate_adapter_access` and
`IsWorkflowOwnerOrShared.has_permission`. Both now short-circuit on a service
account, matching every other access surface.

On key deletion the audit half and the ownership half are now separate.
`created_by`/`modified_by` follow the key's creator even if they have left the
org -- deleting the account is SET_NULL on those FKs, and a null `created_by`
breaks `CustomTool.delete()`. The OWNER row still goes only to a live member.

Also: eager-load `by_prompt_studio_tool` so `owner_emails` does not fan out,
write `modified_at` in the backfill (historical models skip BaseModel.save),
and return to page 1 after creating a deployment, since never-run rows sort
last and the new one could land off-page.

Three regression tests, each verified by reverting its fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

* UN-3853 [FIX] Drop the now-unused list refresh handler

Rewiring the create modal to the page-1 refresh left `handleListRefresh`
destructured but unread here. The hook still exports it and Workflows.jsx
still uses it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

* UN-3853 [FIX] Share the platform-key ownership repair for cloud-only apps

tenant_account_v2's backfill migration can't depend on cloud-only apps'
absorb_shared_users migrations, so their service-account OWNER rows could
land after the repair and never get revisited. Extract the repair into
_membership_backfill so a cloud migration can import and re-run it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3853 [FIX] Cover the third ownership gate, and stop jumping to page 1

Re-review findings from #2274.

`IsFrictionLessAdapterDelete` is a third gate that authorizes by OWNER
membership with no service-account bypass, so a full_access key could no longer
delete an adapter it had just created. Every sibling in the file has the
bypass, including `IsFrictionLessAdapter` directly above it.

Swept the rest rather than wait for a fourth: all 13 `_is_resource_owner` call
sites across both repos now either carry the bypass or are unreachable by a
service account -- `prompt_studio_helper._adapter_accessible_by` is guarded by
an `is_service_account` early return at its only caller.

The page-1 refresh is reverted. The ordering is `last_run_time DESC NULLS
LAST`, so a never-run deployment sorts to the END -- page 1 is the wrong
target, and jumping there also threw the user off the page the row was
actually on. `handleListRefresh` at least keeps them there. Making the new row
visible needs an ordering change, which is a product decision, not this PR.

Regression test for the adapter delete gate, verified by reverting the bypass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BAfubrF8kR2rKewMSjXagg

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
… shared with a user (#2273)

* UN-2868 [FIX] Restrict workflow connector and tool changes to owners and org admins

Workflow sub-resources were never gated when the sharing model landed.
tool_instance_v2 was fixed; endpoint_v2 was missed, so any user a workflow
was shared with could change its destination folder, database table or
connector.

Adds a reusable WorkflowOwnerMutationMixin next to is_workflow_mutator and
applies it to WorkflowEndpointViewSet. Sharing -- direct, group or org-wide
-- now grants read only; owners, co-owners, org admins and service accounts
may still write.

The UI now says so up front instead of failing on save: a shared user sees a
read-only notice and greyed controls in the connector modal, tool settings,
and the Prompt Studio project selector, with no Save button to press.

The connector modal's Save now also flushes the HITL plugin's rules. It
previously lit up for rule changes it could not save, then closed as if it
had saved them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Show the Prompt Studio project name to shared users instead of its ID

The workflow builder resolved the project name from exportedTools, which
holds only the viewer's own exported projects. On a shared workflow the
lookup missed and fell through to the raw function name, so shared users saw
an ID where the owner saw a name.

The tool instance already carries the display name (ToolInstanceSerializer
sets it from the tool's properties), so fall back to that before falling back
to the ID. The ID remains the last resort for a tool the registry can no
longer resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Do not close the connector modal on a failed or partial save

Addresses review feedback plus two defects found alongside it.

- perform_create skipped authorization entirely when the payload carried no
  workflow, then saved anyway. It now fails closed: every viewset using this
  mixin has a required workflow field, so a payload without one cannot be
  authorised.
- handleValidateAndSubmit swallowed its own error, so a failed endpoint write
  still fell through to the HITL write and "Save and Close" dismissed the
  modal. Both writes now report success and the modal stays open on failure.
- The HITL write only runs when its form is actually dirty; otherwise every
  connector save would have written a rule row too.
- A view-only user can no longer be asked whether to save unsaved changes on
  close. They cannot have made any.
- ReadOnlyNotice used defaultProps, which React 19 ignores on function
  components, so its default message never applied. Uses a default parameter.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Refuse form submits from view-only users, not just clicks

The read-only styling suppresses pointer events but leaves fields focusable,
so a keyboard user could tab into one and press Enter. That fired a request
the backend rejects, producing exactly the confusing error this ticket is
about.

Both RJSF forms now refuse to submit when the workflow is not editable, so
the request is never made.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Hide edit and delete actions on resources shared with the user

Sharing grants read only, but every resource list still offered Edit, Share,
Delete and the enable/disable toggle to the people it was shared with. The
backend refused them; the UI did not say so.

All eight shareable resources render through two shared widgets, so the row
actions are gated in one place each -- ResourceTable covers workflows, Prompt
Studio, connectors, adapters, agentic projects and lookups; CardActionBox
covers pipelines and API deployments.

The two card kebabs mix read and write actions, so those are filtered per
page: Manage Keys, Notifications and Clear File History go, while View Logs,
File History, Sync Now, Code Snippets and Download Postman stay. Running and
watching a shared pipeline is still allowed -- that is what sharing is for.

The rule itself now lives in one helper, canEditResource, which
useWorkflowCanEdit also delegates to.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Expose is_owner on the Prompt Studio editor payload

The Prompt Studio editor is served by CustomToolSerializer, which never
carried is_owner -- only the list serializer did. Without it the editor cannot
tell a shared user from an owner, so its edit controls cannot be gated.

canEditResource now treats a payload that has not arrived yet as editable.
The backend refuses the write either way, and the alternative flashes a
read-only view at the resource's own owner while the request is in flight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Keep Share available to shared users; gate only edit and delete

Sharing onward is allowed for someone a resource was shared with: they may
pass access to a group they belong to, or to a user in the same organisation.
ShareAuthorizationService enforces both rules per axis, which is why the
share endpoint sits at IsOwnerOrSharedUserOrSharedToOrg rather than IsOwner.

The previous commit hid the Share button along with Edit and Delete. Only the
latter two should go.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Make Prompt Studio settings read-only for shared users

Settings hold the project's LLM profiles and adapter selections -- the
credential-bearing part. A shared user can read them but not change them: the
panel gets the read-only notice and its controls are inert.

Prompts are deliberately untouched. Editing, running and deleting prompts is
what a project is shared for; only the settings panel is restricted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Restrict the gate to edit and delete only

The scope is settings and deletion, nothing else. The pipeline and API
deployment cards had also lost their enable/disable toggle, Manage Keys,
Notifications and Clear File History for shared users, which goes further
than intended.

Both card configs are reverted. Only the Edit and Delete controls in the two
shared list widgets stay gated; Share, the toggle and every kebab action are
available again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Show Edit and Delete disabled rather than hiding them

Hiding the two controls left a shared user with no idea they existed or why
they were missing. They now stay on screen, greyed out, with a tooltip
reading "Only the owner can change this". Same treatment in both list widgets
so every resource looks the same.

The rename pencil beside a project title follows the same rule: ToolNavBar
takes an editTitleDisabled prop, and Prompt Studio passes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016eB6bLmieWVwUnmYH6H5WZ

* UN-2868 [FIX] Report one outcome when the connector save also writes rules

The connector write showed "Configuration saved successfully" before the
rule write ran. A rule failure after it left a green toast on screen next
to a red one, reading as though everything had landed.

The connector success message is now suppressed when a rule write follows,
and the rule write reports the outcome for both.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TaTtTMaDriVZgw6BR8HZ4G

* UN-2868 [FIX] Prefill the workflow rename form and limit shared-user locks to owner-only actions

Four defects found while testing the shared-user gates.

Workflow rename modal opened blank for the owner. `Form.Item` controls its
child and injects a value from the form store, so the `defaultValue` on the
Input never reached the DOM; the OK button also started disabled and only
flipped on change, so the owner had to retype both fields to save. Seed the
form via `initialValues` and derive the button's initial state from the props.

Workflow builder header offered its rename pencil to shared users. `ToolNavBar`
already takes `editTitleDisabled` and Prompt Studio and Agentic already pass
it; `MenuLayout` was the one screen that did not.

Prompt Studio settings were locked wholesale for shared users, which also
blocked adding an LLM profile of their own. The backend only refuses editing
and deleting an existing profile (`IsParentToolOwner`) -- create, copy, set
default and every other tab are open to a shared user. Move the lock onto the
two buttons that are actually refused and drop the modal-wide notice. The rows
are built in an effect, so `canEdit` joins its deps: it starts true while
`details` loads and would otherwise leave the buttons stale-enabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Make Prompt Studio prompts and settings read-only for shared users

Shared access is meant to grant read and run only, but Prompt Studio still
saved prompt and settings writes from a shared user, and three toggles
offered actions the backend already refuses.

Prompts. `PromptAcesssToUser` admitted viewers and group-shared users on
every method, so their edits and deletes went through; shared access is now
limited to SAFE_METHODS. `reorder_prompts` is routed without a pk, so DRF ran
no object check at all and any org member could reorder any project's
prompts -- it now resolves the prompt and checks it.

Settings. Every tab in the modal writes to the project row, so it is
owner-only again, LLM profiles included. `update`, `partial_update` and
`make_profile_default` move to `IsOwner`, which also closes project rename.
`IsParentToolOwner` gains a `has_permission`: `create` was already listed as
owner-only, but DRF never calls the object check on a create, which is why a
shared user could add an LLM profile and then not edit it.

Toggles. The ETL pipeline and API deployment enable/disable switches carried
no ownership gate even though both endpoints reject the PATCH.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Share Prompt Studio for collaboration, and gate the API key controls

Product call on the ticket thread: sharing is not one uniform rule. A Prompt
Studio project is shared for collaboration -- a locked one is not useful to a
team -- while adapters, connectors, workflows and deployments are shared for
use. Across all of them the owner keeps three things: renaming, deleting, and
deciding who else gets access.

Prompt Studio therefore reopens. Prompts, every Settings tab and LLM profiles
(create, edit, delete, and the default) are back to collaborative, in Agentic
Prompt Studio too. The credential concern behind locking profiles does not
hold: a profile can only point at adapters the requester already has access
to, and it never renders a key.

Renaming a project moved to a per-field check in the serializer. Settings and
the name share one PATCH endpoint, so gating the action would have closed both.

Two checks that were missing stay in place rather than reverting with the
rest. `reorder_prompts` is routed without a pk, so DRF ran no object check and
any org member could reorder any project's prompts. And `ProfileManagerView`
now uses `ParentToolAccess`: create is collection-level, so without a
`has_permission` it was open to the whole org rather than to collaborators.

Manage Keys hid nothing behind ownership, so a shared user could press New Key
and the active toggle and get a 4xx -- the toggle surfacing as "Api deployment
not found". Both are disabled now; listing and copying keys are unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Restrict file history deletion to the workflow owner

Shared access to a workflow granted read only everywhere except its file
history, where a shared user could delete single entries, bulk-clear by
filter, or wipe every file marker. All three paths are reachable from both
the ETL card and the workflow builder.

Gate the two FileHistoryViewSet write actions and clear_file_marker on the
workflow's owner, co-owner, org admin or service account. Listing and
retrieving history stay open to shared users. No UI change: the existing
exception handler surfaces the 403.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Scope the endpoint list, correct the rename status, unlock frictionless deletes

F1 (Critical): workflow_endpoint_list took the workflow id straight from the
URL, bypassing get_queryset() and object permissions -- and the response
serializes connector_metadata decrypted for S3/GCS/DB. Any org member could
read any workflow's connector credentials. Resolve the workflow through
Workflow.objects.for_user() first.

F5: the Prompt Studio rename refusal raised ValidationError, rendering as
400 {"tool_name": [...]} and painted under the name input by
AddCustomToolFormModal, while the sibling delete refusal returns 403. Raise
PermissionDenied so an authorization refusal reads as one.

F7: frictionless adapters are deletable by any org member server-side
(IsFrictionLessAdapterDelete), but the table gated delete on ownership and
AdapterListSerializer never exposed is_friction_less, so the only route to
remove a platform-provisioned adapter disappeared for non-admins. Expose the
field and exclude those rows from the delete lock. Edit stays locked.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Correct three docstrings in prompt_studio/permission.py

Each stated a rule the code does not enforce, on the sharing surface:
sharing onward is open to shared users, not owner-only; the rename is gated
in CustomToolSerializer.validate_tool_name, not on the viewset; and the
deferred import's stated reason was false -- no model imports this module,
and hoisting it creates no cycle (verified both directions). The comment is
removed rather than given a new reason I could not confirm.

Also records what the gate actually admits: direct viewers, groups and
admins, but not shared_to_org -- which IsOwnerOrSharedUserOrSharedToOrg does
admit. That divergence is finding F9 and is not fixed here.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Admit org-shared users to Prompt Studio; make disabled-control tooltips render

F9: _can_access_tool admitted owner, direct viewer, group member and admin but
not shared_to_org, while IsOwnerOrSharedUserOrSharedToOrg -- used for the same
project's settings PATCH and profile reads -- does admit it. A user whose only
access was an org-wide share could change project settings and read profiles
but got 403 on every prompt edit and profile write: half the collaborative
surface working, half refusing, for one user on one project. The frontend
cannot express that state, since usePromptStudioCanEdit collapses to a single
boolean. Admit shared_to_org so both sides agree. Verified both directions.

F3: every "hover for the reason" tooltip added by this PR sat on a `disabled`
control, which swallows mouseenter, so the trigger never fired and the reason
never rendered -- 6 of the 8 new lock affordances communicated nothing. Fixed
once in the Tooltip shim rather than at each call site: a disabled child is
wrapped so the pointer events land on the wrapper. Not pinned by a test; the
existing cascade-and-affordances suite asserts the pointer-events-none
behaviour this works around, and 63 shim tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Close the connector-credential path the endpoint-list fix left open

Findings raised by adversarial verification of the previous commits.

NEW-1 (Critical): WorkflowEndpointSerializer.connector_instance_id is a
writable PrimaryKeyRelatedField whose queryset was ConnectorInstance.objects
.all() -- org-scoped but not user-scoped, unlike every sibling. Any org member
could create their own workflow (becoming its owner, so IsParentWorkflowOwner
passes), PATCH their endpoint to a colleague's connector, and read that
connector's decrypted credentials out of the PATCH response, which
re-serialises through the nested ConnectorInstanceSerializer. This is the
surviving half of the exposure the endpoint-list fix closed. Scope the
queryset with for_user; no request in context now yields none() rather than
everything, and the only instantiation without context is read-only.

NEW-3 (Critical): WorkflowEndpointAPIView logged dict(request.headers) at INFO
-- including the internal service bearer key that authenticates it -- on the
view that returns decrypted connector metadata for any workflow in any
organization. No redaction filter exists. Drop the headers entry and lower the
line to debug. Pre-existing and outside this PR's surface; fixed here because
a logged credential that unlocks that view should not wait.

NEW-2 (High): the same stored-parent-gate-plus-writable-parent-FK shape is
open at ToolInstanceSerializer.workflow and
ProfileManagerSerializer.prompt_studio_tool. Refuse a parent change on update
at both. NOTE: with the cloud sites this is now five occurrences of one rule
patched site by site. The rule -- a gate that authorises against a stored
parent requires that parent to be non-writable -- is stated nowhere and
enforced nowhere central, so the next sub-resource added under
WorkflowOwnerMutationMixin will repeat it. Recommend hoisting the check into
the mixin; not done here, to avoid a broad refactor late in the run.

F6 (High): APIDeploymentSerializer -- the class returned for retrieve, create
and update -- did not carry is_owner, while canEditResource treats a missing
is_owner as editable. Any consumer seeding state from a non-list response
unlocked every Edit/Delete/toggle on it. Carry the field on both serializers.

NEW-6/NEW-7 (Low), both regressions in the previous endpoint-list fix: restore
the specific WorkflowDoesNotExistError 404 that get_object_or_404 had
flattened to "Not found.", and stop re-resolving the workflow row a second
time inside get_endpoints_for_workflow.

NEW-5 (Low): canEditResource's docstring claimed delete follows ownership,
which the frictionless-adapter carve-out makes false.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Close two more reparenting sites; simplify the endpoint-list fix

Two further sites of the stored-parent-gate class, both verified writable
before fixing:
- APIKeySerializer.api / .pipeline. IsParentDeploymentOwner gates every detail
  action against the stored parent, and create spends ~60 lines refusing a
  path-vs-body disagreement -- while update had nothing, so a live key could be
  retargeted onto a deployment the caller does not own.
- ToolStudioPromptSerializer.tool_id, gated by PromptAcesssToUser on obj.tool_id.

That makes seven occurrences of one rule now patched site by site: a gate that
authorises against a stored parent requires that parent to be non-writable.
Recommend hoisting it into a shared mixin beside WorkflowOwnerMutationMixin --
deliberately NOT done here, since it touches seven viewsets across two repos.

Cleanup from the review pass:
- workflow_endpoint_list reuses get_queryset() and get_serializer() instead of
  rebuilding the scoping by hand. That restores select_related and injects
  request context, which the serializer's new for_user scoping reads.
- get_queryset also joins connector_instance; the nested serializer needs it.
- Removed WorkflowEndpointUtils.get_endpoints_for_workflow, which inlining had
  left with zero callers -- a public helper carrying exactly the unscoped
  lookup this change removed.
- Dropped a comment claiming this raises "the same 404 the sibling actions do".
  It does not: the siblings raise DRF NotFound via get_object().
- Trimmed comments that retold mechanism or narrated an exploit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Make the disabled-control tooltip work beyond Button, and keep orphan profiles attachable

The span wrapper alone does not restore hover. A browser suppresses mouse
events on a disabled control and does not bubble them, so the span only
becomes the hit target once the child stops hit-testing. ui/button.tsx sets
disabled:pointer-events-none and worked; switch, checkbox, input, label,
radio-group, select and textarea set only cursor-not-allowed and did not --
which includes the live case this PR is about, the owner-only Switch in
ManageKeys. Neutralise the child from the wrapper instead of relying on the
child's own class, and give the wrapper tabIndex so the tooltip is reachable
by keyboard, which a disabled child never is.

Separately: ProfileManager.prompt_studio_tool is nullable and ParentToolAccess
has an explicit orphan branch, so refusing every value on an orphan left such
a row permanently unattachable. Only refuse a change away from an existing
parent.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Guard the workflow_id alias that bypassed the tool reparenting check

Two independent reviewers found the same hole in the guard added two commits
ago, and it is the sharpest argument yet for fixing this class structurally.

ToolInstanceSerializer declares workflow_id = UUIDField(write_only=True), and
workflow_id is also the attname of ToolInstance.workflow. validate_workflow
fires only when the payload key is "workflow"; a body of {"workflow_id": ...}
skips it entirely, and DRF's ModelSerializer.update does
setattr(instance, "workflow_id", uuid), writing the FK column directly.
IsParentWorkflowOwner has already passed, because it authorises against the
STORED parent. So the guarded key is the one no caller uses and the required
one was unguarded. Verified: the model's attname is workflow_id and the
serializer field is writable.

This is the eighth site of one rule, and the miss was an ALIAS of a field
already guarded in the same file -- enumerating the class by field name cannot
find it, only enumerating by gate can. The recommendation to hoist this into
WorkflowOwnerMutationMixin stands and is now stronger.

Also: reverted the orphan-profile relaxation from the previous commit. Letting
a NULL stored parent be attached looked like a usability fix, but
ParentToolAccess falls back to created_by for orphans, so it would let that
creator attach the profile to any project in the org. Refusing is the safer
half of the disagreement between the two reviewers.

Narrowed two comments that over-claimed. Scoping the write field and the list
action does NOT redact connector_metadata; the nested read serializer still
returns it decrypted to anyone the workflow is shared with. That residual is
escalated, not fixed here -- redacting it would change what the read-only
connector view shows shared users, which is a product decision.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Drop the focusable tooltip wrapper SonarCloud flagged

Sonar raised "tabIndex should only be declared on interactive elements"
(MAJOR) on the span added for disabled-control tooltips, and it is right: a
focusable span with no role and no accessible name is a stop in the tab order
that announces nothing, which is worse for a screen reader than no stop.

The substantive half stays -- neutralising the child's hit-testing is what
makes the tooltip open at all on a disabled Switch, Checkbox or Input, which
is the reported defect. Keyboard access to a disabled control's tooltip needs
a role and a name to be worth anything and is a larger change than this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Close the ungated tool-instance create, and cover the gates with tests

Four findings from the standardized review, plus the test coverage the whole
batch was missing.

Adding a tool to a workflow was ungated. `ToolInstanceViewSet.get_permissions`
covered update/partial_update/destroy but not `create`, and the serializer
resolved the workflow unscoped and flipped `is_active` on it -- so any org
member could attach a tool to a workflow they do not own, while this branch
disabled only the button. Gated with the `is_workflow_mutator` idiom already
used by `reorder` in the same file. `WorkflowOwnerMutationMixin` does not fit
here: its `perform_create` reads `validated_data["workflow"]`, and this
serializer carries `workflow_id`, so adopting it would fail closed for owners.

`clear_file_marker` moves from GET to POST. It clears execution markers, so a
GET made it reachable by prefetch or a pasted URL with no CSRF in the way. No
frontend caller exists.

The workflow builder's connector-type dropdown was the one control left
ungated on that card -- a shared user picked a type and got a bare failure
toast from the endpoint this branch gates.

`IsParentToolOwner` is deleted. It had zero production callers after the swap
to `ParentToolAccess`, while its test asserted the inverse of the shipped
behaviour and passed. That test now targets the live gate, with the viewer
case flipped to allowed.

Tests: shared viewer refused on endpoint update/destroy and on tool-instance
create, allowed to read; a no-access user gets 404 rather than 403; rename
refused per-field while other settings save. Each gate was broken in turn to
confirm the matching test fails.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Close the credential read path and the ungated prompt creates

Review findings D1-D4, D6, SF2 and CR4.

- Shared viewers were served the destination connector's decrypted
  credentials on the endpoint read path. Redacted for anyone who may only
  read; the previous test asserted a bare 200 and could not see it.
- create_prompt and create_profile_manager never reached the object gate,
  so any org member could write into any project. The parent now comes
  from the URL, which both runs the gate and stops the payload naming
  another project.
- clear-file-marker moved to POST but its only caller still sent GET, so
  clearing file history 405'd for everyone, owners included.
- LookupDefinition was missing from the shareable-resource registry, so
  its group shares were never purged and never shown to an admin deleting
  the group.
- Two gates filtered a UUID column on raw request data ahead of any
  serializer, turning a malformed id into a 500.
- Manage Keys gated the switch and New Key but left per-key Edit and
  Delete live, so a shared user could fill in a form that only 403s.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [DOCS] Correct five comments this change made untrue

Review findings D6, O1 and L16-2/4/6.

- PromptAcesssToUser enumerated four ways to qualify and the code has
  five; point at _can_access_tool instead of restating it.
- The share endpoint's docstring counted 7 shareable resources; there are
  eight, so name the rule rather than the count.
- The frontend helper and the Prompt Studio hook both said sharing grants
  read only, which is not true of Prompt Studio or Agentic PS.
- A test cited IsParentToolOwner, which no longer exists and whose
  successor admits collaborators.
- The read-only CSS named the HITL query builder, which no longer uses it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [DOCS] Stop the shared list widgets claiming a rule they cannot know

Cross-document sweep for the read-only rule corrected in 025c12c.

Both widgets render Prompt Studio alongside every other resource, and
Prompt Studio is shared for collaboration -- so "sharing grants read only"
is wrong for one of their own consumers. What they actually gate is edit
and delete, which is owner-only everywhere; say that instead.

The card grid also carried a second owner rule, falling back to
``created_by`` when ``is_owner`` was absent. That fallback cannot see
co-owners, and every list serializer sends the field.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [FIX] Answer the verifier: three of the last round's fixes were wrong

An adversarial pass re-found three findings and raised five more.

- The credential redaction asked who owned the workflow once per row, which
  is a membership query each time. Prefetch the rows on the endpoint
  queryset and let the owner check read them: measured 44 queries for 7
  endpoints both with and without the redaction, so it now costs nothing.
  A test pins it.
- The share docstring traded a stale count ("all 7 resources") for a
  universal that is false: lookups is the one host whose share entry is
  org-member-gated. Name the mechanism instead of the class.
- The read-only CSS comment dropped the HITL query builder as a stale
  consumer. It is not stale -- the connector modal still wraps the builder
  in that class. Stop naming a list that goes stale.
- Both new comments called a detail action "collection-level". The
  conclusion held but the reason did not: no custom @action gets
  get_object() for free, pk in the route or not.
- OwnerFieldRow kept a sessionDetails prop nothing read after the owner
  rule was collapsed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [DOCS] Name the two child-create gates separately

The docstring called both halves custom ``@action``s. Only the prompt half
is: profiles go through a plain DRF ``create`` whose parent is read from
the payload in ``ParentToolAccess.has_permission``. Conflating them points
the next reader at a decorator that is not there.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [DOCS] Drop the child-create restatement instead of rewording it again

Three attempts, three wrong descriptions -- the last one named a route
that does not exist: ProfileManagerView registers no POST, and the live
add-profile endpoint is PromptStudioCoreView.create_profile_manager, a
detail @action like its sibling.

The rule is already stated where it is enforced, once per half, in
prompt_studio_core_v2/views.py. A third copy on the test class is a
surface that has now gone stale every time it was touched, so remove it
rather than correct it a fourth time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [FIX] Reject a malformed prompt id at validation, and un-vacuum its test

The security pass caught this in the previous round's own work. The view
gate was guarded with parse_uuid, but the controller re-reads prompt_id
from validated_data and feeds it to objects.get, where a malformed value
is a 500. prompt_id was a CharField, so a complete request still reached
it -- the guard only covered the gate.

The test hid that: it omitted a required field, so it 400'd before the id
was ever parsed and would have passed against no fix at all. It now sends
a complete payload and asserts the 400, and fails if the field goes back
to CharField.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [FIX] Admit group-shared users to a workflow's file history

Opening a workflow reached through a group worked; its File History tab
returned 403. `Workflow.objects.for_user` has always included group shares,
so the workflow is listed and opens, but `IsWorkflowOwnerOrShared` stopped at
OWNER membership, direct VIEWER membership, `shared_to_org` and org admin.
The queryset and the gate disagreed, and the gate was the narrower one.

Pre-existing rather than introduced here: this branch only added the
`IsWorkflowOwnerForFileHistoryWrite` subclass for delete and clear, and left
the read path as it is on main. `FileHistoryViewSet` is the only consumer, so
that tab was the whole blast radius.

Swept both repos for the same shape afterwards. Every other call site of the
owner/viewer predicates without a group branch is deliberately owner-only --
`IsOwner`, `is_workflow_mutator`, `IsRegistryToolOwner`,
`IsFrictionLessAdapterDelete`, the co-owner serializers -- since a group share
grants read and run, never mutation. Every `for_user` either includes groups
or delegates to one that does; `AppDeployment` predates the membership model
entirely.

Tests: a group member can open file history, the owner still can, and a
non-shared org member still cannot -- widening to groups must not widen to
everyone. Verified by removing the branch again and watching the first fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015aPCgGhE6Ma2NEhB8LQP1c

* UN-2868 [FIX] Scope the deployable workflow, and pin last round's gates with tests

Two findings from the standardized review.

**Deploying someone else's workflow.** `workflow` is writable on both the API
deployment and pipeline serializers and was bound through a merely org-scoped
manager, so any organisation member could POST a deployment naming a
colleague's private workflow -- then execute it, with its connectors and
adapters, at the owner's cost, and mint API keys against it. Reachable on
create and on update. Pre-existing.

Both serializers now scope the field in `get_fields`, the pattern
`WorkflowEndpointSerializer` already uses, and refuse reparenting the way the
other guards in this branch do.

The scope is `mutable_workflows_for` -- owners, co-owners, org admins -- not
the `for_user` floor. A deployment is a persistent execution surface its
creator owns and can share onward, so standing one up is an owner act rather
than a use of a shared workflow. Co-ownership is the route for anyone else.
Switching to the looser floor is a one-line change in that helper if the
product call goes the other way.

`resources_visible_via_memberships` grows an optional `role`, so the helper
reuses its varchar/UUID cast: joining the membership table directly fails
with `operator does not exist: uuid = character varying`.

**Tests.** Last round's fixes could each be reverted with the suite staying
green. Now pinned: endpoint-list scoping and its credential redaction, both
tool-instance reparent guards including the `workflow_id` alias, file-history
delete and clear, `clear_file_marker` as POST-only, and the new deployment
scoping. Every gate was reverted in turn to confirm the matching test fails.

Two of those runs found tests that passed for the wrong reason -- the
deployment cases were being refused by the endpoint-configuration check, not
by the gate -- so they now assert the `does_not_exist` code specifically.

`CreateEndpointGrantsCreatorOwnershipTests` built its parent workflow with
`created_by` but no OWNER row, which the scoping correctly refuses; its own
docstring says `created_by` is audit-only, so the fixture now grants the row
the real create path grants.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Clear the four SonarCloud smells on this branch

- `WorkflowEndpointSerializer.to_representation` had two `return rep`
  statements. Sonar reads that as always returning the same value; it does
  not, since `rep` is mutated between them, but a single exit says so
  plainly and the rule was flagged BLOCKER.
- The ETL and API deployment status tooltips were nested ternaries. Both
  lift to a named `toggleTitle`, which also removes a second
  `canEditResource` call per row.
- `canEditResource` uses an optional chain for the in-flight-payload guard.

Behaviour is unchanged throughout; 72 permission tests and the frontend
build pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Give the deployment-provision fixture the OWNER row it needs

`test_deploy_mints_key_and_endpoint` built its workflow with neither
``created_by`` nor an OWNER membership, so the deployable-workflow scoping
added in ba4f559 correctly refused it: ``created_by`` is audit-only, and
ownership comes from the membership row the real create path writes.

The fixture now grants that row, matching ``WorkflowViewSet.perform_create``.
Same shape as the `CreateEndpointGrantsCreatorOwnershipTests` fixture
corrected in the same commit; CI reported this as the only other one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Unbreak co-owners, and let a shared user start a pipeline

Findings from a code review of the last four commits.

**Co-owners locked out (2 sites).** Scoping the writable `workflow` field to
workflows the requester owns broke every edit of a deployment or pipeline
whose workflow belongs to someone else: both forms PUT the whole record
back, `workflow` included, so a co-owner of the resource 400'd on a field
they were not changing. The bound workflow is now always selectable;
`validate_workflow` still refuses an actual move, so reparenting stays shut.

**The enable toggle.** The rule is that a shared user may start and stop what
is shared with them, and the code said owner-only on both halves. Toggling is
now an activation-only PATCH -- one field, nothing riding along -- admitted
for anyone the resource is shared with. Settings stay with the owner. The API
deployment toggle sends that narrow PATCH instead of a full PUT, as the
pipeline one already did.

**A test that could not fail.** `test_a_get_is_rejected` drove
`as_view({"post": ...})` and sent a GET, so the 405 came from its own method
map; it passed with the decorator back on `methods=["get"]`. It now asserts
the action's mapping and every route that binds it.

**The deploy picker** offered workflows the serializer then refused, with a
raw `Invalid pk` naming an id the user never typed. It lists only deployable
ones now, and the refusal says why.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [FIX] Make Enter save the HITL rules too, not just the endpoint

Greptile P1. The schema form's own submit was wired to the endpoint-only
path, so pressing Enter with both a connector edit and a rule edit pending
wrote the endpoint, announced "Configuration saved successfully", and
dropped the rules. The Save button took the coordinated path; the keyboard
did not.

Both now go through `handleSave`. It takes RJSF's validated data when the
form supplies it and falls back to state for the button, so neither path
loses the fresher value.

The Save button's `onClick` is wrapped rather than passed by reference: a
bare reference would hand the click event to that new parameter, and the
event would then be compared against the saved configuration and written
as it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBTWcZd4CFXXRZSG94TsnB

* UN-2868 [MISC] Empty commit to re-run CI

No code change. The last red run predates the fixture and SonarCloud fixes on
this branch; this re-triggers the checks against the current head.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
…rded (#2286)

* UN-2868 [FIX] Stop the connector modal reporting a HITL save it discarded

Editing the HITL rules, switching to the connector's Settings tab and saving
reported success and wrote nothing. The tabs are the antd shim over Radix,
which unmounts the pane behind an inactive tab, so leaving Manual Review
destroyed RuleEngine along with the edits and the ref the modal saves
through. `writesRules` then read false and, being false, also told the
endpoint write to post its own success toast.

Only the owner sees this: `canEdit` keeps a shared user from making the edit
in the first place.

Unmounting is not new -- it predates the sharing work and the shim is
untouched by it. What is new is that HITL lost its own Save button and now
writes through the modal's, so an unmount stopped losing a visible unsaved
edit and started making the Save button lie.

The shim gains antd's behaviour of holding a pane once it has been opened,
opt-in per pane so nothing else changes. Held from first open rather than
from render, because RuleEngine loads on mount and alerts when that fails --
mounting it up front would raise an error for a tab nobody opened. Radix
treats a force-mounted pane as present and so leaves off `hidden`; the pane
is hidden on its state instead.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Pin the ref across a tab switch, and trim the comments

The tests measured mounts; the failure is a ref. A dropped pane nulls the
handle the modal saves through, so the new test asserts a ref into a kept
pane is still callable after switching away -- it fails on its own when the
hold is removed.

Comments cut back to what is not in the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

* UN-2868 [FIX] Decide the hold once per pane

`forceMount` required the pane to have been opened; the hiding class went on
`keepMounted` alone. Only ever a cosmetic mismatch -- an unopened pane is not
rendered, so the class had nothing to hide -- but the two reading differently
invites a fix to the wrong one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UwnxaoVTsxbzufUixPGFQ9

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…, backend and SDK (#2284)

* UN-4078 [MISC] Delete the SDK Celery ExecutionDispatcher and retarget its tests

First slice of the Celery transport removal. `ExecutionDispatcher` published
`execute_extraction` to RabbitMQ and blocked on an `AsyncResult`; nothing has
selected it since UN-4046 made `get_executor_dispatcher()` return the PG
request-reply dispatcher unconditionally. It had zero non-test importers.

Deleted: `unstract/sdk1/.../execution/dispatcher.py` (316 lines), its export
from the package `__init__`, and the `TestExecutionDispatcher` suite.

The 11 workers test files that imported it are retargeted rather than dropped —
they encode live product behaviour (which queue each operation lands on, what
the payload carries), and that behaviour survives on `PgExecutionDispatcher`:

- Queue naming moves to `unstract.workflow_execution.executor_rpc.QUEUE_PREFIX`,
  the surviving definition. The `celery_executor_*` names are NOT dead Celery
  surface — `worker-pg-executor` subscribes to exactly those strings — so the
  assertions pin the literal wire names.
- New `tests/executor_dispatch_fakes.py` holds the shared fake transport, an
  eager variant that runs the task in-process (replacing the `send_task`
  monkey-patching four round-trip tests did), and a correctly-shaped callback
  signature builder.
- The header-forwarding suite is replaced, not ported: PG dispatch takes no
  `headers=` by design, so the new tests assert org routing rides the payload
  AND that passing `headers=` still raises — the exact mistake that broke three
  call sites during UN-4046.
- The raw-`send_task` canary is kept and its message updated; it matters more
  now, since a raw send_task publishes to a broker nothing drains.

Two docstrings that claimed executor calls "go through Celery" were corrected —
they became false with this change, not merely dated.

Verification: workers 1517 passed / 1 skipped. sdk1 unchanged at 20 pre-existing
failures (`test_llm_compat`, `test_litellm_cohere_timeout`), reproduced on a
stashed tree to confirm they predate this work. Pre-commit clean on all 17 files.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4078 [MISC] Delete the Celery execution transport

Removes the Celery fan-out substrate and the per-execution transport field.
Nothing has selected either since UN-4046 made `select_backend()` return PG
unconditionally, but both were still compiled, imported and shipped — and
importable Celery surface has already cost two incidents (UN-3779's hardcoded
dispatcher, and the `headers=` breakage at three call sites during UN-4046).

Deleted outright:
- `queue_backend/redis_barrier.py` (761 lines) — the DECR-counter substrate
- `CeleryChordBarrier` + `BarrierBackend` + `get_barrier()` + the
  `WORKER_BARRIER_BACKEND` env selector
- `WorkflowTransport`, `DEFAULT_WORKFLOW_TRANSPORT`, `normalize_transport`,
  `is_pg_transport` (88 lines of `unstract/core/data_models.py`)
- `barrier_pg_decr_and_check` — a thin @worker_task wrapper reachable only as a
  Celery `.link`; the decrement runs in-body via `_barrier_pg_decrement`

Collapsed: the 10 `is_pg_transport` branch sites, the `transport` parameter
threaded through the general/api/scheduler orchestrators, the `transport` field
on `WorkflowContextData` and `FileProcessingContext`, the `is_pg` flag on the
file-batch path, and the `transport` key the backend wrote into the dispatch
payload. `_barrier_for_transport` becomes `PgBarrier()`.

Two guards become unconditional rather than PG-gated: the terminal-execution
skip and the duplicate-destination-write check. Both were only ever no-ops on
Celery.

This also starts retiring a documented live hazard. `DEFAULT_WORKFLOW_TRANSPORT` was
`"celery"`, so a payload that lost the field during a rolling deploy selected a
Celery fan-out with no consumers. Worth correcting the in-code note while
deleting it: it claimed `CeleryChordBarrier` was selected and no PG rows were
written, so the reaper could not see the strand. Neither held — every PG worker
sets `WORKER_BARRIER_BACKEND=pg`, so `PgBarrier` was selected, and its
`_reset_barrier` UPSERT is unconditional, so the barrier row was written and the
reaper did see it. Real behaviour was a ~2.5h delayed ERROR.

The reads are removed here, but the writes are NOT: a pre-UN-4078 worker still
defaults an absent field to Celery, so during a rolling deploy it would publish to
a RabbitMQ with no consumers. The next commit keeps writing the field for one
release; the hazard is gone only once that shim is removed.

`EXECUTION_EXCLUDED_PARAMS` deliberately keeps its `"transport"` entry: a
rolling deploy can still have an un-upgraded producer emitting the field, and
without the exclusion it reaches the legacy `execute_workflow` signature and
raises.

Tests: four whole suites deleted with their substrate (redis barrier, barrier
backend selection, barrier differential, workflow-context transport). The rest
are retargeted rather than dropped — the chord canary now asserts *zero* chord
call sites (with a non-vacuity lock, since a zero-expectation assertion is
exactly what a path bug passes silently), and the Celery-gated guard tests are
replaced by the unconditional behaviour they now have.

Verification: workers 1438 passed / 1 skipped / 0 failed. Pre-commit clean.
The backend suite cannot start in this environment (`AppRegistryNotReady`,
reproduced on a stashed tree — pre-existing), so the four backend edits are
CI-verified only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4078 [MISC] Keep writing the transport field for one release as a rolling-deploy shim

The previous commit removed every read of the `transport` payload field and
every write. Removing the writes in the same release is unsafe: a pre-UN-4078
worker still treats an absent field as "celery" (DEFAULT_WORKFLOW_TRANSPORT)
and, during a Kubernetes RollingUpdate, can receive a payload from an upgraded
producer. It then publishes to RabbitMQ, which has no Celery consumers, so:

- general workflows are marked ERROR by the reaper after ~2.5h, even with every
  file processed;
- API deployments take no orchestration claim, so nothing sweeps them and they
  stay EXECUTING.

The window is real: old file-processing pods keep draining claimed batches for
their full grace period (up to 9120s).

The four writes are restored as `transport: "pg_queue"`, all via one constant,
LEGACY_TRANSPORT_KEY / LEGACY_TRANSPORT_VALUE in unstract.core.data_models, so
the follow-up removal is a single grep:

- backend WorkflowHelper orchestrator dispatch payload
- backend create_workflow_execution response
- workers scheduler async_execute_bin kwargs
- workers PgBarrier callback descriptor (CallbackDescriptor gains
  `transport: NotRequired[str]`)

Nothing on the new side reads the field. Tests pin each write so the shim cannot
be dropped early; the scheduler cases also assert a stale "celery" in the
backend response is never forwarded.

Remove in the release after UN-4078: the writes, the constants, the
`transport` entry in EXECUTION_EXCLUDED_PARAMS, and test_legacy_transport_shim.py.

Verification: workers 1478 passed / 1 skipped / 0 failed; backend dispatch
tests 5 passed (with backend/sample.env loaded); pre-commit clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4078 [MISC] Address standardized review: shim gating tests, dead branches, stale comments

Review findings on #2284, highest first.

#1 (High) — half the rolling-deploy shim had no gating test that runs in the
default lane. The create_workflow_execution response write had none at all, and
the PgBarrier descriptor was pinned only by a test behind the barrier_db fixture,
which skips wherever Postgres is absent. Descriptor construction moves to
build_callback_descriptor(), pinned by a DB-free suite
(workers/tests/test_legacy_transport_shim.py); the response write is pinned by a
new backend case. All four writes now fail a required build if dropped early.

#2 (High) — _process_file_batch_core's Args entries still described
barrier_context=None as the supported Celery chord path while the body treats it
as a malformed payload. Both entries rewritten; the log line now carries
execution/workflow ids and the batch size.

#3 — process_file_batch_django_compat delegates with two arguments, so after this
PR every call landed in that malformed-payload branch: a false ERROR, and a full
batch run with no claim (no pg_batch_dedup marker, so a redelivery re-runs it —
LLM spend twice and a duplicate destination write with use_file_history=False).
Its only producer was the Celery chord this PR removes, so it now refuses, the
same fail-fast choice step_execution makes. Its MRQ helpers are left in place per
the no-removal rule.

#5 — PG_TRANSPORT_CALLBACK_KWARG became unconditional but its consumers still
branched on it, leaving an else branch that swallows a failed finalization and
strands the execution. The marker is now popped for wire compatibility only;
the duplicate guard and the re-raise are unconditional. is_pg on
_update_execution_status_unified becomes raise_on_failure (default True); the one
caller inside an except block opts out so a raise cannot mask the original error.

#6 — the removal checklist now lists all five artefact groups, and
EXECUTION_EXCLUDED_PARAMS names the key through LEGACY_TRANSPORT_KEY instead of a
hardcoded literal grep would miss.

#7 — CallbackDescriptor.transport is Literal["pg_queue"] and required, not
NotRequired[str]: it is mandatory this release, "celery" must never be written,
and the follow-up removal becomes a type error at the write site.

#8 — the Barrier protocol's "two call sites still program against it" was untrue;
both construct PgBarrier concretely. Corrected, with what actually keeps it.

#10 — executor_dispatch_fakes reimplemented the queue-naming rule and accepted
any enqueue signature. queue_for now delegates to a new production helper
queue_for_executor() (also used by the three dispatch sites), and the fake mirrors
the QueueTransport protocol's keyword-only key set.

#11, #14 — stale "PG only" / "No-op on Celery" comments at the sites this PR made
unconditional, a citation of the deleted Celery max_retries=0 wrapper, and a
cross-reference to a chart file that lives in the cloud repo.

Five tests that asserted the removed Celery branches are restated against the
collapsed behaviour rather than deleted.

Verification: workers 1481 passed / 1 skipped; backend shim, dispatch-orchestrator
and async-wait tests pass; pre-commit clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
… so nested cell values expand again (#2288)

* UN-4124 [FIX] Implement antd's expandable on the shared DataTable

The Ant Design removal (#1683 / #2212) replaced `<Table>` with the shared
DataTable, which never implemented antd's `expandable` API. Undeclared, the
whole object fell into `...props` and was spread onto the wrapper <div>, where
React ignores it — so `expandedRowRender` was never called and the table
rendered as if the prop had not been passed, with no console error.

HITL's review editor is the visible casualty: an array or object inside a table
cell shows a truncated JSON blob with an expand button beside it, and clicking
that button did nothing at all, leaving nested values unreadable in Table view.

Support `expandedRowRender`, controlled `expandedRowKeys` and uncontrolled
`defaultExpandedRowKeys`, `rowExpandable`, `showExpandColumn`, `expandIcon`,
`expandedRowClassName`, `onExpand` and `onExpandedRowsChange`. The panel is a
sibling <tr> spanning every column, since a row may only contain cells.

Keys are compared as strings because that is what TanStack's `getRowId` (and so
`row.id`) produces: a call-site numbering its rows `key: index` passes numbers,
and `[0].includes("0")` is false — a mismatch that would have hidden every
expansion on its own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4124 [FIX] Report expansion keys in the caller's own type

Review feedback on #2288.

`onExpandedRowsChange` built its argument from the internal `expandedKeys`
set, which is normalized with `.map(String)` so it can match TanStack's
`row.id`. That normalization leaked out: a controlled caller that passed
`[1]` was handed back `["1"]`, and a parent testing `includes(1)` against it
would never match.

Normalization is now strictly internal. The reported array is built from the
caller's own keys — `controlledExpandedKeys ?? ownExpandedKeys` — appending
`originalRowKey()`, which reverses `getRowId`'s stringification by reading
`rowKey` off the record directly. Collapse compares with `String(k) !== key`
so it can filter a normalized id out of an un-normalized list without
rewriting the survivors' types.

Expansion itself is unaffected: `expandedKeys` is still a string Set, so
matching behaves exactly as before. `defaultExpandedRowKeys` no longer
stringifies on the way into state, since the memo normalizes anyway.

Both directions are covered and mutation-checked: stringifying the appended
key fails the expand test, and filtering the normalized set fails the
collapse test.

Also addresses SonarCloud S9020 — `waitFor` + `getByText` becomes
`findByText` in the nested-panel test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* [MISC] Mark use_file_history as internal in the published spec

The field is accepted by the server but not documented, and the CLI builds
its flags from this spec, so it exposed the option. Dropping the field would
break the generated clients that forward it by name, so it stays in the
serializer and in the spec with an `x-internal` marker a reader can honour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ

* [MISC] Withdraw use_file_history from the published spec instead of marking it

The option is internal-only, so nothing generated from the spec should offer
it: the serializer keeps accepting it, the spec no longer declares it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ

* [MISC] Drop comments the decorator and test name already cover

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014f9oEEYspPH4fmPULTnLkJ

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* UN-4128 [FIX] Stop the Pipeline list page 500ing on the workflow-scoping guard

PipelineSerializer.get_fields() (UN-2868, #2273) scopes the workflow field
to the requester's own workflows, guarding the single-instance case with
`self.instance is not None`. On a list request DRF hands the child
serializer of a many=True ListSerializer the whole queryset as
self.instance, not one row -- `is not None` let that through and
`.workflow_id` crashed on a list.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018KZLGSa3oWxgVJdFqvRUQX

* UN-4128 Add regression test for pipeline list serializer crash

Covers the AttributeError DRF's paginated list GET triggers when
self.instance is a list instead of a Pipeline or None.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-4128 Cover the public many=True constructor in the list-scoping test

Greptile asked for a test that exercises PipelineSerializer(queryset,
many=True) directly instead of hand-setting self.instance. DRF's
many_init passes the same instance to the child, so both paths already
caught the regression, but this removes any doubt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…cutor RPC caller for an hour (#2289)

* UN-4126 [FIX] Stop a NUL in an extracted value from stranding the executor RPC caller for an hour

A finished executor task could fail to hand its result back, leaving the
blocking caller to wait out the full EXECUTOR_RESULT_TIMEOUT (3600s) on work
that had already succeeded, then error. Seen in prod us-central and reproduced
in a dev namespace.

`store_result` encodes the reply with `json.dumps` and inserts it as
`%s::jsonb`. That cast makes Postgres *parse* the JSON, and `jsonb` stores
strings as `text`, which cannot hold a NUL:

    psycopg2.errors.UntranslatableCharacter: unsupported Unicode escape sequence
    DETAIL:  \u0000 cannot be converted to text.
    CONTEXT: JSON data, line 1: ..."output": {"invoice_number": "POZFBBOK\u0000...

LLMWhisperer's `native_text` mode returns a PDF's embedded text layer verbatim,
NUL bytes included; the byte travels through the prompt into the extracted
output. The consumer then logs-and-acks (deliberately, to avoid an expensive
re-run), so a write that never lands is a reply that never comes.

This is PG-only and did not exist before. Celery's result backend stored the
reply object in a `PickleType` column (bytea), where a NUL is just a byte; the
payload shape never changed, only the storage medium. The code has been
reachable since #2108 (Jul 23) but became reachable for *all* traffic when
UN-4046 made `get_executor_dispatcher()` return the PG dispatcher
unconditionally.

Adds `unstract.core.jsonb` as the single place that knows what `jsonb` refuses
(NUL, lone surrogates, NaN/Infinity) and points all three PG-queue jsonb writers
at it. They previously carried three different partial defences — the barrier
guarded NUL reactively, the producer guarded NaN, the result backend guarded
neither — which is why an existing regression test for this exact hazard
(`test_nul_byte_result_tears_down_barrier`) could not catch it.

Strings are repaired rather than rejected: the work is done and paid for, and a
stray control byte is not a reason to discard an extraction. Numbers are not —
`NaN` has no correct jsonb spelling and coercing it would corrupt a value, so
`allow_nan=False` is enforced and the caller decides.

`store_result` now always writes a row. If the payload is still unstorable it
degrades to a `failed` row carrying a diagnostic message, so the caller fails in
seconds instead of hanging. That is the property that made this invisible for
two weeks, and it is now the one under test.

Behaviour change worth review: `allow_nan=False` moves NaN/Infinity in a barrier
header result from the DataError path to the encode path. That class used to
reach the barrier teardown, so the teardown is extended to the encode-failure
branch — without it a NaN would newly hang the barrier to its ~6h expiry.

No migration: `pg_task_result.result` stays `jsonb`.

Tests: new `unstract/core/tests/test_jsonb.py` (15); three DB-backed tests in
`test_pg_result_backend.py` including a literal reproduction of the production
payload; `test_pg_barrier.py`'s NUL test inverted to assert repair, plus a new
teardown test for the unencodable case. Full workers suite 1485 passed against a
real Postgres.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Close the same jsonb gap on the enqueue path, which loses a callback instead of hanging

An audit of every raw-SQL jsonb writer in the PG queue found three more sites on
the plain `json.dumps` path. One of them carries the SAME payload as the bug this
branch already fixes.

`consumer._handle` self-chains a continuation with `prepend=eager.result` — the
executor result dict, byte-for-byte the object `store_result` stores. It reaches
`PgQueueClient._insert_message`, which encoded with a plain `json.dumps` and
inserted as `%s::jsonb`. A NUL in an extracted value therefore fails the INSERT
on this branch too.

The symptom differs, which is why it was not visible alongside the original
report. `_chain_continuation` is documented "never raises" (a failure must not
wedge the executor message's ack), so the enqueue failure is swallowed and the
run falls back to `on_error`. That fallback prepends only a `task_id` string, so
it succeeds — meaning the user is shown a FAILURE for work that completed, with
the LLM spend already made. No hang, no error in the caller's logs, just a wrong
terminal state.

Sites changed:

- `pg_queue/client.py::_insert_message` — the workers' enqueue. Kept without a
  `default=` so the TypeError-on-UUID contract that `consumer._json_safe`
  compensates for is unchanged.
- `pg_queue/consumer.py::_json_safe` — the prepend coercion; the same pass now
  strips what jsonb refuses instead of only coercing UUID/datetime.
- `pg_queue/pg_scheduler.py` (x2) — scheduler-built payloads. Lower risk (they
  carry schedule rows, not document content) but the same shared rule, so the
  remaining `json.dumps` in the tree is gone.

Audited and deliberately NOT changed: `pg_barrier.py`'s `'[]'::jsonb` literals
(no user data) and `liveness.py`'s `json.dumps(...).encode()` (an HTTP body, not
SQL).

Test: `test_send_repairs_a_nul_instead_of_failing_the_enqueue` in
`test_pg_queue_client.py` — real Postgres, sends the executor-result shape with a
NUL and asserts the message lands and reads back repaired. Full workers suite
1486 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Keep valid surrogate pairs — strip only the halves jsonb rejects

Greptile P1 on #2289, and it is correct: `JSONB_UNSAFE_RE` was the character
class `[NUL + U+D800-U+DFFF]`, which matches BOTH halves of a well-formed
surrogate pair. An emoji in an extracted result reaches Python as a high+low
pair, so the sanitiser deleted it outright — silent data loss, in the module
written to prevent silent data loss.

Verified against a live Postgres rather than reasoned:

    valid pair (U+1F600)  -> ACCEPTED  {'a': 'GRINNING FACE'}
    lone high  (U+D800)   -> REJECTED  invalid input syntax for type json
    lone low   (U+DC00)   -> REJECTED  invalid input syntax for type json
    NUL        (U+0000)   -> REJECTED  unsupported Unicode escape sequence

So a pair is storable and must be preserved; only unpaired halves are unsafe.
The pattern now matches NUL, a high surrogate with no low after it, and a low
surrogate with no high before it. `re.sub` evaluates against the original
string, so a deletion cannot fuse two neighbours into a spurious "pair".

Tests: four new cases in test_jsonb.py (pair preserved, lone low stripped, only
the unpaired half stripped, pair intact in the encoded output) and a DB-backed
round-trip in test_pg_result_backend.py storing an emoji through the real
result store. Full workers suite 1487 passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Close the three High findings from the standardized review

All three reproduced independently before fixing; none was taken on trust.

1. `default=` hook output bypassed sanitisation (jsonb.py)

   `sanitize_for_jsonb` runs before `json.dumps`, so a string the hook
   manufactures at encode time was never inspected — and `str` on an exception
   carrying document text is exactly how a NUL gets there. The module's stated
   guarantee was false at every call site passing a hook: a NUL-bearing object
   coerced via `default=str` re-emitted the escape jsonb refuses. The hook is
   now wrapped rather than passed through. No call site changes.

2. `RecursionError` escaped both degradation seams (jsonb.py)

   The Python pre-walk sits in front of a C encoder that has its own cycle
   check, so a circular reference raised `RecursionError` where `json.dumps`
   raised `ValueError`, and nesting past ~1000 newly failed. `RecursionError`
   subclasses `RuntimeError`, so `except (TypeError, ValueError)` missed it at
   both seams — re-creating the exact strand this PR removes, on a new input
   class. Now re-raised as `ValueError` so the documented contract and every
   downstream seam keep working.

3. "A row is always written" was false (result_backend.py)

   `ProgramLimitExceeded` (SQLSTATE 54, "string too long") subclasses
   `OperationalError`, so it was absent from the `DataError` net and present in
   `CONN_DEAD_ERRORS`: an oversized result was misread as a dead connection,
   pointlessly retried, then escaped — no row, caller stranded for the full
   3600s. Signalling was also skippable, and two inserts were unguarded.

   Departed from the review's suggested fix here. It proposed `except
   Exception`; that breaks three existing tests encoding a deliberate contract
   (infrastructure errors propagate) and, worse, would record a database outage
   as "payload unstorable" — hiding an outage behind a content error. Content
   rejections are a knowable, closed family, so they are enumerated in
   `_PAYLOAD_REJECTED_ERRORS` instead. Signalling moved into a `finally`, and
   the degraded insert is now itself best-effort.

Also from the review (Medium): `status="failed"` now has a third meaning —
completed-but-undeliverable. Recovery logic that retries every `failed` reply
key would re-run an executor task that already finished, a second full LLM
spend. Documented on both the backend module and `PgTaskResult`.

Testing, addressing the review's strongest finding — every real-Postgres
assertion for this fix sat in `integration-workers`, which is `optional: true`
and cannot turn CI red. The strand-prevention contract now has DB-free tests in
the gating `unit-workers` lane (`TestStoreResultNeverStrands`), using a fake
cursor: oversized result still records a failed row, unencodable result still
records a failed row, and the waiter is signalled even when every write fails.
Plus three encoder tests in the gating `unit-core` lane.

Full workers suite 1490 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Close the four open High findings, and the barrier's sibling of the bug this branch just fixed

Two of these are regressions introduced by the previous commit.

1. The `finally` test asserted nothing (test_pg_result_backend.py)

   `test_waiter_is_signalled_even_when_every_write_fails` injected
   `ProgramLimitExceeded` — a member of the rejected family — so
   `_write_outcome` caught it, `_insert_degraded` swallowed the second failure,
   and the method returned NORMALLY. The `finally` was never the reason the
   signal fired, so deleting it left the suite green: a future refactor moving
   the call back to a trailing statement would have silently restored the
   strand.

   Split in two. The raising path now injects an error OUTSIDE the rejected
   family so it propagates and only the `finally` can signal; the swallowing
   path keeps the original scenario. Mutation-checked: removing the `finally`
   now fails the first and passes the second, which is the point.

2. `Raises:` named ValueError for a TypeError case (jsonb.py)

   The previous commit rewrote this block and kept the false clause, describing
   one condition with two classes. `json.dumps` raises `TypeError` for an
   unserialisable type with or without a `default`; `ValueError` is only
   NaN/Infinity and the recursion conversion.

3. The shared status enum was not extended (data_models.py)

   `PgTaskStatus` exists so the workers writer and backend reader agree on this
   vocabulary — its docstring says exactly that. Both consumers' docstrings were
   updated last commit; the canonical definition still read "task raised", so a
   maintainer reading the source of truth got the contract this branch
   invalidated and would build the retry that double-spends. Documented there,
   including why there is no third status value (it would break every existing
   reader) and that the discriminator is therefore the error text.

4. Two of four jsonb sinks had no gating coverage

   The previous commit closed this for `result_backend` and `jsonb`; `client.py`
   and `pg_barrier.py` were still verified only by `integration-workers`, which
   is `optional: true`. Both now have DB-free tests in gating lanes.

5. The barrier carried the same DataError-only net (pg_barrier.py)

   This branch's whole argument is that `ProgramLimitExceeded` subclasses
   `OperationalError` and slips a `DataError`-only catch. `result_backend` was
   widened; its sibling was not — an oversized header result was misclassified
   as a dead connection, the same oversized UPDATE re-sent, then it escaped and
   the barrier hung to `expires_at` (~6h) with no actionable log.

   The classification tuple moves to `pg_queue/connection.py` beside
   `CONN_DEAD_ERRORS`, for the reason that module already gives for hoisting the
   other one: both sites classify on it and must not drift. The comment records
   the trap explicitly — `ProgramLimitExceeded` is absent from `DataError` AND
   present in `CONN_DEAD_ERRORS`, so payload rejection must be tested first.

Full workers suite 1493 passed, 1 skipped; core 22 passed.

Deliberately still open, and recorded as deferred rather than waived: the
sibling message asymmetry (consumer.py:660), the stale `json.dumps` docstring
(consumer.py:751), the producer's per-field sanitisation, key-collision and
mutation-logging in the sanitiser, and the degraded row's ON CONFLICT semantics.
The last two are design decisions that want an owner's call, not a default.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Stop the barrier decrement releasing the reaper's recovery handle

Regression introduced by this branch, found by the standardized review and
confirmed independently. It is worse than the bug it was trying to avoid.

The first commit extended the barrier teardown to the encode-failure branch,
reasoning that `allow_nan=False` moved NaN/Infinity onto that path so the
teardown should follow it. That reasoning checked only which exception reached
the branch — never whether tearing down there was correct at all. It is not.

`run_batch_with_barrier` is the only caller, and it deliberately implements the
opposite ordering:

    if _mark_execution_error_on_abort(barrier_context, reason=reason):
        _abort_barrier_in_body(...)          # confirmed terminal -> release
    else:
        # Mark unconfirmed (backend down / no org): leave the barrier row so
        # the reaper marks it ERROR and reclaims it at expiry. Do NOT erase the
        # handle — that's the strand this ticket fixes.

`_barrier_pg_decrement` called `_delete_barrier` and then re-raised, so by the
time that `else` runs the row is already gone. The execution stays EXECUTING
with no `pg_barrier_state` row for the reaper's sweep to find — permanently
stranded, not hung-to-expiry — and stale EXECUTING rows also make the workflow
look busy to later runs (`SourceConnector._get_active_workflow_executions`).

Both `_delete_barrier` calls on the raising paths are removed; the failures now
propagate and the caller's mark-then-release ordering governs. The two calls
that remain in the function are on non-raising paths (expiry/replay at
`remaining < 0`, and cleanup after the callback fires at `remaining == 0`), so
they cannot pre-empt the caller's handler — verified by AST walk.

Scope note: this removes the pre-existing teardown on the DB-rejection branch as
well, not only the one this branch added. The two were the same defect; leaving
one would keep the strand reachable for oversized payloads.

Tests: both tests that pinned the old behaviour now assert the new contract —
`test_unencodable_result_raises_and_leaves_the_row_for_the_caller` (real PG,
asserts the row survives) and `test_oversized_result_propagates_without_
releasing_the_handle` plus a new encode-side twin (both gating, assert
`_delete_barrier` is never called). Full workers suite 1494 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Gate the result-backend encoder, and stop the error-only path stranding

Four findings from the second standardized review, all in the result backend.

1. The encoder swap had ZERO gating coverage. Every test that proved
   `dumps_for_jsonb` was in use ran against real Postgres, and `conftest.py`
   marks those `integration`, which routes them to `integration-workers` —
   `optional: true`, so it cannot fail CI. Reverting the call to plain
   `json.dumps` left the gating lane green at `15 passed, 17 deselected`.
   Three mock-level tests now assert on the SQL parameter itself:
   `test_result_payload_is_sanitised_before_the_jsonb_cast`,
   `test_error_text_is_sanitised_before_the_insert`,
   `test_rejected_error_text_also_degrades_to_a_row`. With the sanitisers
   reverted the lane now reports `2 failed, 16 passed` — mutation-verified,
   not assumed.

2. The `result is None` branch of `_write_outcome` could still strand. The
   completed branch catches `_PAYLOAD_REJECTED_ERRORS` and writes a degraded
   row; the failure branch did not, so an error string the database refused
   (a NUL from a traceback quoting extracted text, or one over the field
   limit) propagated with no row written at all — the exact strand this
   ticket exists to close, reachable from the error path instead of the
   success path. It now degrades the same way.

3. The surrogate round-trip test could not fail. `chr(0x1F600)` is a single
   code point on a wide build, not a surrogate pair, so the naive
   `[\x00\ud800-\udfff]` class the previous commit removed would have left it
   untouched and the test would have passed against the bug it was written to
   catch. It now builds the pair explicitly — `chr(0xD83D) + chr(0xDE00)` —
   and asserts Postgres recombines it to `chr(0x1F600)`.

4. `PgResultBackend.PAYLOAD_UNSTORABLE_ERROR` in the `PgTaskStatus.FAILED`
   docstring was a dotted path that does not resolve; the constant is a module
   attribute, not a class one. Corrected to
   `queue_backend.pg_queue.result_backend.PAYLOAD_UNSTORABLE_ERROR`.

Tests: `test_pg_result_backend.py` 35 passed. Full workers suite 1497 passed,
1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Record the encoder's measured cost, and why the fast path was rejected

Regression sweep before merge. The sanitising walk rebuilds every container and
string on the way to the database, so it is the one place on this branch that
could cost something on the hot path. Measured rather than assumed: ~11x a plain
`json.dumps` on a 165 KiB result (0.38 ms -> 4.2 ms) and ~16x on 2 MB
(3.1 ms -> 49 ms).

Accepted. One encode per stored result, against a task that took seconds, at a
measured platform ceiling of ~4-6 executions/s spread across the executor fleet.

The obvious optimisation was built and thrown away, which is the part worth
recording: encode first, scan the OUTPUT for the escapes jsonb refuses, and walk
only on a hit. It is correct and it is fast, but the walk is recursive and the C
encoder is not, so it made a deeply nested payload succeed or fail depending on
whether it also contained a NUL -- and it broke
`test_excessive_nesting_raises_valueerror`, which pins exactly that contract.
Trading a consistent contract for milliseconds nobody is waiting on is the wrong
way round, so the note explains the cost instead of the code chasing it.

No production code changes in this commit.

Tests: unstract/core 22 passed. Full workers suite 1497 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Stop an oversized payload being re-sent as if the connection died

Greptile's remaining P2 on this PR, and it is right. The previous commit
documented the trap and left it in place.

`ProgramLimitExceeded` ("string too long", SQLSTATE 54) subclasses
`OperationalError`, so it is a member of BOTH `PAYLOAD_REJECTED_ERRORS` and
`CONN_DEAD_ERRORS`. Every retry site classified with a bare
`isinstance(exc, CONN_DEAD_ERRORS)`, which answers "dead connection" for a
payload the server merely refused: the handle is condemned and closed, a
reconnect happens, and the write is re-sent once before the caller's
payload-rejection handler finally sees it. It can never land, and the payload is
by the nature of this error very large — so the cost is a pointless reconnect
plus a second large write, on the path that is already degrading.

The order is no longer left to each call site. `is_connection_dead` in
connection.py is now the single place that decides, testing the payload family
first, and the four retry sites ask it instead of testing membership themselves:
`PgResultBackend._cursor` / `_store_with_reconnect`, `PgQueueClient._cursor` /
`send` / `delete`, and the barrier's `_recover_after_error` / `_run_with_reconnect`.
A rejected payload leaves the connection perfectly usable, so it also stops
being closed.

Tests: `TestPayloadRejectionIsNotAConnectionDeath` covers the predicate directly
(including an assertion that ProgramLimitExceeded really is an OperationalError,
so the test fails if psycopg2 ever changes the hierarchy this reasoning rests on)
and the behaviour end to end — one rejected INSERT then the degraded row, no
reconnect, connection not closed. Mutation-verified: restoring the bare
`isinstance` turns the gating lane red (2 failed, 20 passed).

Full workers suite 1501 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4126 [FIX] Stop a genuinely failed task being recorded as completed-but-unstorable

Praveen's review finding on #2289, and it is correct.

`_write_outcome`'s `result is None` branch runs only when the task RAISED. When
the database refused its error text, that branch called `_insert_degraded`,
which wrote `PAYLOAD_UNSTORABLE_ERROR` -- and this PR's own contract makes that
exact string the signal for "the task completed; retrying this reply key is a
second full LLM spend". So the one case where a retry is not merely safe but
correct was being labelled as the one case where it must not happen, and the
task's real error message was dropped on top.

`ERROR_TEXT_UNSTORABLE` is the twin for the failure channel, saying plainly that
the task did NOT complete and the reply key is safe to retry. `_insert_degraded`
now takes the text as a keyword argument, defaulting to the completed-branch
constant since that is the common path. Two constants rather than one
parametrised string, so the difference is visible at both call sites.

The module docstring and the `PgTaskResult.status` comment previously described
`failed`'s third meaning as a single case; both now describe two, and say
explicitly that seeing `failed` is not enough -- recovery logic must match the
error text, because the two point in opposite directions.

Why the existing test missed it: `test_rejected_error_text_also_degrades_to_a_row`
asserted only that a second row was written with status `failed`, never what it
said. It now asserts the text, and a twin test pins the completed branch from the
other side; both assert the two constants are not each other, so swapping them
fails rather than silently passing. Mutation-verified -- restoring the original
call turns the gating lane red.

Full workers suite 1502 passed, 1 skipped.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ns complete instead of timing out (#2294)

* UN-4137 [FIX] Stream LLM completions under the hood so long generations complete instead of timing out and being replayed

A non-streaming completion keeps the socket silent until the last token.
On Anthropic, generations the console finishes in ~16 minutes never
arrived: litellm.Timeout after 900 s (staging) and after 1800 s
(production), then replayed up to 4x by the retry helper because Timeout
is retryable, until the Celery time limit killed the task.

- LLM.complete() streams (stream=True), collects the chunks and rebuilds
  the full response with litellm.stream_chunk_builder; callers unchanged.
- collect_with_retry: retry only before the first content chunk; a drop
  after content started is raised immediately so a long generation is
  never replayed, and chunks from a failed attempt are discarded.
- "Enable Streaming" checkbox on all LLM adapter forms, default on;
  adapters without a stored value stream too. Opt-out for endpoints that
  cannot stream. Read from raw adapter metadata, never sent to litellm.
- Anthropic Timeout description now reflects per-chunk semantics.

Verified locally on the reporting project: 12-minute Sonnet 4.6
generations (64k / 67k completion tokens) complete with no retry under
the same 900 s / 3-retry settings that failed in staging.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* UN-4137 [FIX] Retry request errors raised while creating the stream

collect_with_retry created the stream outside its protected block, so an
error raised by fn() itself escaped on the first attempt. litellm's
streaming completion() sends the HTTP request when called, which is
exactly where a 429/5xx/connection error surfaces, so those requests
would have failed where the non-streaming path retried them.

Create the stream inside the try block; a failure there is a failed
request and retries like before. Tests cover fn() raising before
returning an iterable, both retryable and not, and the same through
LLM.complete() with a RateLimitError from completion().

Addresses Greptile finding on PR #2294.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* UN-4137 [FIX] Keep mocked completions on the non-streaming path

The e2e rig mocks the LLM through litellm's mock_response and counts
calls with litellm's fixed mock usage (10/20/30), which litellm reports
only on the non-streaming path; the streaming mock token-counts the real
prompt instead. A mocked completion never touches the network, so
streaming buys nothing there. Bypass streaming when a mock response is
injected, restoring the rig's contract.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
…nd a configurable metrics database (#2287)

* UN-4123 [FEAT] Support TLS to Redis, and keep pooled/idle connections healthy

Makes an encrypted connection to Redis possible so the platform can run against a
managed endpoint (Memorystore / ElastiCache / Azure Cache, which disables its
non-TLS port by default). Chart-side support for pointing at an external Redis is
UN-4122; password-only auth already worked, so what was missing was TLS.

Everything here is additive and inert by default: with nothing configured, the
local/in-cluster path builds exactly the client it built before.

**The scheme is the switch.** `{prefix}URL` (falling back to REDIS_URL) is handed
to redis.Redis.from_url, and `rediss://` selects TLS on its own — no separate
"use TLS" flag to forget, and `redis://` behaves as today. Discrete host/port vars
remain the primary path: they need no percent-encoding, and they are what the Helm
chart, every sample.env and the non-Python services (api-hub, llm-whisperer) read.

Two redis-py behaviours that bite silently, both handled and pinned by tests:

  * The URL path beats a `db=` kwarg. sdk1 metrics asks for db=1 explicitly, so a
    URL ending in /5 would have moved its keys into another service's keyspace
    with nothing to show for it. The path is stripped when an override is given.
  * `ssl=True` into a ConnectionPool does NOT fail at construction — the pool
    defers kwargs to the connection class, so platform-service (max_connections=10)
    started healthy, kept the PLAIN Connection class, and raised
    `TypeError: AbstractConnection.__init__() got an unexpected keyword argument
    'ssl'` on its first command. Pooled TLS now selects SSLConnection instead.

Also fixed, because they are the same class of silent failure:

  * `{prefix}SSL` falls back to REDIS_SSL. Enabling TLS platform-wide previously
    meant remembering CACHE_REDIS_SSL and MANUAL_REVIEW_REDIS_SSL too, and a
    forgotten one is a plaintext client dialling a TLS port.
  * django-redis 5.4.0 ignores both DB and USERNAME from OPTIONS (verified against
    the installed 5.4.0: make_connection_params reads only PASSWORD and timeouts).
    The db now travels in the LOCATION URL, so the backend cache stops sitting on
    db 0 while every other service honours REDIS_DB — with REDIS_DB=N the workers
    RPUSH log_history_queue to db N and the backend LPOPs an empty db 0. USERNAME
    is deliberately NOT restored: auth stays password-only as the built-in
    `default` user, which is what a managed AUTH string is.
  * kombu reads TLS off the scheme but defaults ssl_cert_reqs to CERT_NONE —
    encrypted while accepting any certificate. Socket.IO's manager URL carries an
    explicit ssl_cert_reqs, since KombuManager takes a URL, not kwargs.
  * The sidecar and tool-container environments are hand-picked allowlists (the
    trap that made the LOG_TRANSPORT fix necessary). TLS settings and REDIS_DB now
    reach both, and only when actually set — an empty string reads as "configured"
    to os.getenv and would suppress the fallback.

health_check_interval now defaults to 30s, configurable via
{prefix}HEALTH_CHECK_INTERVAL. Only the two worker caches set it before, so a
connection killed while parked — managed failover, or Azure Cache's 10-minute idle
reaper — was discovered by a real command failing. This is the one intentional
behaviour change on the existing path, and it applies with or without TLS.

Retries are deliberately NOT enabled globally: retry_on_timeout would re-issue
blocking BLPOP/BLMOVE calls whose reply was lost, which risks consuming a second
message rather than recovering the first.

Tests: 21 new in unstract/core/tests/test_redis_client_config.py, 5 added to the
runner sidecar suite. Core 136, runner 10, Redis-related worker tests 51 — green.
Django settings verified by rendering both modes: plaintext yields
redis://host:6379/0 with no pool kwargs; TLS yields rediss://…/3 plus
ssl_cert_reqs/ssl_ca_certs, and the Socket.IO URL gains ?ssl_cert_reqs=required.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FEAT] Add a local TLS Redis for dev testing, and fix the CA gap it found

A managed Redis differs from the dev one in two ways that matter to this change —
it requires AUTH and speaks TLS — and neither was reachable from a laptop, so the
TLS path had no way to be exercised before merge. `docker-compose-redis-tls.yaml`
runs a Redis that does both on port 6380, alongside the normal `unstract-redis`,
so a developer can flip between the two and confirm BOTH still work.

Its plaintext listener is disabled (`--port 0`), matching Azure Cache's default:
a component that fails to pick up the TLS settings cannot then quietly succeed
over plaintext and hide the bug.

**Running it immediately found one.** URL mode carries TLS in the scheme and never
sets `{prefix}SSL`, but the CA was read inside that flag's branch — so a
`rediss://` URL verified against the system trust store alone and could not talk
to any server with a privately-signed certificate. That is exactly the case the CA
option exists for (Memorystore's CA is Google-managed and not publicly trusted).
The read moved out of the gate; consumers decide whether it applies, so a
`redis://` URL still ignores it. Two regression tests cover both directions.

Verified live against the container, with the new client code:

  url rediss + CA                -> OK   [SSLConnection] roundtrip
  url rediss, no CA              -> FAIL CERTIFICATE_VERIFY_FAILED (expected)
  discrete REDIS_SSL=true + CA   -> OK   [SSLConnection] roundtrip
  plaintext against TLS port     -> FAIL connection closed  (expected)
  TLS, wrong password            -> FAIL AuthenticationError (expected)
  pooled TLS (platform-service)  -> OK   [SSLConnection] ping
  plain local redis (mode A)     -> OK   [Connection] ping

The negatives matter as much as the positives: they show a misconfigured client
fails loudly rather than silently degrading to plaintext.

redis-tls/README.md carries the switch-over runbook, including the one deliberate
asymmetry — the runner uses `ssl_cert_reqs=none`, because it forwards its settings
to tool sidecars and those get no CA mount (their environment is an allowlist and
only the shared log dir is mounted). Encrypted without verification still exercises
the forwarding fix and the handshake; a real managed endpoint does not hit this,
since ElastiCache and Azure chain to public CAs.

Certificates are gitignored — generate-certs.sh writes them locally, and the keys
are unencrypted dev material.

Core tests now 138.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FEAT] Ship generate-certs.sh, which .gitignore silently dropped

The repo ignores *.sh, so `git add -A` skipped the script and the committed
README referenced a file that did not exist in the branch — the dev harness was
unusable for anyone cloning it. Force-added, as every other tracked .sh in this
repo is.

Found by running the harness from a fresh checkout rather than the worktree it
was written in.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Honour REDIS_URL in the Django cache and Socket.IO manager

Found on a live run against the TLS Redis, not by reading the code: an API
deployment came back `execution_status: COMPLETED` with `result: null`.

`create_redis_client` honoured REDIS_URL from the start, but settings/base.py
still built its own URL from REDIS_HOST/REDIS_PORT — so with a URL configured the
WORKERS moved to the managed endpoint while the BACKEND stayed on the in-cluster
one. Nothing errors in that state; the two simply stop sharing a keyspace. The
execution really did run, its result really was cached — into the URL's Redis —
and the backend looked for it in the other server and found nothing. Verified by
key: `api_results:b2dd28d9…:d4355e01…` existed only in the TLS instance.

REDIS_URL now drives both the cache LOCATION and SOCKET_IO_MANAGER_URL. The CA is
appended as a query parameter rather than passed separately, because BOTH
consumers parse it out of the URL — confirmed against the pinned redis-py 5.2.1
(-> SSLConnection with ssl_ca_certs) and kombu 5.5.4 (-> CERT_REQUIRED +
ssl_ca_certs). An explicit ssl_ca_certs already in the URL is left alone, and a
plaintext redis:// URL never gets one.

In URL mode the db and credentials are dropped from OPTIONS: they travel in the
URL, and passing both invites one silently winning over the other — the same
class of bug as the db kwarg losing to the URL path in redis-py.

Tests: backend/backend/tests/test_redis_settings_derivation.py, 9 cases. They
execute the real source range from settings/base.py rather than a copy of the
logic, since the block runs at import and cannot be called. Covers the
plaintext default unchanged, the db-in-LOCATION fix, TLS pool kwargs, kombu's
CERT_NONE default, and URL mode in both directions.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-4123 [FIX] Let a prefix's own DB survive an inherited REDIS_URL

Spotted while documenting the Helm keys, not at runtime — which is the only
reason it was caught before someone hit it in a cluster.

The chart configures ONE endpoint for the platform and sets CACHE_REDIS_DB=1 for
the worker cache. With the endpoint given as REDIS_URL, the CACHE_REDIS_ prefix
inherits that URL (no CACHE_REDIS_URL of its own) and took the db from its path —
so the worker cache would quietly move from db 1 to db 0 and sit beside every
other key. Nothing errors; the keys just relocate.

A prefix's own {prefix}DB now applies to an INHERITED url. An explicit
{prefix}URL is untouched, since it names its db deliberately, and an explicit
db= argument still beats both (sdk1 metrics depends on that).

Three tests pin the precedence chain: inherited URL + prefix db, explicit prefix
URL, and the db= argument. Suite now 26.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-4123 [FIX] One Socket.IO URL builder, so the worker publisher gets TLS too

Review caught that enabling REDIS_SSL moved the BACKEND's Socket.IO manager to
rediss:// while workers/log_consumer/tasks.py kept a hardcoded redis://. Against
a TLS-only endpoint that publisher simply cannot connect, and execution-log
events stop reaching the UI — the backend meanwhile looking perfectly healthy.

The cause is that both files assembled this URL by hand, so they could drift; the
fix is one builder in unstract.core that both import, not two corrected copies.

Two more holes it closes, both specific to kombu taking a URL and nothing else:

  * ssl_cert_reqs was absent in URL mode. Kombu defaults a rediss:// URL to
    CERT_NONE, so TLS was encrypting traffic to a server it never authenticated —
    the connection an operator believes is verified is exactly the one that isn't.
  * ssl_ca_certs never reached Socket.IO at all. The Django cache gets the CA
    through pool kwargs, so against a privately-signed server (Memorystore) the
    cache would work while WebSocket delivery silently died.

An explicit ssl_cert_reqs already in the URL is left alone, and a plaintext
redis:// URL gains no TLS query.

Verified live, not just in unit tests: kombu connects to the local TLS Redis with
CERT_REQUIRED against the generated CA. 9 new cases in TestSocketIoUrl; core +
backend suites 180 green, runner 10, worker log-stream 17.

The backend's settings test moved its Socket.IO assertions to the core suite,
which is where that logic now lives.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-4123 [FEAT] Make the sdk1 metrics database configurable, for single-database endpoints

sdk1's MetricsMixin hardcoded create_redis_client(db=1) — the last place in the
codebase that chose a Redis database in code rather than in configuration, and
the only code blocker to running against a SINGLE-DATABASE endpoint.

That matters because a whole class of managed Redis exposes db 0 only: Azure
Managed Redis, Redis Enterprise / Redis Cloud, and every cluster-mode service
(ElastiCache and Memorystore included). Against those, this client's SELECT 1
fails on first command — caught by the existing try/except, so the run survives
but the time-taken metric is silently lost and the logs fill with errors.

METRICS_REDIS_DB defaults to 1, the value that was hardcoded, so an unset var
leaves every existing deployment byte-identical. A single-database deployment
sets it to 0 alongside CACHE_REDIS_DB and FILE_ACTIVE_CACHE_REDIS_DB — those
three are the whole of Unstract's on-prem database map. Multi-database stays the
default and needs no configuration at all.

Read per instance rather than once at import, so a malformed value raises inside
__init__'s existing try/except (costing the metric) instead of killing the
process at import time.

The tool-container allowlist gains the key too. Tool containers build their own
Redis client from a hand-picked environment, not an inherited one, so without
this entry the setting would apply everywhere EXCEPT the containers doing the
work — the same silent, partial failure as the LOG_TRANSPORT allowlist miss in
UN-3755. The SIDECAR allowlist deliberately does not: it publishes logs and
never imports sdk1.

Note for operators, documented in workers/sample.env: CACHE_REDIS_DB and
FILE_ACTIVE_CACHE_REDIS_DB must always match. Workers write file_active:* to one
and the backend reads from the other, so splitting them stops active-file dedup
finding anything — no error, just files reprocessed as new.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Import pytest as a module in the metrics-db test

SonarCloud python:S9084. `from pytest import MonkeyPatch` was only there to give
the fixture a type annotation for ruff ANN001; `pytest.MonkeyPatch` does the same
job without the from-import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Verify the server's hostname, and resolve ssl_cert_reqs once

Two review findings, both reproduced against the pinned redis-py 5.2.1.

1. TLS was never authenticating the server. redis-py defaults ssl_check_hostname
   to False and then OVERRIDES ssl.create_default_context()'s safe default with
   it, so every path this work added — discrete kwargs, Redis.from_url on
   rediss://, and kombu — validated the chain but not the identity. For the
   ElastiCache/Azure case this targets (public CA, no pinned ssl_ca_certs) that
   means any publicly trusted certificate for any domain is accepted, and an
   on-path attacker can terminate the connection and read or write execution
   state, cached results and log traffic. Encryption without server
   authentication is not what enabling TLS is understood to buy — the same
   argument this module already makes about kombu's CERT_NONE default.

   ssl_check_hostname now defaults to True, overridable per prefix via
   {prefix}SSL_CHECK_HOSTNAME falling back to REDIS_SSL_CHECK_HOSTNAME, and is
   forced off when ssl_cert_reqs is "none" because Python's ssl module raises on
   that combination. Applied in all four places: the discrete kwargs, the URL
   path, the Socket.IO/kombu URL, and the Django cache pool. The dev harness is
   unaffected: docker/redis-tls issues SANs for unstract-redis-managed,
   localhost and 127.0.0.1.

2. ssl_cert_reqs was resolved three different ways in one module, with two live
   consequences, both executed:

   * URL mode never read it. `REDIS_URL=rediss://host:6380/0` with
     REDIS_SSL_CERT_REQS=none gave the data-plane clients redis-py's default
     while build_socketio_redis_url emitted ?ssl_cert_reqs=none — one process,
     two verification policies, and nothing to say which was intended.
   * Prefixed clients had no generic fallback, unlike {prefix}SSL and
     {prefix}SSL_CA_CERTS which this work already gave one. REDIS_ honoured
     "none" while CACHE_REDIS_ and MANUAL_REVIEW_REDIS_ stayed "required", so the
     worker cache alone failed verification — and cache_backends.py catches that,
     logs a warning and sets available=False, degrading silently to no-cache.

   Now resolved once, outside the ssl gate and with the generic fallback, exactly
   as ssl_ca_certs already was; applied in both the discrete and URL paths, with
   a setting already present in the URL's query string still winning.

One existing expectation updated deliberately, not to reach green:
test_ssl_switches_scheme_and_pool_kwargs asserted the exact pool-kwargs dict and
now includes ssl_check_hostname, because the rendered behaviour changed on
purpose. Eight new tests cover prefix inheritance of cert_reqs, URL mode, the
URL-query precedence, the hostname default, the forced-off case, the opt-out and
the Socket.IO URL. 53 green across the core and backend Redis suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Split the URL composer out of build_socketio_redis_url

SonarCloud python:S3776 — the hostname-verification branch added in 3bc8aaa
pushed the function's cognitive complexity from under the limit to 17.

The assembly of a URL from the discrete vars was the part that did not belong:
that function exists to get kombu's TLS settings into a query string, and it was
also a URL builder. Moved to _compose_redis_url, leaving one expression at the
call site. No behaviour change — 53 tests green across the core and backend Redis
suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Test the settings that ship, not a copy of them, and pin the credentials

Two testing findings, both mutation-verified by the reviewer and re-verified here.

1. The harness re-implemented the very lines this work adds. Its docstring says it
   executes the shipping source "instead of re-implementing it, which would test a
   copy rather than the thing that ships" — but the exec'd slice started at
   REDIS_SENTINEL_MODE (base.py:531), while REDIS_SSL, REDIS_SSL_CERT_REQS,
   REDIS_SSL_CA_CERTS and REDIS_URL are defined at :112-121. All four ran from a
   hand-written prelude instead, so the shipping definitions were untested and the
   copy could not diverge visibly.

   The prelude now keeps only the three imports and splices in the real
   definitions range. Both of the reviewer's mutations, which previously left the
   suite green, now fail: a typo'd REDIS_URI (which re-introduces the exact
   REDIS_URL-ignored regression this file exists to pin) fails 9 tests, and a
   flipped REDIS_SSL default fails test_plaintext_is_unchanged.

2. Discrete mode's cache credentials were never asserted. Discrete mode is the
   default for every existing on-prem and Helm deployment and the only path where
   the password reaches Redis through OPTIONS — in URL mode it rides inside the
   URL. Removing the PASSWORD assignment left the suite green while the cache
   authenticated as nobody: every read and write failing at runtime, nothing
   failing at import. That block is now built conditionally in this PR, so the
   credentials are newly reachable-or-not depending on a branch.

   Three cases added: the password reaches OPTIONS (mutation-verified red), DB and
   USERNAME are passed through, and URL mode does NOT duplicate them. The USERNAME
   assertion pins the stated invariant — django-redis 5.4.0 discards it, so
   password-only auth currently holds by accident of the pinned version; the test
   makes a bump that starts honouring it a deliberate decision rather than a
   surprise.

Also documented in docker/redis-tls/README.md why the recipe now works: the
reviewer was right that REDIS_SSL_CERT_REQS was ignored in URL mode, and the fix
in 3bc8aaa is what makes the documented dev setup correct as written. The URL
query form is noted as an equivalent that wins if both are set.

61 green across the backend, core and sdk1 Redis suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Blank values, a lever that never arrived, and a silent TLS downgrade

Three review findings, all reproduced.

1. A declared-but-BLANK METRICS_REDIS_DB disabled every sdk1 metric. An empty
   variable is this repo's own convention for "leave the default"
   (CACHE_REDIS_PASSWORD=, REDIS_SSL_CA_CERTS=), and every other variable in this
   work treats it that way — this one raised int(""). The exception was caught, so
   the claim that it costs the metric rather than the process held, but the
   consequence was every LLM and x2text timing silently missing platform-wide,
   once per instrumented call, behind a log line naming Redis rather than the
   variable. Blank now means unset; an unparseable value falls back to db 1 and
   says which variable it came from.

   The old test codified "" as malformed, so it was rewritten rather than
   adjusted: losing every metric is a bad trade for a typo.

2. REDIS_HEALTH_CHECK_INTERVAL reached neither container allowlist, though
   _resolve_health_check_interval reads it in the sidecar and in every tool
   container, three sample.env files document it, and the PR body names
   REDIS_HEALTH_CHECK_INTERVAL=0 as THE way to restore the previous behaviour for
   this work's one intentional default change. That lever stopped at the pod
   boundary. Added to both allowlists and both forwarding tuples. Benign in
   itself — an extra PING on connections idle past 30s — but it is exactly the
   trap the comments beside it were written to warn about.

3. REDIS_SSL=true beside an older plaintext REDIS_URL downgraded silently and
   then crashed the Django cache. The URL wins for the connection, so traffic went
   in clear while the operator believed TLS was on; meanwhile the cache's pool
   kwargs were gated on the flag alone, handing ssl_cert_reqs to a plain
   redis.Connection — verified: TypeError on first use, in a request rather than
   at startup. The pool kwargs now follow the EFFECTIVE scheme, and
   _resolve_redis_env logs an error when a URL's scheme disagrees with
   {prefix}SSL, since that combination is never deliberate.

Tests: blank/unparseable/metrics-still-enabled for the sdk1 db, and
TestSchemeFlagMismatch for the cache — the latter mutation-verified red against
the flag-only gate. 63 green across the backend, core and sdk1 Redis suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Name the TLS scheme instead of repeating the literal

SonarCloud python:S1192 — the scheme-mismatch check added in 178f1c7 made
"rediss://" a third occurrence.

Extracted as _TLS_SCHEME with a note on why it carries weight here: the scheme IS
the TLS switch for redis-py, kombu and django-redis alike, which is the reason
URL mode has no separate flag. No behaviour change; 65 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] TLS for Sentinel's discovery connections, and warn when the cache moves db

1. With REDIS_SSL=true, Sentinel deployments encrypted the master connection and
   left DISCOVERY in plaintext. _build_connection_kwargs returns from its
   auth-only branch before the ssl block, and redis-py builds the Sentinel clients
   from those kwargs alone — so against a Sentinel still accepting plaintext the
   Sentinel password went out in clear while the operator believed one switch had
   turned TLS on everywhere; against a TLS-only Sentinel, discovery failed through
   the full ten-attempt backoff first.

   The asymmetry predates this work, but this work is what makes REDIS_SSL the
   documented one-switch way to enable TLS, which is what brings operators to it.
   The discovery connections now follow {prefix}SSL rather than getting a switch
   of their own: a Sentinel node IS a redis-server and takes the same tls-port
   configuration, so the two never差 in practice. Verified — discovery is now
   SSLConnection and the master stays SentinelManagedSSLConnection.

   The review also checked and rejected a TypeError on the master path:
   SentinelConnectionPool pops `ssl` and selects the SSL class itself. That
   matches what this module found earlier and is why only the auth-only branch
   needed changing.

2. The Django cache's move from db 0 to REDIS_DB is correct — django-redis 5.4.0
   ignores OPTIONS["DB"], so this cache always sat on db 0 — but it is a one-way
   RELOCATION of live data, not the inert change the PR describes. CacheService
   wraps get_redis_connection("default"), so log_history_queue, the rate-limit
   counters and the dashboard caches move with it, and a rolling deploy has old
   pods on db 0 while new pods read db N. No shipped config sets a non-zero
   REDIS_DB for the backend, which is what keeps it survivable — but it should not
   be silent, so it now logs a warning naming the database and what stays behind.

Tests: Sentinel TLS on both planes, and the plaintext Sentinel unchanged — there
was no Sentinel+TLS coverage at all before. 67 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] One TLS kwargs builder, shared by both connection branches

SonarCloud python:S3776 — giving Sentinel's discovery connections TLS in
59df87e duplicated the TLS block into both branches of
_build_connection_kwargs and pushed it to complexity 17.

Extracted as _tls_kwargs, which fixes the duplication and the complexity
together. That duplication is exactly how the bug arose in the first place: the
auth-only branch returned before the TLS block, so one plane was encrypted and
the other was not. A single helper means the two cannot drift again — worth more
than the complexity score on its own.

No behaviour change; 67 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [DOCS] Correct the database map, the USERNAME comment, and the tense

Three doc-accuracy findings. The first is the one that would have caused harm.

1. workers/sample.env told operators to set FILE_ACTIVE_CACHE_REDIS_DB in the
   WORKERS' environment, where nothing reads it — it is read only by the backend
   (settings/base.py, consumed in file_history_helper.py). Following that
   guidance sets it inertly, leaves the operator believing the pair is aligned,
   and produces exactly the silent dedup failure the same paragraph warns about.

   The same block called three keys "the whole of Unstract's on-prem database
   map". There are FOUR: REDIS_DB also selects a database — for the Django cache
   LOCATION and every REDIS_-prefixed client — so collapsing the other three onto
   a db-0-only endpoint while REDIS_DB stays non-zero still leaves clients on a
   database the endpoint does not have. It is the easiest to miss because it is
   already set, higher up the same file.

   Rewritten as a per-process table, since which ENV a key goes in is the whole
   point.

2. "USERNAME is deliberately NOT restored" sat eleven lines above code setting
   both USERNAME and DB. Both are genuinely inert — django-redis 5.4.0's
   make_connection_params reads PASSWORD and the two timeouts and nothing else —
   but a reader working out why username auth never reaches Redis found a comment
   and code that disagreed, with two dead OPTIONS keys looking load-bearing. The
   comment now says what is true: they are passed for readability and discarded,
   auth stays password-only, and the tests pin it so a dependency bump that starts
   honouring USERNAME is a visible change rather than a silent one.

3. Several comments narrated an INTERMEDIATE state of this branch as shipped
   history — "the backend learned TLS while the worker kept a hardcoded redis://",
   "create_redis_client honoured REDIS_URL from the start while this module built
   its own". Neither was ever true on main: both conditions existed only between
   commits here. Post-merge they read as a shipped incident, and this repo writes
   RCAs from exactly this kind of comment. The rationale was right and is kept —
   only the tense changed, to the hazard the code prevents rather than events that
   never happened.

67 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] One database rule, a dev recipe that is not quietly broken, and the CA caveat

Three review findings.

1. The database precedence could not be stated, because there were two rules. An
   INHERITED generic URL honoured {prefix}DB — deliberately, since the chart ships
   one REDIS_URL with CACHE_REDIS_DB=1 beside it — while a prefix's OWN url did
   not. Worse than reported: an explicit REDIS_DB beside a pathless REDIS_URL gave
   db None, neither the env value nor a sane default.

   Now uniform: a URL supplies host, port and credentials; the database is
   {prefix}DB when explicitly set, otherwise the URL's path, otherwise 0; an
   explicit db= argument beats all of them. Stated in the module docstring and in
   workers/sample.env, where an operator meets it.

   One existing expectation changed deliberately: a prefix's own URL no longer
   keeps its db against an explicitly-set {prefix}DB. That test encoded exactly
   the inconsistency being removed. Leaving {prefix}DB unset gives the URL's db,
   which is covered by its own case.

   Not changed: the reviewer also suggested dropping URL inheritance per prefix.
   That would silently DOWNGRADE the worker cache to plaintext under a rediss://
   URL, since the discrete path needs CACHE_REDIS_SSL and the scheme carries TLS
   instead — so inheritance stays and the rule is documented rather than removed.

2. docker/redis-tls/README.md put the workers' cache on db 1 while the backend
   kept FILE_ACTIVE_CACHE_REDIS_DB at its default 0 — the workers writing
   file_active:* to one database and the backend reading another, which is the
   silent dedup failure workers/sample.env warns about in this same PR. Anyone
   following it validated the TLS work on a quietly broken config. The recipe now
   sets FILE_ACTIVE_CACHE_REDIS_DB=1 and says why.

3. REDIS_SSL_CA_CERTS is forwarded into tool containers and sidecars as a PATH,
   and the runner mounts only the shared logs volume — so unless an image bakes
   the CA in at that path, load_verify_locations() raises on a file that is not
   there: the sidecar cannot build its LogPublisher and tool logs are lost.
   Kept forwarded, because the baked-in case is real and is the only way this
   works today, but the caveat now lives in runner/sample.env and at both
   allowlists rather than only in the dev README.

73 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] One TLS-query builder, and a guard against allowlist drift

Two review findings, both about the same failure mode: a second copy of something.

1. base.py hand-built the TLS-query append ten lines above calling the shared
   builder, and the two copies already disagreed — unstract.core added
   ssl_cert_reqs AND ssl_ca_certs, base.py only ssl_ca_certs, so one process held
   two verification policies for one endpoint. That is precisely the drift the
   shared builder was introduced to prevent, reintroduced in the same change, and
   it is the mechanism behind the URL-mode cert_reqs bug fixed earlier.

   Extracted as ensure_tls_query_params — "given a Redis URL, add the TLS settings
   it is missing" — called from both. build_socketio_redis_url is now the thin
   wrapper that adds the discrete-vars fallback on top. Anything handing a URL to
   a library that reads TLS only from the query string wants this; kombu and
   django-redis both do.

   The helper now uses safe="/" so a CA path stays readable rather than arriving
   percent-encoded; two expectations that pinned %2F were updated to match, and
   the result still round-trips through urlsplit/parse_qs.

2. Added the allowlist test the review asked for — the one assertion that would
   have caught the REDIS_HEALTH_CHECK_INTERVAL omission from BOTH container lists
   and the METRICS_REDIS_DB divergence between them. It reads both constants files
   as text (neither package is importable here, and the question is what the
   source declares) and asserts every variable create_redis_client reads is
   forwarded, plus that METRICS_REDIS_DB is in the tool list and deliberately NOT
   in the sidecar's, since the sidecar publishes logs and never imports sdk1.

   Mutation-verified: removing the health-check entry from the sidecar list fails
   it.

The review's other four gaps were closed by the fixes they accompanied — prefix
inheritance of ssl_cert_reqs, URL mode plus the env var, Sentinel with TLS, and
REDIS_SSL beside a plaintext URL — as was the sdk1 test that codified a blank
value as malformed. 76 green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Resolve TLS once from the URL as well as the env, and derive the guards

Eleven findings from a four-agent review of 3690a07..8616202. Nearly all of
them were introduced by that range: each previous fix was applied at one site
while the same defect class sat untouched at the next one. Every fix here is
mutation-verified — eleven mutations, each caught by a named test.

CRITICAL. A URL carrying ?ssl_cert_reqs=none got ssl_check_hostname=true
appended beside it, and Python's ssl module raises on that pair rather than
degrading: every Redis connection in the process failed at the first command,
across the client, the Socket.IO URL and the Django cache LOCATION. The
suppression keyed on the ENV value and never looked at the URL's query string —
which is the form docker/redis-tls/README.md documents, and what redis-py, kombu
and django-redis all actually honour. effective_cert_reqs() now decides it, at
all three sites. The existing test fed this exact input and passed because it
only asserted the value was present.

THE DATABASE RULE reached create_redis_client but not the Django cache, so one
REDIS_URL plus one REDIS_DB resolved to two different databases: workers RPUSH
log_history_queue to db N while the backend LPOPs an empty db 0, silently.
set_url_db_path()/url_db_path() are now shared by both sides, and a test asserts
they agree for the same environment rather than merely both being right today.

The rule also let the generic REDIS_DB cross into a prefix that brought its own
URL. workers/sample.env ships REDIS_DB=0 uncommented, so CACHE_REDIS_URL=…/1
landed on db 0. The db var at the URL's OWN level now governs; an inherited
REDIS_URL still honours the prefix's db, which is the shape the chart ships.

BLANK MEANS UNSET, everywhere this time. REDIS_SSL_CHECK_HOSTNAME was added by
the previous range with a bare == "true", so the repo's own "leave the default"
spelling read as False and quietly turned server authentication off. A blank
{prefix}SSL_CERT_REQS resolved three different ways in one process. Both now go
through one parse that accepts the usual spellings, warns by name on anything
else, and treats blank as unset.

SENTINEL + TLS was regressed by the same range. Masters are reached at the IP
SENTINEL get-master-addr-by-name returns, which SSLConnection passes as
server_hostname, so defaulting hostname verification on would have failed every
existing REDIS_SENTINEL_MODE + REDIS_SSL deployment on upgrade, through the full
ten-attempt backoff. The master plane skips it by default; discovery, which uses
the configured service name, keeps it. An explicit setting is honoured on both —
a typo is not, or it re-breaks exactly what the default protects.

THE GUARDS ARE DERIVED, NOT LISTED. REDIS_SSL_CHECK_HOSTNAME reached neither
container allowlist and was missing from the drift-guard set added in the same
commit to catch that. _SHARED is now regexed out of redis_client.py with an
explicit list of deliberate exclusions, so the next variable fails a test instead
of needing a third hand-maintained list; a second test asserts the forwarding
TUPLES, which are what actually reach the container. Deleting
Env.REDIS_HEALTH_CHECK_INTERVAL from runner.py — the omission the guard was
written for — used to leave the suite green.

Tests: core 57 -> 91, backend 15 -> 30. The backend harness now splices base.py's
real import block as a third source slice instead of hand-listing it, and sets
__name__ so the relocation warning is reachable — part of why it was untested.
That warning also had a bug of its own, caught by its first test: it compared
REDIS_DB against the URL's path, announcing a move for a plain redis://h/3 that
never moves.

Also: a blank {prefix}DB no longer escapes as a bare ValueError; the dead
cert_reqs fallback in build_socketio_redis_url is gone; four docstrings that
contradicted the code are corrected; the CA caveat sits above the constant it
describes; the TLS README's two-database recipe sets CACHE_REDIS_DB explicitly,
without which it produced the exact dedup split its own paragraph warns about;
and workers/sample.env ships the prefixed TLS vars commented out, since shipping
them set defeated the generic fallback for every deployment derived from it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Share parse_db with the backend, and tighten three guards that could not fail

A scoped review of 418e78b found no Critical or High — the first round in four
where the previous round's fixes introduced no blocking defect. These are the
three Mediums and three Lows it did find. Seven mutations, all now caught.

parse_db IS NOW SHARED. It was introduced in the last commit with a docstring
explaining why `int(os.getenv(...))` on a database var takes the process down at
import — and three sites in backend/settings/base.py still did exactly that with
the same variables: the cache db, the Sentinel branch's db, and
FILE_ACTIVE_CACHE_REDIS_DB. So the two halves disagreed on a malformed value:
workers warn and continue on db 0, the backend refuses to start. That is the
"fixed here, not there" shape this whole series keeps producing, one more time.

FILE_ACTIVE_CACHE_REDIS_DB sits ~350 lines above the derivation block, outside
both slices the exec harness runs, so no _derive() case can observe it. It is
pinned by a source check instead, which reaches all three sites at once — the
defect was never one line, it was one expression repeated wherever a database
var is read.

THE FORWARDING GUARD MATCHED ANYWHERE IN THE FILE. The last commit rewrote it to
assert the tuple rather than the constants declaration, because asserting the
declaration let a deleted tuple entry through. It searched the whole file text,
so COMMENTING OUT a tuple entry still passed — the same "present somewhere is not
present where it matters" weakness, moved one level down. It now slices the
tuple's own region and drops commented lines. Both mutations fail.

SAMPLE.ENV SHOWED THE TRAPPING VALUE. All three files displayed
`# REDIS_SSL_CHECK_HOSTNAME=true`, and an explicit value is honoured on Sentinel
MASTER connections — so uncommenting the line, which reads as "turn on the
documented default", silently flips masters from False to True and breaks exactly
the deployments the default was added to protect. They now show `false`, the only
value there is a reason to set, and say why setting it explicitly is not a no-op.
runner/sample.env was also the one block missing the Sentinel caveat.

Tests: core 91 -> 100, backend 30 -> 35. New coverage for behaviours that had no
test capable of failing: url_cert_reqs taking the FIRST repeated value (matching
redis-py's parse_url) and normalising case/whitespace; a prefixed client in
DISCRETE mode still inheriting the generic REDIS_DB, which the "at the URL's own
level" rule must not narrow; the backend's cert_reqs normalisation.

Three blank-value tests gained `assert ... not in caplog.text`. Without it "blank
means unset" is indistinguishable from "blank is unparseable, warn and fall back"
— both return the default, but only one is silent, and the other logs on every
client. One of those assertions is load-bearing in a non-obvious place:
_resolve_redis_env strips before calling parse_db, so parse_db's own .strip() is
unreachable through create_redis_client; base.py reads REDIS_DB raw, so the
backend test is the only thing that can pin it. Verified by mutation rather than
assumed — the first version of the assertion sat in the core suite, where it
could never fail.

Also corrects a test docstring that described a different test than the one it
sat on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [DOCS] The TLS port is provider-specific, and 6380 is not a default

Both sample.env files presented `rediss://:<password>@<host>:6380/0` as the TLS
form with no note on the port. 6380 is our own docker/redis-tls convention and
Azure Cache's port; it is wrong for the other two providers these files name:

    GCP Memorystore        6378   (plaintext instances use 6379)
    AWS ElastiCache        6379   (same port; TLS is in-transit)
    Azure Cache for Redis  6380

Found by creating a TLS Memorystore instance and watching it come up on 6378
after I had written 6379 into a deployment manifest from memory. A reader
following these files has no reason to doubt the number.

The failure mode is the reason this is worth a note rather than left implicit:
a wrong port does not error. The prerequisite init containers loop on
`nc -z <host> <port>` forever, so every pod sits in Init with no event and no
log line, and it reads as a firewall or peering problem rather than a typo in a
port. That is the same "fails silently and late" class the rest of UN-4123
documents.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
* [FIX] Stop tox 4.64's .venv redirect from breaking every CI run

CI went red on every branch at once on 2026-09-24, with no commit to blame:
a branch that passed on 09-23 failed on 09-24 with byte-identical errors.

`uv tool install tox --with tox-uv` is unpinned and resolves at job time, so
tox 4.63.0 -> 4.64.1 landed in CI on its release day. tox 4.64.0 ships a PEP
832 `.venv` redirect: after each run it writes a *file* named `.venv` at the
project root, holding the path of a tox env, so editors can find an
interpreter. It skips this when a real `.venv` directory already exists, which
is why no local checkout noticed and only CI, which starts clean, broke.

The repo root is also a uv workspace. uv reads `.venv` as the project
environment and refuses a file:

  error: Project virtual environment directory `.../.venv` cannot be used
  because expected directory but found a file

That is exactly the three root-workdir invocations: the unit-rig group, the
e2e-smoke group (whose failure then SKIPs the other seven e2e groups), and the
final `uv run --with coverage[toml] coverage combine`. Both tiers exit 2 and
report fails behind them. Every Python test in the unit tier passed throughout.

Disabling the redirect is the fix, not a workaround. Nothing in this repo wants
it: the redirect aims at .tox/<env>, ~97 packages of pytest/testcontainers/
coverage, while the groups pointed at that root need the ~269 package workspace
environment. A uv that honoured PEP 832 would run them in the wrong env
silently, which is worse than today's loud failure.

There is no newer tox to move to instead -- 4.64.1 is the latest release, and
it keeps the feature (4.64.1 only fixes an unrelated override bug). The
version-only alternative is pinning back to 4.63.0, which forfeits fixes and
re-breaks on the next deliberate bump.

tox and tox-uv are now pinned as well, so the next release is a bump we make
rather than one CI makes for us.

Verified locally against the real versions:
  * tox 4.64.1 before the change: `.venv` is a 9-byte file containing
    `.tox/rig`, and `uv run` at the root reproduces the CI error verbatim
  * tox 4.64.1 after: no `.venv` file, and `uv run` at the root succeeds
  * tox 4.63.0 after: accepts the key without warning, so the change is safe
    either side of the bump

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [FIX] Install the CI toolchain from wheels only

Sonar flagged the three `uv tool install` lines this PR touches (C Security
Rating on New Code, blocking the quality gate): omitting --no-build permits a
source distribution's setup script to execute during install. The lines are
older than this PR, but editing them made them new code, so a dormant rule
became a gate failure.

Worth fixing rather than waiving. A CI runner installing the test toolchain is
exactly where an sdist build hook would be cheapest to abuse, and the flag
costs nothing here: tox, tox-uv, uv, virtualenv and the rest of the tree all
publish wheels.

Verified with uv 0.6.14 -- the version setup-uv pins, and so the one that
actually runs this command -- that --no-build is supported there, that the
resolution is byte-identical (uv==0.12.18, virtualenv==21.12.0,
tox-uv==1.36.0), and that the resulting tox 4.64.1 runs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
* UN-3487 [FIX] Require and enforce a bucket on the S3/MinIO connector

The S3/MinIO connector had no bucket field, so its reach was bounded only
by whatever the underlying credential (often a shared IAM role) could see
account-wide. Any connector could browse into any bucket that role had
access to, including other teams' data.

- `bucket` is now a required field on the connector schema, matching
  Azure's existing (but unenforced) precedent — this one is actually
  enforced.
- `MinioFS.get_fsspec_fs()` wraps the filesystem in fsspec's own
  `DirFileSystem` when a bucket is set, confining every list/read/write
  (and `test_credentials`) to that one bucket regardless of what the
  credential could otherwise reach. UCS opts out via `_REQUIRES_BUCKET`,
  since it restricts access through its own `path` setting instead.
- Fixed a related IDOR in `file_management`: `list`/`download`/`upload`
  resolved a connector by a bare `ConnectorInstance.objects.get(pk=id)`
  with no ownership/group check, org-scoped only. Any org member holding
  a connector's id could browse/read/write through it regardless of
  sharing. Now scoped through `ConnectorInstance.objects.for_user()`, the
  same queryset every other connector view already uses.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Stop double-prefixing bucket-scoped root-level operations

Greptile flagged (P1): DirFileSystem's own `.path` attribute is the
wrapped bucket prefix, but file_management_helper's root-path fallback
treated it as a connector-level default root and substituted it as the
operation path — every root-level list/upload then resolved to
`<bucket>/<bucket>/...` instead of `<bucket>/...`.

- Factored the fallback into `_default_root_path()`, which now excludes
  DirFileSystem explicitly: its `.path` never needs re-applying, every
  operation already resolves relative to it.
- Added regression tests exercising the real `FileManagerHelper.list_files`
  / `upload_file` against a bucket-scoped MinioFS with a mocked S3 backend,
  asserting the underlying call receives a single bucket prefix.
- Fixed the same root cause in the e2e ETL fixture: its connector had no
  `bucket` (now required), and its configured `folders`/`outputFolder`
  included the bucket name as a path segment — now implicit via the
  connector's own scope, per the same fix.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Update the live-MinIO integration test for the required bucket

TestMinoFS::test_minio asserted that connecting without a bucket lists
every bucket in the account — exactly the behavior this ticket removes.
Bootstrap the test bucket via a plain S3FileSystem (the connector itself
can no longer discover buckets this way), then assert the bucket-scoped
connection lists successfully.

CI signal: this is what "test (integration)" failed on in both runs, not
a new issue. The accompanying test_pg_barrier.py failures in that same
job are a pre-existing, unrelated CI DNS flake (identical on both runs,
"could not translate host name unstract-db"), not caused by this PR.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Fix DirFileSystem.walk() leaking the bucket into file names

Found by tracing the e2e ETL failure through the compose stack's own
logs (docker-compose-logs.txt from the CI run), not assumed: the worker
log showed "Failed to prepare input file and metadata:
unstract/unstract/e2e-in-.../probe.txt" — a double bucket prefix, but
from a different cause than the earlier file_management_helper fix.

fsspec's DirFileSystem.walk() relpaths the directory string it yields,
but not the `name` field inside each file/dir entry's own metadata dict
— those still carry the wrapped fs's raw, bucket-prefixed key (ls()
already fixes every entry; walk() doesn't, confirmed by reading fsspec's
own source and reproducing it directly against a mocked S3FileSystem).
Workflow-execution file discovery walks (UnstractFileSystem.list_files,
shared by backend and workers); the UI file browser lists — which is why
this was invisible to the file_management-focused testing so far.

_BucketScopedFileSystem wraps DirFileSystem and relpaths both dict keys
and each entry's `name` field for walk()/_walk(), so discovery sees the
same bucket-relative paths ls() already produced correctly.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Handle walk()'s default detail=False shape correctly

Greptile P1: the walk()/_walk() override always treated dirs/files as
detail=True dicts, calling .items() unconditionally. fsspec's own
default is detail=False (confirmed by reading AbstractFileSystem.walk's
source), which yields plain basename lists — every caller using the
default contract got an AttributeError instead of results.

Also caught, verified empirically against the real base implementation:
my own relpath was wrong on the dict-keyed (detail=True) path too. The
dict key is already a bare basename — only each entry's own `name`
field carries the bucket-qualified path. Relpath-ing the key itself
would hit `DirFileSystem._relpath`'s own assertion on real data (a
basename never starts with the bucket prefix); the prior test happened
to pass only because its mock's key and `name` field were identical,
which isn't how fsspec actually shapes walk() results.

Re-checked Greptile's second (repeated) finding on this same review —
the file_management_helper double-prefix fix from an earlier commit is
still intact: `_BucketScopedFileSystem` is a `DirFileSystem` subclass,
so the existing `isinstance(fs, DirFileSystem)` exclusion still applies.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Move the Bucket field to the bottom of the S3/Minio connector form

The connector config form renders fields in json_schema.json's property
order. Bucket landed second, right after the connector name, ahead of
the credential fields users fill in first. Moved it to the end so the
form still reads top-to-bottom the way users configure it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

* UN-3487 [FIX] Wrap long S3/MinIO error text instead of overflowing the toast

Connector error messages come wrapped in a markdown code fence
(handle_s3fs_exception), which CustomMarkdown renders as a bare <pre>.
<pre> defaults to white-space: pre, so a long single-line message like
the "Invalid Secret" one never wraps — it just overflows past the
fixed-width toast instead of staying inside it. Added pre-wrap +
overflow-wrap so long connector errors wrap like the rest of the toast.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
…ack on each child's first task (#2293)

* fix: stop the file_processing worker booting the executor stack

Each pg_queue_consumer child paid ~9s of lazy initialisation on its first
task. The supervisor preforks 20 children per pod, so that is ~170
CPU-seconds per pod spent during serving rather than at startup, which
spikes fleet CPU at load onset and inflates CAST AI's WOOP p99 to 792m
against a 200m request -- putting the HPA signal's ceiling below its own
target so the fleet collapses to minReplicas under backlog.

structure_tool_task.py imports two string constants and a StreamMixin shim
from the executor package. None needs the executor registry, but every
import under `executor` booted the whole thing: executor/__init__.py
imported .worker, which imported executor.executors, which imported
LegacyExecutor and ran entry-point discovery. Measured at 9.98s cold.

Resolve celery_app lazily (PEP 562) and replace the executors package's
import-side-effect registration with an explicit, idempotent register_all().
The two entrypoints that need a populated registry -- executor/worker.py and
executor/tasks.py -- call it directly; both already carried an explicit
`import executor.executors  # noqa: F401` for exactly this purpose, so this
makes an existing intent explicit rather than changing it.

structure_tool_task.py is untouched; all three of its imports become cheap.
Deferred first-task cost goes 2.334s -> 0.004s locally, with
legacy_executor no longer imported into file_processing at all.

The new test pins the import graph rather than a duration -- a timing
assertion would be flaky on CI, while what actually regresses is an eager
import creeping back into either package init. Both cases were confirmed
failing on origin/main.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address round-1 self-review findings

Critical: restore the celery.app.trace suppression. executor/worker.py's
setLevel(WARNING) mutes the trace logger, which prints the full task result —
for execute_extraction, extracted customer document text — at INFO. That module
is on no deployed path: workers/worker.py exec-loads executor/tasks.py by path
for both executor roles and nothing runs `celery -A executor`. It ran only
because executor/__init__.py eagerly imported .worker, so making that lazy
silently re-enabled result logging on every extraction. Moved the suppression to
executor/tasks.py, which keeps it without re-importing the expensive module.

register_all(): latch before discovering rather than after. ep.load() executes
third-party code, and a plugin whose import graph reaches back into the function
would restart the entry point loop, nesting once per level (measured: 50 nested
loads where 1 was intended). The import-side-effect version got that safety free
from sys.modules. A discovery that raises now resets the latch instead of
pinning a permanently-empty list.

Correct the docstrings rather than the behaviour where the claim was the defect.
register_all() cannot repopulate a registry something else emptied — the second
import is a sys.modules hit and the decorator does not re-run. Making it
re-register explicitly broke four test files that do unguarded
ExecutorRegistry.register(LegacyExecutor) after a clear, so the guarantee is now
stated as per-process instead. Also: executor/__init__.py no longer contradicts
its sibling in the present tense; worker.py's register_all() call is gone (it
could never do work — import executor.tasks above it already registered, so the
comment claiming otherwise would have led a maintainer to delete the call that
matters); plugins/loader.py and test_plugin_loader.py's own enumeration no
longer describe the removed import side effect.

Tests: all three new cases were defective. One was vacuous — removing the latch
entirely left the suite green, because with no cloud plugins installed the first
call also returns []. Two passed only on suite ordering: an unrelated module
writes ExecutorRegistry._registry["legacy"] directly and never cleans up, and CI
shards with xdist's default per-test scheduler, so their result depended on shard
assignment. Rewritten to assert registration in a fresh interpreter, to assert
idempotency on a spy's call count rather than a return value that is [] either
way, and to snapshot/restore the process-global registry. Added coverage for
re-entrancy, for the non-latching failure path, and for the trace suppression.
All five guards are mutation-verified.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address the late round-1 findings

_reset_discovery_for_tests()'s docstring still claimed register_all()
re-registers the bundled executor when the registry has lost it. That was true
of a fix I reverted — making register_all() re-register broke four test files
that do unguarded ExecutorRegistry.register(LegacyExecutor) after a clear — and
the stale claim then contradicted register_all()'s own docstring. It sat on the
helper a test author reads before writing `clear(); reset(); register_all()`
and asserting on the registry, which returns cleanly and leaves it empty.

register_all() now returns a copy. It was handing out the latched module list,
so a caller appending to the result corrupted what every later caller saw.

Subprocess test snippets raise SystemExit instead of asserting. The child
inherits the parent environment, so under PYTHONOPTIMIZE every assert is
stripped: the snippet would print OK, exit 0, and the guard would pass having
checked nothing. Verified by mutating the trace suppression under
PYTHONOPTIMIZE=1 and confirming the test still fails.

Test coverage for three guards that had none: the LegacyExecutor __getattr__
shim (no caller in this repo or the cloud plugins uses that spelling, so a typo
in the name comparison would break every cloud plugin while OSS CI stayed
green), the breadth of the BaseException catch (the failure test drove only
RuntimeError, so narrowing the handler would have kept the suite green while
reopening the latched-empty window for the KeyboardInterrupt case it exists
for), and the copy semantics above.

Documented, not fixed: discover_executors() catches per-entry-point failures and
logs a warning, so register_all() latches [] and returns it whether no cloud
plugins are installed or every one of them failed to import. A broken plugin
wheel therefore boots clean and surfaces per-request as "No executor registered
with name 'table'". The Returns section now states both meanings rather than
only the benign one. Making the aggregate loud is a behaviour change to the
loader and out of scope for a performance PR.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address round-2 self-review findings

Every finding this round was introduced by round 1's fixes.

Drop the reset-on-failure rather than patch it. It was added so "a caller that
survives the failure retries instead of silently getting a permanently empty
list", but there is no such caller — the single call site is at
executor/tasks.py module scope, so a failure kills the boot and the process
restarts with clean module state. Worse, the retry it enabled is harmful: a
plugin whose @ExecutorRegistry.register fires before it raises is evicted from
sys.modules but left in the registry, so re-importing it raises a duplicate-name
ValueError that discover_executors swallows into a warning, leaving that
executor silently absent for the life of the process — the exact outcome the
reset claimed to prevent. Verified. Dropping it also closes the window where the
latch was armed one statement before the try, and removes the BaseException
breadth question along with the test that pinned it.

Stop the test fixture corrupting the registry it was added to protect.
clear() + update(saved) deletes a registration the test itself caused: when this
class is what first imports legacy_executor, the decorator fires during the test
so `legacy` is absent from the snapshot, and it cannot be restored because the
re-import is a sys.modules hit. Every later test in that process then saw an
empty registry; nothing failed only because five other modules re-register
defensively. Now an additive restore.

Restore test_register_all_tolerates_legacy_executor_already_imported, deleted in
round 1. Its scenario is live in production — a cloud plugin imports
LegacyExecutor at module scope and is loaded by ep.load() inside discovery — and
the subprocess replacement covers the opposite ordering, a fresh process with no
prior import. Deleting it weakened coverage for a real path.

Convert the remaining subprocess snippet to raise SystemExit. Round 1 wrote that
rule into one test file and violated it in the other in the same commit; under
PYTHONOPTIMIZE the asserts are stripped and the guard passed having checked
nothing. Confirmed by mutation under PYTHONOPTIMIZE=1.

Docstrings: the module docstring still described a register_all() call in
executor/worker.py that the same commit deleted, and tasks.py still called that
second call "harmless" — both now say executor/tasks.py is the only caller and
that worker.py's import of it is load-bearing. The Returns block promised the
names "on every call" when the re-entrant call it advertises returns the empty
placeholder, and counted two causes for an empty list when there are three. The
claim that tests clearing the registry re-register explicitly was false for four
of the five that clear it. The xdist claim is narrowed to the repo's test rig,
which is what is checkable — there is no .github/workflows here.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address round-3 findings, including a regression in the core change

The lazy import moved WHEN legacy_executor first loads, and two test files
depended on the old timing. On main, importing executor.executors registered
`legacy` at package-import time — before any fixture cleared the registry — so
an unguarded ExecutorRegistry.register(LegacyExecutor) in the test always
followed a cache hit. With the import lazy, that first import now happens inside
the test, after the clear, and the explicit registration raises a duplicate-name
ValueError. tests/test_legacy_executor_index.py and
tests/test_legacy_executor_extract.py fail alone on this branch and pass alone
on main; they survive the full alphabetical suite only because an earlier module
imports the class first, which is why this looked pre-existing. Guarded both,
matching the pattern eight sibling modules already use.

Restore the except BaseException reset removed in round 2. That removal rested
on a false premise: the handler never retried, it re-raised unchanged, and its
only effect was to stop a LATER call reporting "no cloud executors" as though
discovery had succeeded. It also preserves pre-PR behaviour — discovery ran as a
package import side effect, and a module that raises mid-import is evicted from
sys.modules, so the next import re-ran it. The duplicate-registration hazard
cited as the reason to drop it applies to re-running discovery at all, so it
predates this function and was never traded away by keeping the reset.

Restore the clearing teardown, also removed in round 2. It was made additive to
stop it deleting a registration the class itself caused, but nothing in the
suite depends on import-side-effect registration of `legacy` — every consumer
registers explicitly or assigns directly — so the snapshot restore was correct
isolation and the additive version leaked `legacy` into every later test in the
worker. Worse, the one test that ran unpatched discovery would, where the cloud
wheels are installed, have leaked all eight cloud executors into names the
sibling modules register themselves.

That test is rewritten: it arranges its own precondition instead of inheriting
one, patches discovery so it stops doing real entry-point loading, and asserts
only the property it is named for. As written it was order-dependent — it failed
deterministically after any module that clears the registry — and its registry
assertion was satisfied by its own import line rather than by register_all().

Docstrings: "the only caller" was false (the suite calls it in-process) and the
handler-removal argument was reasoned from it; restored the "production"
qualifier and the note that executor/worker.py's app is test-only. The stated
failure symptom for a broken plugin wheel held only when the plugin raises
before its decorator. Cited the rig path repo-root-relative.

All six guards mutation-verified.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: address round-4 findings

Guard the two remaining duplicate-registration sites. test_legacy_executor_
scaffold.py is a proven regression from this branch, not pre-existing as an
earlier check concluded: run alongside the index and extract modules it passes
on main (52 passed) and fails here. My earlier check ran that file ALONE, where
it is broken on main too for its own reason, which hid the difference. Mutation-
verified: removing the guard reproduces the failure in that ordering.
test_plugin_migration_regression.py has the identical shape; its guard is
defensive consistency and is NOT pinned by a test — that file's own earlier
tests import legacy_executor before the helper runs, so no ordering within it
exercises the guard. Recorded rather than claimed as covered.

"This does not retry" was true of the handler and false of the mechanism:
un-arming the latch is exactly what lets the NEXT call re-run discovery, and the
comment then justified itself by saying the next import re-ran it. Both cannot
hold. It now says plainly that it re-arms a retry for a later caller, that the
re-run is not free when a plugin registered before it raised — that plugin ends
up live in the registry but absent from the returned list — and that no
production caller survives the re-raise to make that call.

The Returns block now says a non-empty list can under-report the registry after
a failed-then-re-run discovery. The _cloud_executors sentinel comment said None
means "discovery has not run"; since the handler came back it also means "ran
and raised". The empty-list placeholder moved inside the try, so an async signal
between the assignment and the try cannot leave it armed. The register_all
docstring no longer offers test_legacy_executor_scaffold.py as "the pattern to
copy" — that file carried the unguarded helper this commit fixes; it states the
guard rule directly instead.

TestExecutorsInit's docstring claimed the fixture leaves the process as it found
it. sys.modules is not restorable and this class can be the first importer of
legacy_executor, which is the state the sibling guards exist to survive.

test_failed_discovery_un_arms_the_latch is parametrised rather than looped: a
for loop stops at the first failing arm, and the KeyboardInterrupt arm is the
one the docstring argues is the point. Its docstring no longer says only
BaseException reaches the handler — an entry_points() failure is an ordinary
Exception and reaches it too, which is what the RuntimeError arm covers.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: close the last duplicate-registration site; cut the over-claiming prose

tests/test_line_item_extraction.py was the last site with the hazardous
ordering — autouse fixture clears the registry, then the test imports
legacy_executor and registers it unguarded. When that import is the first in the
process the decorator fires after the clear and the explicit register raises.
Fails alone on this branch, passes alone on main. Routed through the module's
existing guarded helper. Mutation-verified. An enumeration of all 22
ExecutorRegistry.register sites under workers/tests/ says this was the last one
of its shape: 13 already guard, 2 import before they clear and are safe by
ordering.

Delete the historical narrative from the guard comment in all four files. It was
written for index/extract, where it is true, and copied into scaffold and
migration_regression, where it is false: scaffold fails alone at the merge base,
so it was never "unguarded safely", and in both of those the first import of
legacy_executor was already inside a test before UN-4136, so UN-4136 moved
nothing. Only the rule survives, which is true everywhere.

The prose in register_all() has now been wrong in four consecutive rounds, each
time by being true of the case it names and false of one it never mentions, so
this trims rather than rewrites:
- the guard rule stated one implementation as mandatory and gave a reason that
  inverted the actual cause (the hazard is the import has NOT happened yet);
  it now states the invariant, and notes clearing immediately before is equally
  valid, which is what test_summarize_operation.py does;
- the under-report note was scoped to a non-empty list, but with a single
  installed plugin the re-run returns [] — folded into the empty-list
  enumeration as "at any length, including zero";
- the handler comment claimed every already-registered plugin is evicted from
  sys.modules. Only the module that was mid-import when the exception escaped
  is. Narrowed, and the surrounding argument cut to what is checkable.

Also reverts formatter churn this branch introduced in two test files: a
`ruff format` run reflowed an aligned comment block unrelated to the guard.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: correct the inverted sys.modules claim; record the duplication follow-up

TestExecutorsInit's docstring said the sibling guards "assume" the sys.modules
entry for legacy_executor is left behind. They assume the opposite: the guard is
INERT when the entry is present (the import is a cache hit, the decorator does
not fire, the cleared registry has no `legacy`, the register runs) and
load-bearing when it is absent. The previous commit fixed this same inversion in
register_all()'s docstring and missed this sentence.

Record in code that the guard idiom is hand-copied across 23 sites in 18 files,
in three spellings, and that folding it into a shared test helper is the
follow-up rather than something this PR does. Counted: 23 guard sites, 7 direct
_registry["legacy"] assignments, 1 clear-then-register. 18 of the 23 predate
this branch. Detail on UN-4136.

Correcting the record on the previous commit: it stated the
test_plugin_migration_regression guard is "NOT pinned by a test". That is wrong.
No whole-FILE run pins it, but three class subsets fail without it, and the rig
runs xdist with the default per-test `load` scheduler, so the unit of ordering
in CI is the node. Measured with the guard removed:

  whole-file:  31 passed        <- what the mutation gate was measuring
  node-level:  3 classes fail   <- the guard is load-bearing

That wrong granularity is also why the test_line_item_extraction site survived
four rounds of module-ordering A/B: a defect that only appears when a node runs
without its file-mates is invisible at module granularity. Mutation checks on
the remaining guards were re-run at node level.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: cut two over-specified sentences added last round

Both are the same failure this PR keeps repeating — saying more than is true —
so both are trimmed rather than restated.

The register_all() docstring claimed the guard rule is hand-copied across "~23
sites in three spellings". The 23 is the count of the guard IDIOM, but only 16
of those are followed by ExecutorRegistry.register; the other 7 assign
_registry["legacy"] directly, which cannot raise, so the rule does not reach
them. The count also excluded the third spelling's only site, so it matched two
of the three it claimed. Dropped: a tally of test-directory sites in a
production docstring rots on the next test added, and the duplication is the
fact worth recording, not the number.

It also pointed the follow-up at UN-4136, which is the ticket for THIS change —
cited as the problem being fixed at four other sites on the branch. A reader
following it lands on the ticket this PR closes and concludes the follow-up
shipped. The follow-up is recorded on that ticket as a comment and in the PR;
the docstring now just says it is deliberately left out.

TestExecutorsInit's docstring said the sibling guards are "inert when the entry
is present and load-bearing when it is not". True of the four modules whose
helper calls register(), false of the 7 direct-assign sites, where the guard is
inert in both states. The surviving sentence — the guards are correct either
way, so leaving the sys.modules entry is harmless — is true unconditionally.

Verified while checking this: all three touched test files now differ from
origin/main by their guard alone, with no formatter churn remaining.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop claiming ordinary plugin exceptions reach the BaseException handler

Round 5's trim replaced a correct enumeration with a broader one that is false.
ep.load() sits inside discover_executors' per-entry-point `except Exception`
(loader.py:48-56), so an ordinary exception from a plugin never escapes — it is
logged and skipped there. Only a non-Exception BaseException, or a failure of
entry_points() itself, reaches the handler.

The risk is not the wording. A maintainer who believes anything out of ep.load()
reaches this handler has no reason to keep loader.py's per-entry-point catch,
and removing it turns "one broken plugin wheel is logged and skipped" into "one
broken plugin wheel aborts discovery" — on the executor worker's boot path. The
comment now says what that handler is for rather than enumerating what escapes.

Verified both ways: read against loader.py, and by driving a fake entry point
that raises RuntimeError — register_all() returns [] rather than raising.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: stop explaining the BaseException choice in prose; point at the test

Third writing of this sentence, third distinct falsehood. The latest claimed an
ordinary exception from a plugin "never reaches here". It can: entry_points()
sits OUTSIDE discover_executors' per-entry-point try (loader.py:46 vs :48), so a
wheel with malformed entry-point metadata raises an ordinary Exception that
escapes straight to this handler. Reproduced by building a dist-info whose
entry_points.txt has a garbage line — TypeError, isinstance(Exception) True,
escapes discover_executors.

So the previous wording was wrong in the other direction from the one before it,
and the one before that was wrong differently again. Each rewrite was a fresh
surface for the same defect.

Replaced with a pointer to test_failed_discovery_un_arms_the_latch, which is
parametrised over both classes that can reach the handler — an ordinary
Exception out of entry_points(), and a non-Exception BaseException out of
ep.load() — and pins them by executing rather than by asserting in prose. Both
arms pass and report independently.

The correctly-scoped statement of what the per-entry-point handler absorbs
already lives in the module docstring, which says "failed to import" rather than
the broader "broken wheel" that made the last version false.

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix: give the test pointer a path, and SystemExit its own arm

Two below-floor items from the final review round.

The pointer to the test that pins the BaseException choice gave a bare function
name, while the file's two other test references both carry paths. A pointer's
whole value is that it resolves, and a grep that comes back empty after a rename
is indistinguishable from a typo. It now reads
tests/test_plugin_loader.py::test_failed_discovery_un_arms_the_latch.

The test docstring named KeyboardInterrupt/SystemExit as the classes escaping
ep.load() while only KeyboardInterrupt had an arm. Rather than argue which
reading was intended, SystemExit now has one — it is free, since the handler
treats them identically. Verified it discriminates: with except BaseException
narrowed to except Exception, the RuntimeError arm passes (as it must) and both
the KeyboardInterrupt and SystemExit arms fail.

The docstring also stops saying "both classes", which was only true while there
were two arms, and now describes the partition instead: an ordinary Exception
escaping entry_points(), and the BaseException-not-Exception classes escaping
ep.load().

UN-4136

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Rahul Johny <116638720+johnyrahul@users.noreply.github.com>
Co-authored-by: ali <117142933+muhammad-ali-e@users.noreply.github.com>
…requires auth (#2300)

quay.io/minio/minio and quay.io/minio/mc started returning 'unauthorized',
breaking the integration (testcontainers) and e2e (compose) CI tiers.
Mirror cgr.dev/chainguard/minio and minio-client:latest-dev to Docker Hub
under unstract/* and pin by digest. Run minio as root so existing
root-owned minio_data volumes stay writable under Chainguard's uid 65532.
…r logo and match the 2026 top-bar design (#2296)

* UN-4172 Render the cloud product switcher in place of the top-bar logo and match the 2026 top-bar design

- TopNavBar: when the platform-dropdown plugin is present it carries the
  product mark, so it replaces the logo instead of trailing it. OSS-only
  builds keep the logo.
- Top bar is 56px with 12px gutters (Figma 1638-44720); min-height keeps an
  OSS-only bar the same size.
- Avatar menu restyled to the design: 32px avatar, chevron, 40px target,
  6px spacing between right-side items.
- Switch Org: give the row its own flex layout (the old rule keyed on an
  antd class the Dropdown shim no longer emits, stacking the icon over the
  label), and stop its click from selecting the parent menu row, which
  closed the menu and the org list with it.

* UN-4172 Add a regression test for Switch Org's nested dropdown

Drives the profile menu with user-event's full pointer sequence, asserts the
org list stays open after a real click on Switch Org, then picks an org and
checks the switch request is sent. Both tests fail with the click guard
removed.
* UN-2646 [FEAT] Add LLMWhisperer image output mode to the v2 adapter

Adds an "image" output mode to the LLMWhisperer V2 X2Text adapter: it converts a
PDF to per-page images via the LLMWhisperer pdf-to-images API and returns them as
PageImageReference objects (persisted to FileStorage), never smuggled into
extracted_text, so text-mode consumers are unaffected.

- dto.py: PageImageReference + additive TextExtractionMetadata.page_images.
- constants.py: OutputModes.IMAGE, ImageOutputConfig (PDF-only), OUTPUT_MODE key.
- helper.py: get_page_images() — submit / poll / retrieve+unzip the pdf-to-images
  job and persist the page images.
- llm_whisperer_v2.py: _process_image_mode() + output-mode branch in process(),
  with PDF-only validation.
- json_schema.json: "image" enum + "Image (PDF only)" label + description.

Recovered from the earlier implementation (MFBT phase
llmwhisperer-image-output-mode-adapter) and rebased onto main, kept independent of
the document_insights/signature feature (PR #1967). 45 tests pass. MUNS-193/194/195
complete; MUNS-196 UI validation (UNS-757/758/759) remains.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FEAT] Conditional PDF-only guidance on image mode (UNS-759)

Convert the adapter json_schema's single top-level if/then into an allOf so a
second conditional can coexist with the existing low_cost one. Adds a PDF-only
guidance note (type: null field, RJSF renders it as a labelled callout) shown
only when output_mode == "image", guarded by required:[output_mode]. Purely
UI/UX; no effect on submission, validation, or backend behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-2646 [FIX] Gate image PDF-only guidance to image mode (UNS-759)

The output_mode description carried the image PDF-only note unconditionally,
so it rendered for every mode (Layout Preserving / Text too). Move it into an
if/then/else on output_mode: the note shows only when Image is selected;
other modes show the neutral documentation description. The previous
type: null notice field never rendered (RJSF NullField emits nothing).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FEAT] Fail-fast PDF-only check before index dispatch (UNS-757)

When the profile's x2text adapter is in image output mode, reject non-PDF
inputs in build_index_payload (the live pre-dispatch path) so the user gets
the SDK's PDF_ONLY_ERROR at index time instead of an extraction failure inside
the executor. Message is sourced from ImageOutputConfig.PDF_ONLY_ERROR so the
early check and the SDK runtime guard stay in sync. Mirrored in the legacy
index_document path for symmetry. Adds no-DB unit tests (10 cases).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Resolve SonarCloud findings on image-output code (UNS-758)

- helper.py: drop redundant 0* from the page-image regex — int() already
  strips leading zeros, so 0*(\\d+) was ambiguous and flagged for
  super-linear backtracking. Now a linear page[_-]?(\\d+)\\.png$.
- Tests: hoist non-asserting setup out of pytest.raises blocks so each
  block has exactly one call that can throw (3 sites).
- Test: split the deterministic build_page_store_dir assertion into two
  named locals so the reliability check no longer sees identical
  expressions on both sides of == (was flagged as a bug).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Address CodeRabbit/Greptile review on image-output helper

- _send_raw_request: give timeout a finite default (IMAGE_REQUEST_TIMEOUT)
  so test_connection can no longer hang forever on a stuck endpoint.
- download_pdf_to_images_zip: consume the stream in try/finally — map
  read-time transport errors to ExtractorError and always close the
  response so a mid-stream failure cannot leak the connection.
- extract_page_images_from_zip: fail closed on an archive with no page
  images (was a silent empty success), and also catch RuntimeError
  (encrypted member) and zlib.error (corrupt member) alongside BadZipFile.
- Tests: scope the constants env patches via monkeypatch.context() so the
  module reload no longer leaks a patched value into later tests; add
  coverage for the no-page-archive hard error and the finite default
  timeout.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Make image-mode results survive indexing + cache (UNS-758)

Resolves the two pipeline gaps that capped Greptile confidence at 3/5:

1. page_images dropped in transit: the executor built the indexer payload
   from extracted_text only, so image-mode references never reached the
   indexer and the document indexed empty. The executor now forwards
   page_images (new IKeys.PAGE_IMAGES), mirroring the highlight_metadata
   pattern, so the references survive the transport.

2. Repeated remote conversions on re-run: image mode never wrote the extract
   file the Prompt Studio extraction cache gate requires (non-empty), so every
   re-run re-submitted the pdf-to-images job. Image mode now returns a short
   human-readable summary as extracted_text and writes it to the extract file
   (plus a durable <output>.page_images.json manifest of the references), so
   the cache treats the conversion as complete and the indexed document is
   meaningful instead of blank. References are never inlined into the text.

Tests: update the image-mode process assertions for the summary contract and
add coverage for the extract-file/manifest write and the summary format
(49 sdk1 image-output tests pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Keep image-mode cache fix, drop unused ref plumbing (UNS-758)

Scope the previous change to just the real bug fix. The executor page_images
forward and the JSON manifest sidecar were producer-side plumbing with no
consumer anywhere in the codebase, so they added unused surface without making
image references usable end-to-end. Removed both.

Kept: image mode returns a short summary as extracted_text and writes it to the
extract file, so the Prompt Studio extraction cache treats the conversion as
complete and a re-run is a cache hit instead of a re-submit of the remote
pdf-to-images job (the billing bug). Per-page references remain on
extraction_metadata.page_images and the images are persisted to FileStorage; a
Prompt Studio consumer of those references is deferred as follow-up.

49 sdk1 image-output tests pass; ruff/pycln clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Address Chandru's review on image-output mode

Correctness / robustness:
- Return the real whisper_hash in extraction_metadata (was ''), and reject
  image mode + highlight explicitly instead of silently dropping highlights.
- Store page images under {extract_dir}/{stem}/pages (per-document), not the
  per-run hash — a re-extraction now overwrites its own pages instead of
  orphaning a fresh tree in FileStorage every run.
- persist_page_images cleans up already-written pages on a mid-list failure.
- Poll loop fails closed: only explicit intermediate states continue; a
  failure/unknown/empty status raises immediately instead of hanging to the
  budget. _safe_json logs non-JSON/non-object bodies.
- verify_page_count is now live: expected count derived locally from the input
  PDF (pdfplumber); extract asserts a contiguous 1..N page set (catches
  truncation + duplicate/misnamed members) and logs skipped entries.
- Retrieve advertises accept: application/zip; submit sends Content-Type.

Layering / guard (UNS-757):
- Promote OUTPUT_MODE/image/PDF_EXTENSION/PDF_ONLY_ERROR + an is_pdf() helper
  to the shared x2text.constants (ImageOutputConstants); backend imports the
  generic surface instead of the adapter's private src.
- Move the PDF-only guard into dynamic_extractor (the single extract choke
  point, under profile_manager) so all entry points and prompt-level profile
  overrides are covered; gate on the LLMWhisperer adapter id as well as
  output_mode so other x2text adapters can't inherit the rejection.

Comments: drop ticket-id/PR provenance and the contradictory PR#536/#647
contract notes; remove the unused IMAGE_MODE_LABEL 'single source of truth'.

Tests: fix the constants-reload cross-file pollution (retry tests now pin the
real budget on helper.WhispererDefaults); add network-layer coverage
(poll success/failure/non-JSON-fast-fail/budget, mid-stream download error +
close, submit-without-hash), mid-list cleanup, image+highlight rejection, a
non-LLMWhisperer no-op, and a dynamic_extractor call-site test. sdk1: 57 pass;
backend guard: 12 pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-2646 [FIX] Drop remaining PR/ticket provenance from image-output comments

Follow-up to the review round: remove the contradictory 'ASSUMED contract
(PR #647)' vs 'verified against PR #536' notes and the 'decision 2A' / UNS-743
provenance from the adapter helper + constants comments, keeping the factual
one-line reason (comments carry the why, not the ticket).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* UN-2646 [FEAT] Gate image output mode behind the cloud consumer plugin

The image output mode produces per-page billed PNGs that are consumed by
the VLM answer plugin, which ships only with Unstract Cloud. Without the
plugins.vlm_image_answer package:

- the 'image' option (and its conditional description) is stripped from
  the LLMWhisperer V2 adapter's JSON schema, so the UI cannot select it
- adapter create/update and test-connection reject image-mode metadata
  with a clear error, covering API-created adapters that bypass the UI

This prevents OSS deployments from configuring an extraction whose
output nothing can consume.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FEAT] OSS half of the VLM image-answer feature (path contract, loader, vision policy, bridge) (#2218)

* UN-2646 [FEAT] Promote page-image path contract to the shared x2text surface

Foundation for the VLM consumer (MUNS-206): the writer (adapter) and the
upcoming page-image reader must agree on the storage layout without
metadata persistence or a manifest sidecar.

- Promote build_page_store_dir ({extract_dir}/{stem}/pages) from the
  LLMWhisperer helper to unstract.sdk1.adapters.x2text.constants; the
  helper now binds the shared function directly (identity, not a copy)
- Add page-naming constants to ImageOutputConstants: PAGES_SUBFOLDER,
  PAGE_IMAGE_PREFIX/EXTENSION/PADDING, PAGE_GLOB_PATTERN and
  PAGE_NUMBER_REGEX (integer capture for natural sort — lexicographic
  ordering misorders past page 999)
- ImageOutputConfig now aliases the shared layout constants; the
  tolerant ZIP-member pattern moves to a named ZIP_PAGE_MEMBER_REGEX
  (service wire contract, distinct from the storage contract)
- New writer/reader agreement tests: path determinism across stem
  shapes, glob/regex contract incl. >999-page fixtures, and the
  writer's filename builder round-tripping through the reader's
  glob + regex

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FEAT] FileStorage page-image loader and vision-capability policy

Reader-side sdk1 utilities for the VLM consumer (MUNS-207 + the sdk1
half of MUNS-208):

- page_image_loader: discovers persisted page_NNN.png files through the
  FileStorage abstraction using the shared naming constants, orders by
  integer page index, enforces a fail-fast page cap before any bytes are
  read, base64-encodes, and shapes complete_vision content blocks with
  'Page N' labels before each image. Typed failures keep empty
  (never-extracted/purged), incomplete (post-write loss, with found vs
  missing pages), and over-cap cases distinct — remediation copy steers
  to cache-bypass re-extraction with the per-page re-billing warning.
- vision_capability: classifies a model as SUPPORTED / UNSUPPORTED /
  UNKNOWN from litellm's local model_cost registry only — never
  get_model_info, which can make network calls for self-hosted
  providers, and never bare supports_vision, which returns False for
  both non-vision and unknown models and would wrongly hard-block
  custom vision models (Ollama, proxies). Policy: hard-block only a
  definitive UNSUPPORTED; UNKNOWN warns and allows.

Tests: 33 new (in-memory S3-like double + real local FileStorage
backend, >999-page ordering, cap-before-read assertion, registry fakes
+ real-registry smoke checks). Full sdk1 image suite: 104 pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FEAT] OSS bridge for the cloud-only VLM image-answer plugin

Image-mode prompts are answered by a vision LLM via the cloud-only
'vlm-image-answer' executor plugin. This adds the OSS half (mirrors the
lookup_enrichment bridge, with the opposite error policy — lookups
degrade, image mode fails loudly):

- workers vlm_image_answer bridge: detects image mode by resolving the
  x2text adapter config through the platform service (the payload
  carries only the instance id), cached per (execution, adapter);
  dispatches to the plugin with the deterministic pages dir from the
  shared path helper; raises structured, code-prefixed errors
  (IMAGE_OUTPUT_REQUIRES_CLOUD / IMAGE_OUTPUT_MISSING /
  IMAGE_PAGE_CAP_EXCEEDED / VISION_LLM_REQUIRED) that survive the
  string-only error propagation to PS and deployment responses —
  never a silent fallthrough to the text path
- legacy_executor: image-mode branch replaces retrieval + completion
  only; type conversion, lookups, webhooks and challenge run unchanged.
  Single-pass extraction is rejected for image-mode profiles before
  delegating to the cloud single-pass plugin
- backend vlm_utils no-op bridge (lookup_utils pattern) wired into:
  profile serialization (non-blocking vision_warning), API deployment
  creation (deploy-time guard), and the extraction choke point
  (VLM answer invalidation after a fresh extraction rewrites pages/)

Tests: 13 bridge (detection/cache/dispatch/error mapping/single-pass
guard) + 8 vlm_utils (OSS no-ops + cloud delegation policies). Full
sdk1 image suite (104) and gating suite (12) stay green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FEAT] Surface backend vision_warning after profile save

Profile save responses may carry a backend-computed vision_warning
(image output mode selected with an LLM that is not verifiably
vision-capable — populated by the cloud vlm_image_answer hooks, never
present in OSS responses). Show it as a non-blocking warning toast so
the user learns about the mismatch at config time instead of at run
time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Surface typed errors for stale listings and vanished pages

Live-testing regression: with pages purged from object storage, the
loader raised a raw FileNotFoundError instead of the typed
incomplete-set error — fsspec's directory cache served discovery a
stale listing in the long-lived worker, so the contiguity check passed
and the purge only surfaced at read time, bypassing the error mapping.

- discover_page_images now refreshes the backend's listing cache first
  (duck-typed invalidate_cache; no-op for backends without one)
- load_page_images maps a read-time FileNotFoundError (TOCTOU: page
  vanished after discovery) onto PageImageSetIncompleteError with the
  cache-bypass + re-billing remediation
- in-memory test double now raises FileNotFoundError like real backends

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Sniff PDF content when the storage name has no extension

Live deployment-path regression: workflow executions store the source
file under an extension-less name (e.g. SOURCE), so the extension-only
PDF guard false-rejected every API-deployment input — a real .pdf
upload failed with the PDF-only error.

_validate_pdf_only now checks the extension first and falls back to
content sniffing (%PDF- magic bytes via FileStorage) when the name has
no .pdf suffix; unverifiable content still rejects (fail-closed). No
extra read on the common .pdf-named path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Raise the gating rejection with a dict detail, not a bare string

Live-testing regression: validate_image_output_allowed raises from
inside AdapterInstanceSerializer.to_internal_value, where DRF folds the
detail into its per-field error mapping — a bare-string detail crashed
error collection (ValueError: dictionary update sequence) and surfaced
as a 500 'Something went wrong' instead of the clean 400 with the
'available only on Unstract Cloud' message. Use a field-keyed dict
detail, and log the rejected adapter/mode for diagnosability.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Address Greptile review: run-scoped mode cache and byte budget

Two review findings on the consumer path:

- Run-scoped detection cache (P1): IDE payloads carry no execution_id,
  so every IDE run shared one ('', adapter) cache entry — an adapter
  switched between text and image output kept serving the stale mode
  until worker restart. The cache key now scopes to execution_id or,
  for IDE runs, the run_id (deduplicating the N per-prompt resolutions
  within one run — the cache's actual purpose — with no cross-run
  reuse), and skips caching entirely when no scope id exists.
- Aggregate byte budget on image loading (P2): the page cap bounds the
  COUNT of images, not their size. load_page_images now enforces a
  50MB (default, disable-able) raw-byte budget while reading, raising
  the typed PageImageSetTooLargeError with page-range remediation; the
  bridge maps it to a distinct IMAGE_PAGES_TOO_LARGE error code.

Tests: run-scoped vs no-scope cache behavior, byte-budget stop-at-page
accounting, and the new error-code mapping.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Enforce the byte budget from storage metadata before any read

Greptile follow-up: the read-time budget still pulled each full object
into worker memory before checking cumulative size, so one pathological
page could spike RAM before rejection. The budget is now enforced twice:
first from storage size metadata (object HEAD — zero bytes transferred)
before any read, so an oversized set is rejected with no image bytes in
memory; the read-time accounting stays as a belt-and-braces guard for
backends without size metadata and for stat/read races.

Tests: metadata pre-check rejects with zero reads; no-size() backends
still enforced at read time; unreadable metadata falls back cleanly.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Address review: extract-path keying, stamped detection, image-mode scoping

Addresses Chandrasekharan's review on the consumer path:

1. Pages directory keys on a never-rewritten extract path (blocker):
   summarize-as-source and smart-table runs rewrite the payload
   file_path before the answer step, so the reader looked in the wrong
   directory and surfaced IMAGE_OUTPUT_MISSING with a paid remediation
   that could not help. The payload builders (IDE single/bulk and the
   structure tool task) now stamp an explicit extract_file_path,
   captured before any rewrite; the bridge derives pages/ from it, with
   the old file_path as fallback for older payloads.

2. Text-mode prompts no longer depend on the platform service: the
   backend stamps the x2text adapter's output_mode per prompt (it
   already holds the decrypted metadata), so detection is payload-only
   for stamped runs; the run-scoped platform resolution remains as the
   fallback for unstamped payloads (API deployments, older payloads).

3. Challenge and evaluation are skipped in image mode with a visible
   log line — both verify an answer against retrieval context, which
   image mode does not have; running them billed a doomed second LLM
   call. A vision-aware challenge is a later-phase decision.

4. Retrieval adapters are no longer constructed for image-mode
   prompts: detection now runs before adapter init, which proceeds
   LLM-only (embedding/vector DB skipped) when image mode is detected.

5. Profile-save toasts no longer swallow each other: one alert renders
   — 'Saved — check LLM compatibility' with the warning when present,
   plain success otherwise (the alert store holds a single entry, so
   two synchronous calls batched into showing only the last).

6. vlm_utils logs a distinct warning when plugins.vlm_image_answer is
   present but backend_hooks fails to import — 'cloud hooks broken' is
   no longer indistinguishable from 'running OSS'.

Bridge API split accordingly: detect_image_mode_config (stamp fast
path + platform fallback) and run_vlm_image_answer (dispatch only,
keyed on extract_file_path). Tests: 23 bridge (stamped/unstamped
detection, cache scoping, extract-path keying) all green; sdk1 130;
backend 20.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-2646 [FIX] Bound page-image reads to the remaining byte budget

Greptile's follow-up was right: the metadata pre-pass fell back to an
unbounded full-object read when the size lookup failed, so one
pathological object could still spike worker memory before the budget
check. Replaced both layers with a single stronger mechanism — bounded
reads: every page is read with length = remaining budget + 1 via
FileStorage.read, giving a hard allocation ceiling of budget + 1 bytes
for the whole loop, independent of object sizes or the backend's size
metadata. The metadata pre-pass is gone (also Chandrasekharan's call —
one mechanism, less code).

Also folds in the review nits: dead PAGE_GLOB_PATTERN removed (readers
list + regex, never glob) and the duplicated not-found message
extracted to one helper.

Tests: bounded-read guarantees pinned (single 10MB object → exactly 51
bytes read; aggregate reads <= budget + 1) — 127 sdk1 + 23 bridge pass.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>

* UN-2646 [FIX] Gate the platform fallback by execution source; fix CI test fallout

The merged consumer branch put the pre-existing workers suites in front
of the image-mode bridge for the first time (they never ran on the
stacked PR), and ~105 tests failed on one mechanism: unstamped payloads
sent the detection fallback to the platform service, whose URL came
from a MagicMock shim.

The semantic fix, not a test hack: IDE payloads are always stamped by
the current backend, so an unstamped IDE payload is a pre-upgrade
in-flight run — detection now treats it as text mode instead of making
every legacy IDE prompt depend on a platform call. The platform
resolution remains only for non-IDE (deployment) payloads, which are
built worker-side without adapter metadata — that real dependency is
stubbed once in the workers test conftest (the same place the suite
already mocks celery and the shim), with image-mode tests opting in via
the payload stamp. The single-pass guard gets the same semantics
(stamp first, resolve only for non-IDE).

Also fixes the sdk1 collection error: the loader test imported the
shared fixture bare (PYTHONPATH-dependent) instead of via the tests
package like its siblings.

Bridge tests: 26 pass, incl. unstamped-IDE-never-calls-platform and
stamped-guard-never-calls-platform pins.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Reset the stable pages dir before persisting a new page set

A re-extraction that yields fewer pages than a previous run left in the
stable pages directory would otherwise keep the old trailing images,
which the reader serves back as part of the new document (Greptile P1).
The writer now clears the directory wholesale before writing, and a
failed reset raises instead of risking stale pages reaching the vision
LLM — consistent with this feature's fail-loud policy.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Stamp x2text output mode onto single-pass payloads

build_single_pass_payload was the one payload builder that skipped
_stamp_x2text_output_mode, so IDE single-pass payloads arrived
unstamped and the executor's single-pass guard — which treats an
unstamped IDE payload as a pre-upgrade text-mode run — never fired.
Image mode + single-pass would have silently answered every prompt
against the one-line extraction summary once the cloud consumer lands.

Stamps tool_settings alongside X2TEXT_ADAPTER, matching the other
three builders. Pinned by tests that drive build_single_pass_payload
with all collaborators patched and assert the stamp lands in the built
executor payload (image, non-image, and non-LLMWhisperer cases), so
deleting the stamp call fails the suite.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Fail closed when the VLM plugin is installed but broken

A half-broken cloud install (plugins.vlm_image_answer package present,
backend_hooks failing to import) previously logged a warning and
degraded to no-ops while the adapter gating — probing only the package —
kept image output mode enabled. Re-extracting an image-mode document
would then rewrite its page images while the answers stored against the
old pages were never invalidated (Greptile P1).

Both surfaces now share one fail-closed verdict:
- The gating probe requires backend_hooks to import, so a broken
  install strips image mode from the schema and rejects new saves.
- vlm_utils exports VLM_HOOKS_BROKEN, and dynamic_extractor's image-mode
  guard (the single extraction choke point) rejects image-mode
  extraction outright in that state — pages can never be rewritten
  while stale answers survive. Text-mode profiles are unaffected.

Tested across all three surfaces: probe verdicts for absent, broken and
healthy installs (forced via sys.modules so the local cloud overlay
cannot skew them), the choke-point rejection incl. text-mode passthrough,
and the OSS not-broken baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Order invalidation before the success marker; propagate hook errors

Greptile flagged that a runtime failure in the re-extraction
invalidation hook was swallowed after the success marker had already
committed — so a retry would cache-hit past the failed invalidation and
stored answers could stay stale against rewritten page images, silently
and permanently.

Today this is unreachable (the cloud hook is purely informational:
Prompt Studio has no read-side answer cache; output rows are
overwritten per run, the same semantics text-mode re-extraction has
always had). But the contract now makes it structurally impossible for
any future hook that performs real invalidation:

- The bridge no longer swallows hook exceptions — the hook owns its
  error policy, and a raised error fails the re-extraction loudly.
- dynamic_extractor invokes the hook BEFORE committing the
  extraction-success marker, so a failed invalidation leaves the marker
  unset and a retry re-runs extraction + invalidation instead of
  cache-hitting past a stale-answer state.

Zero behavior change for the current hook; the bridge tests now pin
delegation and propagation separately.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [MISC] Retrigger CI: review-thread confirmations added for the two flagged P1 threads

No code change. The two threads Greptile flagged as resolved-without-
explanation now carry developer confirmations (fixing commit b49587a
on the invalidation thread; intentional-deferral rationale on the
concurrent-reads thread). This empty commit re-fires the required
Greptile Review check so the score reflects that state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Verify the pages dir is empty after reset, not just that rm returned

Greptile caught a real gap in the directory reset: FileStorage.rm's
S3-compatibility fallback (MissingContentMD5 → per-object deletes) only
WARNS on individual deletion failures, so rm can return successfully
while old page files survive. A survivor numbered within the new page
range would silently join the replacement set — exactly the stale-page
defect the reset exists to prevent.

The writer now verifies the prefix is actually gone after rm (through a
fresh listing, invalidating fsspec's dircache first) and raises
ExtractorError on survivors instead of writing a contaminated set.
Regression test drives an rm that silently leaves a file behind; the
in-memory fixture's rm now also drops directory entries, matching real
backends.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [DOCS] Document the accepted concurrency contract at the writer and reader

The overlap between a same-document re-extraction and an in-flight
prompt execution is a designed, accepted limitation: the reader fails
with a typed, retryable error (IMAGE_OUTPUT_MISSING) rather than ever
mixing old and new pages into one answer. This was agreed in review
(atomic directory replacement does not exist on object storage; a
generation-versioned scheme was considered and rejected as out of
scope) but the code never said so — record the contract in the writer
and reader docstrings so it reads as intent, not oversight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* [MISC] Retrigger review: flagged threads reopened with developer responses in place

No code change. The two threads previously flagged as
resolved-without-response are now unresolved and carry the developer
confirmations (fixing commit b49587a on the invalidation thread;
intentional-deferral rationale on the concurrent-reads thread) so the
review run reads them in their answered, open state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [TEST] Pin the no-op-rm variant of the post-reset survivor guard

The guard itself (verify the pages prefix is gone after rm, raise
ExtractorError on survivors) landed in c457331 together with a
partial-delete regression test. This adds the degenerate variant the
review asked for verbatim — rm as a complete no-op — asserting the
write is rejected and the old set is left untouched.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* UN-2646 [FIX] Mark extraction failed when VLM answer invalidation raises

A success marker for the config hash can already exist when the cache-hit
path falls through to re-extraction (text file missing). If the invalidation
hook then raised, that marker survived and the retry cache-hit against the
freshly written text, skipping invalidation. Mark the hash failed before
re-raising so the retry re-runs extraction and invalidation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* UN-2646 [FIX] Remove extracted text when the failure marker cannot be written

If VLM answer invalidation fails and marking the hash failed also fails,
the stale success marker survives. Delete the freshly written extract file
so a cache hit on that marker falls through the file-missing path and
re-extracts instead of skipping invalidation.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…ontend test group required (#2302)

* UN-4183 Run frontend typecheck and vitest in CI; make the frontend test group required

- bun.lock: record TypeScript 7's native compiler packages, without which
  tsc cannot start on any platform, so typecheck never actually ran
- Fix the one type error that surfaced in antd-inputs.tsx
- ci-frontend-lint.yaml: add typecheck and vitest jobs (gate frontend-only PRs)
- ci-test.yaml: install frontend deps on the unit leg
- groups.yaml: drop optional from the frontend vitest group

* UN-4183 Address review: job-level path filter, --ignore-scripts, bun run test

- Filter frontend/** in a changes job instead of the pull_request trigger, so
  typecheck/vitest report skipped (not missing) on other PRs and can be made
  required checks
- bun install --ignore-scripts (Sonar S6505)
- bun run test --run instead of bunx vitest (Sonar S8543)

* UN-4183 Fail open when the frontend path filter job fails

A skipped job satisfies a required check, so a failed changes job must make
the frontend checks run, not skip.
…hild has loaded (#2301)

UN-4136 Serve /ready from the PG consumer so a startupProbe can wait for every child to load

A prefork PG consumer pod went Ready ~20s after start while its N children
were still running their own `import worker` bootstrap in parallel (3-8
cores for 1-2 minutes). Kubernetes' HPA only discards CPU sampled before a
pod turns Ready, so it read that burst as load: on integration it drove
api-file-processing 2 -> 30 pods in ~75s, and CAST AI's HPA calibration
raised the CPU request to 1.1-1.4 cores from it.

/health cannot express "still loading": the heartbeats are seeded fresh so
liveness does not trip during a slow import. Add a separate /ready:

- LivenessServer takes an optional ready_fn and serves /ready (/readyz):
  200 when it returns True, 503 "starting" otherwise or if it raises. With
  no ready_fn the path stays a 404, so a probe aimed at the reaper fails.
- The supervisor keeps a shared per-child "loaded" flag. A child sets its
  slot after `import worker` and building its consumer; reap() clears it.
  /ready is 200 only when every slot is set, and the JSON reports
  loaded_children. Each child also logs how long it took to load.
- The single-process consumer answers 200: it binds the port only after
  loading.

Liveness is unchanged. The chart's startupProbe on /ready is a separate
change in unstract-cloud.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…d remove dead eslint-disable comments (#2303)

* UN-4184 Enable Biome recommended, a11y and hooks rules and replace dead eslint-disable comments

Turn on Biome's recommended set, the React domain, the whole a11y group and
noUnusedImports. Rules that already had violations are pinned to warn so CI
stays green; clean rules keep their default severity. Baseline and the
warn-to-error plan are in frontend/docs/biome-rule-ratchet.md.

Remove every eslint-disable comment (Biome ignores them). Justified ones become
biome-ignore with a reason; the rest are dropped and surface as warnings.

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-4184 Address review: drop unjustified CustomSynonyms suppression, clarify ratchet doc

The rows memo's delete handler also reads sessionDetails and details.tool_id,
so the biome-ignore reason was wrong; let it surface as a warning (UN-4202).
State that every rule in the ratchet table ends at error, including
noUnusedImports whose default is warn.

---------

Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…tree (#2304)

Cloud CI runs this suite against the OSS tree with the cloud plugins copied
in, so plugins/vlm_image_answer is importable and
test_cloud_package_absent_in_oss fails on every cloud run now that #2210 has
merged and unstract-cloud#1690 supplies the plugin.

Skip the class wholesale rather than that one test: once the hooks are real,
the remaining "returns None" / "does not raise" assertions stop describing
no-ops and pass for reasons they never meant to check. Delegation in that
tree is already covered by the cloud_hooks tests.

Verified both ways: OSS-only tree 10 passed; with plugins/vlm_image_answer
copied in, 5 passed and 5 skipped.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
* UN-2238 [FIX] CSP: allow third-party origins found in report-only sweep

Sweep of the report-only policy on a live deployment (browsing + per-directive
probes) turned up hosts the shipped bundle loads but no directive allows:

- style-src/font-src: cdn.jsdelivr.net (Monaco CSS + codicon)
- img-src: cdn.jsdelivr.net (emoji-datasource), ProductFruits, GTM, GA,
  reCAPTCHA assets, q.stripe.com
- media-src: cdn.productfruits.com (new directive; default-src had no blob:)
- connect-src: unpkg.com, api.productfruits.com, GA4 regional endpoints,
  reCAPTCHA api2, m.stripe.network
- frame-src: googletagmanager ns.html, m.stripe.network

ProductFruits' animations.css was the one violation observed in normal use; the
rest belong to code paths and flows that were not exercised.

Drops the bare `wss:` wildcard: socket.io connects to window.location.origin
(GetStaticData getBaseUrl) and 'self' covers same-origin ws/wss per CSP3,
verified with a ws:// probe against nginx serving this policy.

Adds .claude/skills/csp-check so the policy can be re-checked against a build or
a deployment when a frontend dependency changes.

* UN-2238 [FIX] Enforce the frontend CSP instead of only reporting it

Renames the header from Content-Security-Policy-Report-Only to
Content-Security-Policy. The policy value is byte-identical to the one the
previous commit landed after the origin sweep -- the only change is that a
resource from an unlisted origin is now blocked rather than logged.

That sweep predated the shadcn/React 19 frontend migration, so it was rechecked
against both bundles before flipping:

- the OSS build (`bun run build`, 111 chunks): every external host it references
  is in the policy
- the live us-central deployment (363 chunks, which includes the cloud plugin
  code the OSS build does not have): same

The one host either scan turned up that no directive allows is react.dev, which
appears only in React 19's error-message links and is never fetched -- added to
scan_origins.py's IGNORED set rather than to the policy.

Also refreshes the csp-check skill: it described a report-only policy and a
`npm run build`/frontend/dist layout that is now `bun run build`/frontend/build.

* UN-2238 [FIX] Address review: make the CSP gate trustworthy, widen GA4 hosts

Praveen's correctness pass found the static gate could pass without checking
anything, and that two policy entries do not survive enforcement.

scan_origins.py
- `--dist` is relative to the shell, and the documented `--dist frontend/build`
  run from scripts/ resolved to a directory that does not exist. rglob on a
  missing directory yields nothing rather than raising, so the run printed
  "Scanned 0 bundle files" and then "Every external host ... appears in the
  policy" and exited 0. Now: a missing --dist is a hard error, an empty scan is
  a failure, a URL whose index names no /assets/ chunk (a login wall) is a
  failure, and a chunk that fails to fetch for any reason but 404 is a failure.
  404 stays benign because RELATIVE_ASSET_RE also matches worker paths a chunk
  only names as a string (/assets/pdf.worker.js on us-central).
- allowed_hosts() dropped the path from path-scoped sources, so
  `https://www.google.com/recaptcha/` was read as a grant on the whole host and
  any www.google.com URL scanned as covered. It now keeps the prefix and matches
  URL paths, and understands `*.` as the browser does (subdomains only, so
  `*.google-analytics.com` does not cover a bare google-analytics.com).
- index.html and /config/runtime-config.js are now scanned; neither is under
  /assets/ and the second carries the operator-set branding URLs.

nginx.conf
- GA4 picks a regional collection host at runtime, so pinning
  region1.google-analytics.com + analytics.google.com drops the rest. Switched
  the Google analytics hosts to the wildcards Google publishes for CSP.
  Signals/Ads endpoints are deliberately still not granted, and the comment says
  so, because enabling those in the GTM UI has to come back through this file.
- Recorded that VITE_CUSTOM_LOGO_URL / VITE_FAVICON_PATH are governed by img-src
  and must be same-origin paths; both fail silently otherwise.

Verified: matcher unit cases for the recaptcha/gstatic path scoping and the GA4
wildcards; --dist scan of a fresh build (113 files) and --url scan of us-central
(368 files, up from 363 now that the entry documents are included) both clean.

* UN-2238 [FIX] Address Greptile: a missing real chunk must fail the scan

Two ways the gate could still clear a deployment it had not checked.

- 404 was blanket-benign, so a genuinely missing /assets/ chunk was skipped
  with the rest of its origins. The queue now records where each path came
  from: a chunk index.html links is required and any failure on it, 404
  included, fails the scan; a path found by following RELATIVE_ASSET_RE inside
  a bundle stays speculative, because that regex also matches worker paths a
  chunk only names as a string (/assets/pdf.worker.js on us-central).
- read_dist counts index.html, so a directory holding nothing but index.html
  passed the emptiness check having inspected no bundle at all. It now
  requires at least one .js.

Also wraps fetches in a 3-attempt retry. A single transient TLS reset was
enough to redden a 368-chunk --url scan, which would have taught everyone to
ignore the gate. An HTTP status is an answer, not a blip, so it is not retried.

Verified: --dist scan of a fresh build clean; an index.html-only directory now
exits 1; --url scan of us-central clean at 368 files with the pdf.worker.js 404
still tolerated.

* UN-2238 [FIX] Parse the policy the way the browser does; probe the wildcards

A second review pass over the whole PR, not just the last commit.

extract_policy.py feeds both the scanner and the browser probe, so when it
reads a different policy than nginx serves, every check downstream is checking
a fiction. Two ways that happened:

- `HEADER_RE.search()` took the first match anywhere in the file. A
  commented-out add_header won over the live one, and a -Report-Only header
  placed above the enforcing one won too. The second is the realistic case: a
  Report-Only header alongside the enforcing one is how you trial the next
  policy change, and is the shape this repo was in until this PR. Comment
  lines are now stripped, the enforcing header is preferred, and two of the
  same kind is an error rather than a silent pick.
- Duplicate directives were parsed last-wins; browsers honour the first and
  ignore the rest, so `img-src 'self'; ...; img-src https://evil.example` made
  the gate clear a source the browser never applies. First now wins, with a
  warning naming the one being ignored. Latent today -- the policy has 13
  directives and no duplicates -- but it is a 1.6 KB hand-edited line.

probe.js could not test a wildcard source at all: `https://*.example.com/...` is
not a hostname, so the request died before CSP saw it and the probe read clean
whether or not the deployment served the wildcard. It now substitutes a
concrete label, which is a real test -- CSP is evaluated before DNS, which is
why the csp-control.invalid controls get reported without resolving. This
matters as of the previous commit, which is what put wildcards in the policy.

nginx.conf: font-src was the only directive that never got the Product Fruits
hosts. It renders into our DOM rather than a cross-origin iframe, so @font-face
in its stylesheet resolves under our font-src and would have rendered tofu in
tours and checklists. Added, pending confirmation with check 3 with a tour open.

Also records in the skill that `sessionDetails.picture` is the same shape of
hole as the logo and favicon: an absolute URL the auth backend may supply, with
no IdP host in img-src. It does not reproduce today -- neither the OSS
AuthenticationService nor the cloud Auth0 plugin populates it, so the initials
branch always renders -- but the field is plumbed end to end, and if it is ever
filled the avatar is blocked and shows a broken-image glyph rather than falling
back to initials, because `picture` would be truthy.

Verified: the three parser cases (commented-out, Report-Only first, duplicate
directive) each now resolve the way the browser would; --dist scan of a fresh
build still clean.

* UN-2238 [FIX] Port, non-fetch directives, and the hangs the retry could cause

Third review pass, all in the scanner.

- URL_RE stopped at a `:port`, so `https://example.com:8443/x` scanned as bare
  `example.com` with no path. A CSP source with no port grants 443 only, so the
  browser blocks while the gate reported allowed -- and the swallowed path also
  defeated path-scoped checking. Port is now captured and matched, with a new
  PORT verdict.
- allowed_sources() walked every directive, so a host in form-action, base-uri
  or frame-ancestors read as fetchable from any directive. Nothing can be
  loaded on their strength; they are skipped, along with the reporting ones.
- urlopen had no timeout, and the 3-attempt retry added on top meant a
  deployment that accepts a connection and never answers would hang the gate
  forever. 20s.
- The (path, required) queue popped LIFO while deduping on a bare set, so when
  the same path arrived from index.html and from inside a bundle, whichever was
  popped first decided how a 404 was treated -- the provenance distinction
  could be lost for exactly the path it was written for. Required-ness now
  lives in a dict keyed by path, so the order cannot matter.
- The index fetch was outside any try, so a DNS or TLS failure there ended in a
  traceback rather than the script's own incomplete-scan message.

--dist now defaults to this repo's frontend/build, resolved from the script
rather than the shell. The relative path was only ever needed because there was
no default, and it is what made the vacuous-pass bug reachable in the first
place.

SKILL.md: dropped two sentences narrating states no shipped version has (that
the policy "was report-only until then", that a mistyped --dist "used to" pass)
and corrected the exit-code rule, which stopped being "any chunk that cannot be
fetched" when 404s on speculative paths became tolerable.

Verified: matcher cases for port (js.stripe.com:8443 -> PORT) and for the
navigation directives (checkout.stripe.com no longer in the allowed map) on top
of the existing path and wildcard cases; --dist scan clean at 113 files, --url
scan of us-central clean at 368.

* UN-2238 [MISC] Record why the US PostHog hosts are in IGNORED

They are posthog-js's built-in defaults, not documentation strings like the
rest of the list, and they are inert only because api_host is pinned to the EU
host in frontend/src/index.jsx. Move that pin and they become live hosts the
policy blocks, with IGNORED keeping the gate quiet about it -- which is the one
thing this list must never do silently.

unpkg.com was raised in the same pass as dead weight, on the grounds that
nothing in the OSS bundle references it. Left as is: the us-central scan finds
it in PdfViewer-*.js, so it is the LLMWhisperer PDF worker in the cloud plugin
and dropping it would block that viewer. The OSS bundle is a subset of what the
policy has to cover.
…ed ones (#2305)

UN-4185 Declare implicit frontend deps, drop unused ones, move test libs to devDependencies

Declare prop-types, lodash, clsx and @react-pdf-viewer/thumbnail, which were
imported but only resolved transitively. Remove zod, @hookform/resolvers,
js-yaml, cron-validator, http-proxy-middleware, @fontsource/geist-sans, jsdom
and react-syntax-highlighter, none of which OSS or cloud plugins import after
the paired cloud change. Move @testing-library/* to devDependencies.
… pg_barrier dispatch (#2308)

[MISC] Raise the rig Postgres lock limit and mock dispatch in pg_barrier enqueue tests

The integration tier fails intermittently for two unrelated reasons.

Backend: every xdist worker migrates its own test database in one
transaction, holding a lock per table and constraint. The rig's Postgres
runs with the default max_locks_per_transaction=64, so the shared lock
table overflows at random ("out of shared memory") and errors hundreds of
backend tests at setup. Start the container with 256.

Workers: five TestPgBarrierEnqueue tests never mocked
queue_backend.dispatch.dispatch. Since UN-4078 made the PG queue the only
transport, enqueue really dispatches the headers, opening a connection
from DB_* env (default host unstract-db) instead of the TEST_DB_* test
database. Wrap them in the same patch their neighbours use. Also drop a
duplicated _barrier_pg_decrement import.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…sly (#2299)

* UN-4123 [FIX] A credential-free REDIS_URL no longer connects anonymously

In URL mode the resolved password was never passed to the client: credentials
had to be embedded in the URL, and a {prefix}PASSWORD set beside a
credential-free URL was silently ignored. The client connected ANONYMOUSLY and
the endpoint answered NOAUTH on the first command, which reads as a broken
server rather than a dropped password.

This was not exercised by the live managed-Redis testing on UN-4123, because
both configurations tried there avoided it: the URL-mode run embedded the
password in the URL, and the discrete run had no URL at all. The gap sits in the
third combination, which is the one worth recommending.

WHY IT IS WORTH RECOMMENDING. A URL ends up in places a password should not:
the endpoint helper's error messages, ArgoCD's ComparisonError condition, and —
under ESO — the ExternalSecret's spec.target.template.data, which is not a
Secret and is not redacted. All three were raised in review of the chart-side
PR, and all three exist because the password is in a URL. Keeping it in its own
key closes the class rather than patching each place the URL is printed; this
change is what makes that configuration usable.

Credentials FILL A GAP the URL leaves and never override one.
ConnectionPool.from_url ends with kwargs.update(url_options), so an existing
rediss://:<password>@host URL keeps behaving exactly as before — verified for
both the :pw@ and user:pw@ spellings.

GATED ON THE PASSWORD, with the username riding along. A username alone is not a
credential, and values.yaml ships REDIS_USER: default: filling that in on its own
would make redis-py send AUTH to the in-cluster server, which has none, turning
a working default deployment into a failing one. I wrote that regression into
the first version of this change and caught it by testing the chart's own
default posture rather than by reading the diff.

Six cases added, covering both URL spellings, the gap-filling case, the username
pairing, the username-alone regression, and the legitimately anonymous server.
Mutation-verified: removing the fallback fails two, ungating the username fails
the third.

The three sample.env recipes now show the password as a separate key, and say
that an existing URL-embedded credential still works.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Carry the credential fallback to the other two Redis consumers

The first commit fixed create_redis_client and stopped there, which left the
recommendation it enables half true. unstract has THREE Redis consumers and a
credential-free URL reached only one of them:

  create_redis_client      backend, workers, runner, platform-service   fixed
  build_socketio_redis_url kombu / Socket.IO                            ANONYMOUS
  Django cache LOCATION    django-redis                                 ANONYMOUS

Both now fill the same gap, on the same terms: credentials are added only when
the URL carries none, and a URL bearing them still wins.

SOCKET.IO. kombu takes a URL and nothing else — no connection kwargs — so a
separately supplied password cannot reach it any other way; the credentials go
into the URL string itself. The password is then in that string, unavoidably.
That is acceptable here and not in a values file: this URL is built in-process
and handed straight to the client, rather than written into a manifest, a Secret
template or an error message, which is the distinction the whole change is
about. An anonymous publisher against an authenticated server does not fail
loudly — Socket.IO events simply stop arriving.

DJANGO CACHE. django-redis reads OPTIONS["PASSWORD"] and feeds it to
ConnectionPool.from_url, which ends with kwargs.update(url_options) — so setting
it is safe even when the URL has its own. It is still skipped when the URL
carries credentials, because passing them twice invites the two to disagree.

Five cases added. The settings harness now splices the urllib import from source
instead of hand-writing it: the hand-written copy had to be remembered the
moment settings started using another name from that module, and the symptom was
a NameError in every case rather than one clear failure. That is the second time
a hand-copied import in this harness has broken; the unstract.core import block
was the first.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Two credential-resolution findings from review

BOTH ARE IN MY OWN FALLBACK, and both are cases the fallback gets wrong rather
than cases it misses.

A URL CARRYING ONLY A USERNAME NO LONGER DROPS THE PASSWORD. The check keyed on
"@" being absent from the netloc, which is not the same question: redis://alice@host
carries an @ and no password. The fallback was skipped, the client got a username
with no password, and could not authenticate. Keyed on urlsplit().password being
None instead, which asks it directly. apply_url_credentials now uses the same
test and keeps a username the URL already carries.

CREDENTIALS RESOLVE AT THE URL'S OWN LEVEL, by the same rule the database
already follows. A prefix that brought its OWN url may point at a DIFFERENT
endpoint — an anonymous one, say — while the generic REDIS_PASSWORD is set for
the primary. Letting that password reach across made the prefixed client send
AUTH to a server with none, turning a working connection into a failing one. An
INHERITED url is the same endpoint as the generic one, so the full fallback
chain still applies there. For env_prefix "REDIS_" the two levels are the same
variable and nothing changes.

That the database rule and the credential rule now say the same sentence is the
point: "at the URL's own level" is one idea an operator can hold, rather than
two resolution orders that happen to differ.

Five cases added, covering the username-only URL, the prefix that must not
inherit, the prefix's own password, the inherited url that still should, and
the Socket.IO builder following the same rule.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] One credential helper for all three consumers, and the netloc bug in it

A four-agent standardised review of this PR found two Criticals, both in code I
added in the two commits before it. Every fix below is mutation-verified.

apply_url_credentials CORRUPTED A URL THAT CARRIED A USERNAME. parts.netloc
INCLUDES userinfo, and the function prepended to it — so redis://alice@host with
a separately supplied password became redis://alice:pw@alice@host. Userinfo
splits on the LAST "@", so kombu parsed the password as "pw@alice". Not a
missing credential, a WRONG one: the server answers WRONGPASS, the publisher
dies, and socketio.Server is built with logger=False so Socket.IO events simply
stop. The docstring claimed this function existed to prevent exactly that.

It now rebuilds the netloc from the host part, and reuses a username the URL
already carries VERBATIM rather than re-quoting it — quoting an already-encoded
username turned al%40ice into al%2540ice.

THE DJANGO CACHE KEYED ON "@" BEING ABSENT — the precise heuristic the core
comment rejects, 170 lines away, in the same PR. A username-only URL left the
cache anonymous while create_redis_client authenticated: one process, one
endpoint, two outcomes, which is the sentence this work exists to delete.

Rather than align the third hand-rolled gate, it is gone: the cache LOCATION now
goes through apply_url_credentials, the same helper the Socket.IO URL uses. That
also closes a gap neither gate could — django-redis 5.4.0 discards
OPTIONS["USERNAME"], so an ACL username could not reach this cache at all and it
authenticated as `default` while the other consumers used the configured user.
It reads REDIS_USER straight from os.environ rather than the module-level
fallback of "default", so the cache and the client send the same AUTH form.

EMPTY IS ABSENT. redis://:@host parses to password "", and both redis-py
(`if url.password:`) and kombu (`unquote(password or "") or None`) read that as
no password — so `is None` declined to fill a gap the libraries agree is a gap.
That shape is what Helm emits from redis://:{{ .Values.password }}@host with an
empty value. Truthiness in all three places now.

A DEAD GUARD REMOVED. `and not parts.username` could not fail: from_url ends
with kwargs.update(url_options), so a URL username wins regardless. An
unfalsifiable guard is where a later reader deletes something load-bearing.

TESTS 250 -> 257, and the existing ones were not strong enough. The socketio
credential test asserted a SUBSTRING, which passes on the corrupt URL — that is
why the Critical survived a test written for the same shape. Assertions are on
parsed fields now. Added: username-only URLs on every consumer, double-encoding,
a password containing @ / : (unencoded it reparses with host "ss" — a different
server), the empty-password shape, the {prefix}USERNAME spelling, and the cache
carrying an ACL username. Six mutations verified, including the netloc concat.

COMMENT CORRECTIONS. "kombu takes a URL and NOTHING else — no connection
kwargs" is false in both places it appeared: KombuManager forwards
connection_options to kombu.Connection, which takes userid= and password=. The
URL is where TLS must go; credentials ride along by choice, not necessity. And
"never written into an error message" was wrong — the backend stores the result
as settings.SOCKET_IO_MANAGER_URL, which Django's SafeExceptionReporterFilter
does not redact. Two past-tense comments describing a mid-branch state as
shipped history are now present tense.

The three sample.env recipes now show REDIS_USER= blank, because this change
makes that value load-bearing in URL mode for the first time while the files
still ship REDIS_USER=default a few lines above.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Round-2 review: one resolver for usernames, and blank means unset

Second standard-review round. Round 1's two Criticals held up under attack —
apply_url_credentials was fed 16 URL shapes round-tripped through urlsplit,
redis-py and kombu, and all three agree on every one. What round 2 found is a
layer down: the credential CHAIN, and tests that could not fail.

* base.py re-spelled the username chain instead of asking core for it, and
  dropped a leg. The resolver accepts REDIS_USER and REDIS_USERNAME;
  platform-service ships the second, so one shared Redis-credential secret can
  inject it — and the Django cache then authenticated as `default` while
  create_redis_client in the SAME process authenticated as the configured ACL
  user. Where `default` is disabled, the cache alone fails. That is verbatim
  the divergence the round-1 comment says the change exists to delete, so the
  chain now lives in one exported helper both sides call.

* BLANK MEANT SET for credentials, which broke this PR's own recipe.
  workers/sample.env ships `CACHE_REDIS_PASSWORD=` uncommented, and
  `os.getenv(prefixed, os.getenv(generic))` hands back that blank rather than
  consulting REDIS_PASSWORD. An operator following the documented managed-Redis
  recipe — REDIS_URL without credentials plus REDIS_PASSWORD — got url_password
  "" on every prefixed client, apply_url_credentials no-opped, and the worker
  caches connected ANONYMOUSLY. The module already documented blank-means-unset
  for parse_db and _parse_bool; host, port, password and user now follow it.

  This changes a test that asserted the old behaviour. It was a characterisation
  test that said so — "documents a trap rather than endorsing it… this test
  fails if that behaviour ever changes, so the chart-side guarantee can be
  revisited". This is that revisit: the guarantee it rested on (the chart never
  renders an empty credential) is not one sample.env keeps.

* PORT was the one var with no error contract — int() straight off os.getenv,
  so a blank REDIS_PORT= raised at client construction naming neither the
  variable nor the prefix. parse_port now mirrors parse_db.

TESTS — the round-1 lesson had not actually been applied. Four assertions still
compared substrings of a constructed URL, including one two lines below the
test I fixed in round 1, carrying a comment explaining the very bug it was
still vulnerable to. Each is replaced by parsed fields, and the password one by
an unquote() round-trip, since a hostname guard catches only the host half of a
doubled userinfo.

Two mutations survived round 2 and now fail:

* reversing url_user/username precedence — no test set a URL username AND
  REDIS_USER at once, which is the only environment that tells them apart, and
  values.yaml ships REDIS_USER: default so the pairing is ordinary.
* deleting `not parts.password` — harmless for the password (from_url ends with
  kwargs.update(url_options)) but load-bearing for the USERNAME rider, so it
  turned a one-argument AUTH into a two-argument one while the Socket.IO URL
  for the same config kept sending one. The comment above it argued the guard
  was unfalsifiable, which is true of the password half and false of the other
  — i.e. it documented the deletion the suite could not catch.

The _derive harness executes two source ranges with a ~415-line gap between
them, and anything in that gap is invisible to every assertion in the file:
adding `REDIS_PASSWORD = ""` at base.py:251 leaves all 44 tests green while
shipping a broken cache. It now asserts its own coverage — every Redis line in
base.py must fall inside a spliced range — and fails naming the line that
escaped. Verified against that exact insertion.

Also: test_the_shipped_default_user_alone_adds_nothing passed REDIS_PASSWORD=""
and so returned at the `if not password` guard before the username was read,
making the test named for that choice blind to it.

Core 120 -> 126 (263 in the dir), backend 42 -> 44. Six mutations verified
caught. The exposure note now names CACHES["default"]["LOCATION"] as the second
unredacted landing place for the password, and says why it is a deliberate
trade rather than an oversight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Round 3: finish the blank-means-unset convention, and stop stripping

Review of the round-2 commit. Two of the three findings are defects that commit
introduced, and one of them made the change actively worse than before it.

* BLANK-MEANS-UNSET WAS APPLIED TO CREDENTIALS BUT NOT TO TLS. Round 2 made a
  blank {prefix}PASSWORD/USER/HOST fall through to the generic level; the four
  TLS vars kept shadowing. So `CACHE_REDIS_SSL=` beside `REDIS_SSL=true` now
  gave the worker cache a real AUTH over an UNENCRYPTED socket — the credential
  round 2 taught it to inherit, on the wire in clear. Before round 2 the
  password was suppressed too and the client simply failed to authenticate.
  Half a convention was worse than none. All four TLS vars go through env_chain
  now, and the comment claiming the rule held "throughout this module" is true
  again rather than aspirational.

* env_chain STRIPPED what it returned, and backend/settings/base.py does not.
  A password or username from a file-mounted secret ends in a newline, so core
  truncated it while the settings module kept it, and the Socket.IO URL and the
  cache LOCATION — built ten lines apart — carried different secrets. Emptiness
  is now tested on the stripped value and the RAW value returned, so the two
  agree again. That is the property round 2 claimed to establish.

* url_username_from_env, the helper round 2 added specifically to unify this
  chain, was itself the one place not honouring the rule: plain `or` with no
  blank handling, so ` alice ` and `appuser\n` resolved differently from every
  other read of the same variable. It calls env_chain now.

Also:

* base.py still did a bare int() on REDIS_PORT, so a blank REDIS_PORT= raised
  during Django settings import — the backend alone failing to start while
  every worker came up fine on 6379. parse_port was exported in round 2 and
  simply not wired up here.

* parse_port and parse_db named the PREFIXED variable in their diagnostics even
  when the bad value came from the generic one, sending an operator to grep for
  a key that is not set. env_chain_named returns the winning name, and the port
  fallback is logged at error rather than warning: the fallback is a PORT, so on
  a host serving both 6379 and 6380 a typo lands the client on the other server
  rather than failing.

TestContainerAllowlists caught something real and deserves the credit: it
derives the container env allowlist by scanning `os.getenv` calls in the source,
so rewriting those reads to go through env_chain made the whole TLS set vanish
from the guard silently. That is the same drift class as the two test harnesses
fixed in round 2. The derivation now scans the arguments of every env-reading
call, still scoped to those call names so a variable merely named in a docstring
is not demanded of every container.

Core 126 -> 137 (274 in the dir). Five mutations verified caught, including both
blank-TLS shadows and the strip asymmetry.

NOT fixed here, recorded deliberately: unstract-cloud's values.yaml pins
CACHE_REDIS_USERNAME: "" with a comment stating the shadowing is intentional and
"must not be fixed". That reasoning is now void — the pin is inert rather than
load-bearing. The wire change is benign (Redis 6+ treats `AUTH default <pw>` and
`AUTH <pw>` alike, and redis-py retries one-arg anyway), but the comment belongs
in the cloud PR, not this one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Round 4: the SSL flag parses the same in both consumers

Round 3 broadened REDIS_SSL parsing in core and left backend/settings/base.py on
its own `== "true"`. _TRUE_LITERALS accepts 1, yes and on, so the two halves of
one process disagreed about whether TLS was on:

  REDIS_SSL=1  ->  every create_redis_client consumer connects rediss://
                   the Django cache builds a redis:// LOCATION
                   and `if REDIS_SSL` never runs, so CONNECTION_POOL_KWARGS
                   carries no cert_reqs, no check_hostname, no CA either

Against a TLS-only managed endpoint the backend cache alone fails; against one
accepting both it authenticates in clear while everything else is encrypted. In
the same process SOCKET_IO_MANAGER_URL — built by core — came back rediss://.
That is verbatim the "one endpoint, one process, two policies" failure this
module was centralised to prevent, reintroduced by centralising only one side.
resolve_ssl_enabled is exported and base.py calls it.

Also:

* The fifth read of SSL_CHECK_HOSTNAME was missed by round 3's conversion. It
  decides whether the value was EXPLICIT, so a blank prefixed value fell
  through for the value but not for the explicitness — on Sentinel + TLS a
  global REDIS_SSL_CHECK_HOSTNAME=true was honoured on the discovery
  connections and silently dropped on the master one.

* parse_port treated "" as unparseable and logged at ERROR. base.py hands it
  the raw env value rather than env_chain's None, so a bare REDIS_PORT= — the
  repo's own "leave the default" spelling, and the wording of the comment round
  3 added right above it — produced a startup error line in the backend and
  nowhere else.

* host is stripped explicitly. env_chain returns the raw value so a credential
  keeps whatever whitespace it was given, but urlsplit() drops a trailing
  newline from a URL host while redis.Redis(host=...) keeps it, so a
  file-mounted REDIS_HOST would fail DNS on the discrete path and succeed on
  the URL one.

Core 137 -> 139 (276 in the dir), backend 44 -> 57. The new backend class
parametrises every true and false literal across both consumers, which is what
pins them together; nothing previously exercised any literal but "true".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* [pre-commit.ci] auto fixes from pre-commit.com hooks

for more information, see https://pre-commit.ci

* UN-4123 [FIX] Pin the property, not the bug: a consumer-parity sweep

Four review rounds produced the same finding four times in different clothes —
a setting centralised in unstract.core with the parallel read in base.py left
behind. The "@" credential heuristic, the REDIS_USERNAME spelling,
blank-means-unset, and the SSL true-literals were each fixed with a test for
that one setting, and each time the next round found the next one.

So this pins the property instead. TestTheBackendAgreesWithCoreOnEverySharedSetting
runs 19 environments an operator would actually write and asserts that where the
Django cache ends up — host, port, db, username, password, TLS — is where
create_redis_client in the same process ends up. A setting centralised on one
side only now fails without anyone having to predict which setting it will be.

It found a real divergence on its first run, and not one of the four above: the
DISCRETE path cannot carry an ACL username. django-redis discards
OPTIONS["USERNAME"], so with REDIS_HOST + REDIS_USER + REDIS_PASSWORD the cache
authenticated as the built-in `default` while every other client in the process
authenticated as the configured user — and where `default` is disabled, the
cache alone failed. That is the identical bug the URL branch was fixed for,
left standing on the path the module docstring calls primary.

The comment beside it said this was deliberate ("auth stays password-only as the
built-in `default` user ... this cache never sends one"), and a test pinned it as
a "stated invariant" so a django-redis bump would be visible. Passing a key the
library throws away is not an invariant worth pinning; reaching the same server
as everything else is. Both branches now build the LOCATION through
apply_url_credentials.

The password still goes to OPTIONS["PASSWORD"] when no username is in play:
that key matches Django's SafeExceptionReporterFilter and is masked in a
settings dump, while LOCATION is not — so the extra exposure is paid only where
django-redis leaves no alternative.

Backend 57 -> 77. Three mutations verified caught by the sweep, including two it
was not specifically written for.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Round 5: the sweep asks django-redis instead of imitating it

Round 5 found no Critical and no High — the recurring "centralised on one side
only" class has stopped on the path the parity sweep covers, confirmed against
an exhaustive 2160-configuration matrix. These are the Mediums it did raise.

* The sweep reimplemented django-redis's precedence. It parsed the LOCATION
  with urlsplit and hardcoded "userinfo wins, OPTIONS['PASSWORD'] backfills".
  That is correct for 5.4.0 — but it is a second copy of library behaviour
  inside the one test whose stated job is to survive changes it cannot predict,
  so a bump that reversed the precedence would leave it green while the cache
  diverged. It now builds the dict from a real ConnectionFactory, so the rule is
  never restated. (Its pools are memoised by LOCATION, so they are cleared per
  case; leaving that out makes three cases answer with an earlier case's pool,
  verified.)

* An ACL username with NO password reached neither side usefully and reached
  them differently: apply_url_credentials returns the URL untouched when there
  is no password, so the cache went anonymous, while core set username=alice and
  redis-py packed `AUTH alice None` and raised DataError on the FIRST command —
  a type error from inside the library, nowhere near the values file that caused
  it. Redis has no one-argument ACL AUTH, so this is not a configuration that
  exists; it is now reported at startup and the username dropped, which also
  makes the two sides agree. The sweep's 19 cases paired a username with a
  password every time, so it could not see this; two cases added.

* The db relocation warning gave the discrete-mode explanation on both paths —
  it fires in URL mode too, where the previous db came from the URL's own path
  and OPTIONS['DB'] was never the mechanism. The db numbers were right in every
  case; only the stated reason was wrong. Reason dropped, numbers kept.

The Sentinel branch is now marked as OUTSIDE the sweep, with the four
divergences from create_redis_client that live there spelled out — the
"default" username default, the ignored REDIS_USERNAME spelling, no TLS at all,
and a different default port. All four are pre-existing on main and this PR only
replaced an int() there; changing behaviour in a branch no test in this file
executes is its own ticket, not this one.

Backend 77 -> 79. Two mutations verified caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Extend the cache/client parity to Sentinel mode

The parity sweep stopped at the Sentinel branch, and I recorded the four
divergences living there as "its own ticket". That was the wrong call twice
over: a guarantee that stops at a mode boundary is a half-truth, and the reason
I gave for deferring — "changing code no test in this file executes" — was not
true. _derive's slice spans BOTH branches; it just needed an env selecting this
one. "No test executes it" and "no test was written for it" are different
statements, and only the second was ever the case.

Four divergences from create_redis_client, all pre-existing on main:

* THE SENTINEL PORT. The module-level REDIS_PORT defaults to 6379 — the
  STANDALONE port — while core's Sentinel path resolves with
  default_port="26379". With REDIS_PORT unset this cache looked for sentinels
  on the wrong port while every other client found them. The chart always sets
  REDIS_PORT, which is why it stayed hidden.

* THE USERNAME, twice. REDIS_USER defaults to "default" at module scope, so
  this cache sent a two-argument ACL AUTH where core sends the one-argument
  form; and it read REDIS_USER directly, so the REDIS_USERNAME spelling
  platform-service ships was ignored. Both now come from the shared resolver,
  and the same value reaches SENTINEL_KWARGS, the LOCATION and the Socket.IO
  URL.

* TLS REACHED NEITHER CONNECTION. A TLS-only Sentinel deployment got a
  plaintext cache while core encrypted both the discovery and the master
  connections. Carrying it in SENTINEL_KWARGS alone would encrypt discovery and
  leave the master in clear, so it goes to CONNECTION_POOL_KWARGS too — core
  builds both from one env dict for exactly this reason.

BEHAVIOUR CHANGES for existing Sentinel deployments, stated rather than buried:
an unset REDIS_PORT now resolves 26379 instead of 6379; no username is sent
unless one is configured (Redis 6+ treats `AUTH default <pw>` and `AUTH <pw>`
as the same command, and redis-py retries one-arg regardless, so this is a wire
change rather than an auth change); and REDIS_SSL now applies here. Flag off is
byte-identical — asserted.

Backend 79 -> 87. Four mutations verified caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Sentinel's two TLS planes verify by different rules

Valid review finding, and a parity bug in the commit that was meant to
establish parity: 9019ac1 built one TLS dict and copied it into both
SENTINEL_KWARGS and CONNECTION_POOL_KWARGS.

The two planes are not the same connection. Discovery reaches REDIS_HOST, a
name whose certificate can match, so it verifies like any other client. The
MASTER reaches whatever `SENTINEL get-master-addr-by-name` returns, and
SentinelManagedConnection assigns that address straight to self.host, which
SSLConnection passes as server_hostname — an IP that no DNS SAN covers, and one
that changes on failover, so pinning an IP SAN is not an answer either. core
turns checking off there unless the operator asked explicitly (_tls_kwargs's
sentinel_master branch). This cache did not, so it failed certificate
verification against the very master create_redis_client connects to.

The second half of the finding is also real: with REDIS_SSL_CERT_REQS=none the
copied default of True produced CERT_NONE plus hostname checking, and the
pinned redis-py 5.2.1 does NOT coerce that pair — it assigns check_hostname
verbatim, so it reaches Python's ssl module, which rejects it. Verified against
the installed version rather than assumed; 6.x does coerce, which is why the
sibling repo's fix looks different. The standalone branch already guarded this
three hundred lines below; the Sentinel branch did not.

The master-plane rule is exported from core as
resolve_sentinel_master_check_hostname rather than re-derived here — the same
reasoning as every other shared setting in this PR, and the reason it is a new
helper instead of a flag on resolve_ssl_check_hostname is that core's own call
site applies the cert_reqs rule immediately afterwards and would double-apply
it.

Backend 87 -> 92. Two mutations verified caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
…or (#2315)

* UN-4123 [FIX] The default Redis user without a password is not an error

6c0a69d added an ERROR when a username is set without a password, to replace
redis-py's opaque "DataError: Invalid input of type: 'NoneType'" on the first
command with something that names the values file. The diagnostic is right for a
named ACL user. It is wrong for "default".

"default" is Redis's own built-in user and `AUTH default <pw>` is equivalent to
`AUTH <pw>`, so "default with no password" is not a misconfiguration -- it is the
absence of ACL usage, which is the normal state for the in-cluster Redis that has
no password at all. values.yaml and sample.on-prem.values.yaml both ship
REDIS_USER: default, so the error fired on every default on-prem install.

Measured on two staging namespaces before this fix: ~274 lines in 20 minutes in
one and 400+ in the other, across eight pod types -- roughly 20k ERROR lines a
day per namespace. Nothing was broken; the username SHOULD be ignored and was.
The cost is log volume and real errors buried under it, which is what the
diagnostic was meant to prevent.

The username is still dropped either way, so behaviour is unchanged. Only the
logging is conditional, and a NON-default username without a password still
reports -- that is the case redis-py turns into the opaque DataError.

Found by deploying rc.423 to a vanilla in-cluster on-prem namespace. Both managed
configurations set REDIS_USER: "" explicitly, so neither could surface it; only
the shipped default does, which is what most on-prem installs run.

Mutation-checked: removing the exemption fails the three silence cases and
nothing else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-4123 [FIX] Address review: the default-user exemption is case-sensitive

Redis ACL usernames are case-sensitive, so `DEFAULT` is a named user distinct
from the built-in `default`. Lowercasing the comparison suppressed the
missing-password diagnostic for it -- a real mistake made harder to diagnose,
which is the opposite of the point. Compare exactly.

The .strip() stays. env_chain returns the RAW value deliberately (stripping it
there truncated a password once, per the comment at its definition), so a
hand-edited values file can carry padding around a username that was meant to be
the built-in one. Whitespace is an editing artifact; case is semantic.

test_a_case_distinct_default_still_errors covers it, and "DEFAULT" is out of the
silence parametrize. Mutation-checked: restoring .lower() fails that test alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…of losing the whole worker pod (#2311)

* UN-4223 Kill and re-fork only the hung child in the PG consumer supervisor

One child whose task hangs stops publishing heartbeats, which made the fleet's
/health 503 and a liveness kill drain every sibling for up to the grace period.
The supervisor now SIGKILLs just that child once it is silent past
HEALTH_STALE_SECONDS, so its lease lapses, the message redelivers, and the slot
is re-forked. Off when HEALTH_STALE_SECONDS is unset; CHILD_WATCHDOG=false
disables it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4223 Make the child watchdog opt-in and close the gaps the review found

- CHILD_WATCHDOG now defaults off: docker-compose has no healthcheck, so
  HEALTH_STALE was never enforced there and may sit below a long batch. The
  chart enables it where a liveness probe already killed at that threshold.
- poll_once stamps the heartbeat before each queue read, so a multi-queue
  consumer's age covers one task, which is what HEALTH_STALE is sized for.
- A child that never finishes loading is judged by time since fork.
- The child publishes its heartbeat before marking itself loaded.
- pg_consumer_child_watchdog_kills_total on /metrics.
- The loop iteration is _monitor_tick, now under test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4223 Correct two comments that still describe a per-cycle heartbeat stamp

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4223 Give a slow bootstrap its own floor and count only real watchdog kills

Review comments on #2311:
- An unloaded child is judged against max(HEALTH_STALE, 600s): HEALTH_STALE is
  sized for task runtime and can be 180s, while an import under a CPU cap can
  take minutes.
- A child that exits before the SIGKILL lands no longer counts as a kill.
- Split a composite test assertion (Sonar S9073).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…enAI and Azure OpenAI (#2310)

* UN-4224 [FIX] Stop sending temperature to GPT-6 models on Bedrock, OpenAI and Azure OpenAI

GPT-6 models (luna, sol, astra, 6.1-sol) reject the `temperature`
parameter. Our LLM adapters always pass one (the pydantic default, or 1
when reasoning is on), and LiteLLM forwards it on the Bedrock Mantle
route, so every GPT-6 Luna call on Mantle failed with "temperature not
permitted for this model". GPT-5.6 was never affected because LiteLLM
has a GPT-5 rule that drops a non-1 temperature; that rule matches
`gpt-5` names only. The Converse route (`us.`/`global.` profile ids)
already drops it.

- Add a `gpt-6` stem to `_SAMPLING_DEPRECATED_MODEL_STEMS`, the strip
  already used for Claude Opus 4.7+. The Bedrock, Azure AI Foundry,
  Vertex and Anthropic adapters call it on their return path. The
  trailing-edge anchor keeps `gpt-5.6-*` (normalised to `gpt-5-6-*`),
  `gpt-60` and `gpt-oss-*` out.
- Call the strip from the native OpenAI adapter too.
- Azure OpenAI: the deployment name need not name the model, so detect
  GPT-6 from the optional Model field as well. `LLM` sets `cost_model`
  aside and re-validates without it, where Azure's default temperature
  of 1 would return, so pin `temperature` to None instead of popping it
  (LiteLLM omits a None temperature) and stop reasoning from forcing 1.

LiteLLM is intentionally not upgraded here: the first release whose
bundled registry carries the GPT-6 Mantle entries (1.104.0) needs
boto3>=1.43.1, which conflicts with the boto3 1.34 / s3fs 2024.x pins.
See UN-4224 for the routing gap that leaves on air-gapped deployments.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Keep the Azure GPT-6 strip when callers pass temperature per call

Review (Greptile P1): for an Azure OpenAI deployment whose name hides
GPT-6, the only record of the model that survived `LLM`'s re-validation
was the pinned `temperature: None`. `complete()` merges per-call kwargs
over the stored ones, so a caller passing `temperature=` (the cloud
agentic_table / agentic_extraction workers do) overwrote that marker and
the temperature reached a model that rejects it.

Detect from the real model id instead of a caller-writable marker.
`LLM._revalidate` now feeds the `cost_model` it set aside back into
re-validation, after the per-call kwargs so they cannot displace it, and
the four completion paths use it. Azure prefers that `cost_model` as the
original model id, so detection holds on every pass and the sampling
params are simply popped like every other adapter -- the `None` pin and
its sticky-marker check are gone.

Other adapters are unaffected: none declares `cost_model`, Pydantic
ignores unknown keys, and every call site already pops `cost_model`
before LiteLLM sees the kwargs.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Upgrade LiteLLM to 1.104.0 and the boto3 / fsspec storage stack

LiteLLM 1.104.0 is the first release whose bundled model registry carries
the GPT-6 Bedrock Mantle entries, so air-gapped deployments (which fall
back to the bundled registry) now route `openai.gpt-6-luna` to Mantle and
price GPT-6 calls instead of recording $0.

LiteLLM >= 1.100 hard-requires boto3 >= 1.43.1, which drags the storage
stack with it; these move together:

- litellm 1.96.2 -> 1.104.0
- boto3 / botocore 1.34.x -> 1.43.106, pinned exactly in backend, workers
  and sdk1: it must stay inside the botocore range aiobotocore 3.9.2
  accepts (1.43.101-1.43.106)
- s3fs / fsspec 2024.10.0 -> 2026.9.0 (s3fs drops its `[boto3]` extra)
- gcsfs 2024.10.0 -> 2026.10.0, adlfs 2024.7 -> 2026.8.0
- google-cloud-storage 2.9.0 -> 3.16.0: every gcsfs compatible with
  fsspec 2026.x needs google-cloud-storage 3.x. Our direct use (Client,
  bucket, get_blob, md5_hash, upload_from_*) is unchanged in 3.x.

Follow-on fixes:
- workers: bound `requires-python` to <3.13 like every other project. The
  open bound made uv resolve Python 3.14, where google-api-core >= 2.27
  needs protobuf >= 6.31 but the connectors' secret-manager / bigquery
  pins cap it below 5. The image runs Python 3.12.
- MinioFS: drop the UN-3487 `walk()` override. fsspec 2026.x's
  `DirFileSystem.walk()` relpaths each entry's `name` itself, so the
  override relpathed twice and tripped fsspec's assertion. The UN-3487
  regression test now guards the upstream behaviour.
- cohere embed timeout patch: re-pointed to 1.104.0 after re-diffing
  upstream; the sync `embedding()` still builds an untimed HTTPHandler.

Known gap, deliberately not fixed here: boto3 >= 1.36 stops sending
Content-MD5 on DeleteObjects, which MinIO older than RELEASE.2025-01-20
rejects. Verified live against the enterprise chart's
bitnami-minio:2024.12.18: single-file deletes (recursive=False, raw
fsspec) fail with MissingContentMD5, directory cleanup survives through
the UN-3421 fallback. MinIO 2026-09-22 (OSS compose) is unaffected.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Re-add Content-MD5 on S3 DeleteObjects for MinIO older than 2025-01-20

botocore 1.36+ sends a CRC32 flexible checksum instead of Content-MD5 on
DeleteObjects. MinIO older than RELEASE.2025-01-20 -- including the
enterprise chart's bitnami-minio:2024.12.18 -- rejects that with
MissingContentMD5, and s3fs routes every `rm` through DeleteObjects, so
with the boto3 1.43 upgrade single-file deletes (recursive=False, raw
fsspec) and connector deletes failed on those servers. The UN-3421
fallback only covered recursive deletes through sdk1 FileStorage.

Register a `before-call.s3.DeleteObjects` handler that adds Content-MD5
over the final (XML-escaped) body. It is appended to botocore's
BUILTIN_HANDLERS, so every session created afterwards gets it -- boto3,
botocore and the aiobotocore sessions s3fs creates. AWS S3 and new MinIO
accept both headers; S3 Express (which rejects MD5) is skipped.

`AWS_REQUEST_CHECKSUM_CALCULATION=when_required` does not help (the
operation requires a checksum), and botocore's deprecated
`conditionally_calculate_md5` skips requests that carry a flexible
checksum, so neither could be reused.

unstract-connectors does not depend on sdk1, so each carries an
identical, idempotent copy: sdk1 loads it from the file-storage helper,
connectors from the MinIO connector. Whichever loads first registers.

Verified live on the new stack against bitnami-minio 2024.12.18 and
MinIO 2026-09-22: FileStorage.rm(recursive=False), raw fsspec single and
bulk rm, and MinioFS rm (file and recursive) all succeed with no
fallback warnings.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Keep one copy of the DeleteObjects Content-MD5 hook

SonarCloud failed the quality gate on duplicated new code (39.2% vs 3%):
connectors carried a verbatim copy of sdk1's hook and its tests, on the
assumption that connectors does not depend on sdk1.

It does at runtime: `unstract_file_system.py`, the base class every
filesystem connector extends, imports `unstract.filesystem`, which
depends on sdk1. So the MinIO connector now imports
`unstract.sdk1.patches.s3_delete_objects_md5` directly, and the copy and
its duplicate tests are gone. Importing the connector still registers the
handler (verified), and connector deletes against MinIO 2024-12-18 still
succeed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Address review: keep the storage wire format stable, cover GPT-6 gateways

Review findings from the PR review kit (Deepak), all reproduced first.

Storage (sdk1 `patches/storage_compat.py`, renamed from
`s3_delete_objects_md5.py`):
- [major] botocore >= 1.36 defaults `request_checksum_calculation` to
  `when_supported`, so over HTTPS every PutObject / UploadPart went out as
  `Content-Encoding: aws-chunked` with a CRC32 trailer -- including to
  Unstract Cloud Storage (GCS's S3 interop API) and TLS MinIO. Captured on
  the wire for boto3 put_object / upload_fileobj and s3fs pipe_file.
  `S3_CHECKSUM_CONFIG` (`when_required` for requests and responses) restores
  boto3 1.34's plain payload-signed requests and is now passed explicitly to
  every S3 client we create: sdk1 FileStorage (explicit config_kwargs still
  win), MinioFS (and so UCS), and the two boto3 clients in
  `backend/connector_v2/unstract_account.py`. DeleteObjects still requires a
  checksum, so the Content-MD5 hook stays.
- [minor] gcsfs 2026.x swaps `GCSFileSystem` (and fsspec's `gcs`) for the
  experimental gRPC-backed `ExtendedGcsFileSystem` unless
  GCSFS_EXPERIMENTAL_ZB_HNS_SUPPORT=false. Defaulted (operators can still
  opt in) before gcsfs is imported; the GCS connector loads the module too.

LLM:
- [major] The OpenAI-compatible adapter never stripped GPT-6 sampling
  params. `gpt-6` joins the reasoning-model detector (which also now accepts
  `.` so `gpt-6.1-sol` and `gpt-5.6-*` match), and validate() returns
  through `_strip_deprecated_sampling_params` for unrecognised aliases.

Tests:
- [minor] Azure GPT-6 is now tested through a real LLM on all four paths
  (complete, complete_vision, stream_complete, acomplete) with LiteLLM
  captured, replacing the helper that hand-copied LLM.__init__. Both
  mutations from the review (call sites bypassing `_revalidate`; per-call
  kwargs applied after `cost_model`) now fail.
- [minor] Fresh-interpreter wiring tests: importing only the sdk1 storage
  helper, or only the MinIO / GCS connector, applies the settings. Wire
  tests through FileStorage -> s3fs -> aiobotocore on an HTTPS endpoint pin
  plain uploads and Content-MD5 on DeleteObjects.

Verified live against MinIO 2024-12-18 (chart) and 2026-09-22 (OSS): sdk1
and connector writes, reads and every delete path succeed.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Install s3fs and gcsfs in the sdk1 test group

CI's unit-sdk1 group syncs only the `test` dependency group (no extras),
so `tests/test_storage_compat.py` failed to import aiobotocore. Its wire
tests drive FileStorage -> s3fs -> aiobotocore, and its wiring tests check
gcsfs's class selection; at runtime both come from the `aws` / `gcs`
extras. Add them to the test group at the extras' versions, as was done
for boto3.

Reproduced CI's environment locally (`uv sync --group test`, no extras):
760 passed, 2 deselected (slow/integration). Lockfiles relocked; the
diff is the dependency-group metadata only, no package version changes.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Co-authored-by: ali <117142933+muhammad-ali-e@users.noreply.github.com>
* UN-4224 [FIX] Fix GCS connector test connection on gcsfs 2026.x

Since #2310 moved gcsfs from 2024.10 to 2026.10, test connection fails
for every Google Cloud Storage connector with
`HttpError: Required parameter: project, 400`. This is the rc.425
regression behind the staging "GCS ETL Pipeline" UI test (Submit stays
disabled because test connection fails).

`test_credentials()` called `info("/")`. gcsfs 2024.10 turned that into
`GET b?project=<project>` (list buckets); gcsfs 2026.10 sends `GET b/`
with no project, even when one is configured, and GCS rejects it. Use
`ls("")`, which on 2026.10 sends exactly the old `GET b?project=...`
request -- same permission, same behaviour. It is the only root-level
`info` call on gcsfs in either repo.

Verified against real GCS (project unstract-staging) with gcsfs 2026.10:
the connector's test_credentials() now returns True; the rc.425 code
fails with the same 400 seen in staging. A regression test records the
gcsfs request and asserts a bucket listing that carries the project; it
fails if `info("/")` comes back.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

* UN-4224 [FIX] Make the GCS connector test independent of import order

Review (Greptile): the test imported `gcsfs.GCSFileSystem`, which resolves
to the experimental ExtendedGcsFileSystem when gcsfs is imported before
storage_compat sets GCSFS_EXPERIMENTAL_ZB_HNS_SUPPORT=false -- e.g. when
this file runs alone -- so it could exercise a different client from
production. Import `gcsfs.core.GCSFileSystem`, the class production runs,
and pin it with a test. Passes on its own with the env var unset and even
with the experimental class forced on.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…res legible (#2322)

The cap default was a placeholder (20) that became the effective limit in
every environment because it was never configured. Hari's call on the
standup: 300, with clear errors as the companion work.

- DEFAULT_PAGE_CAP 20 -> 300 (the agreed product limit for single-call
  image answering).
- DEFAULT_MAX_TOTAL_BYTES 50MB -> 14MB. 50MB raw is ~67MB base64, larger
  than EVERY provider request limit (Bedrock/Gemini-inline 20MB, Google
  Cloud 30MB, Anthropic 32MB), so the provider rejected the request before
  our own budget check could fire. 14MB raw fits the strictest.
- Reject an over-cap PDF BEFORE submitting to pdf-to-images, which bills
  per converted page. Previously the cap was only checked at answer time,
  so a 164-page document was converted and billed in full before being
  refused (reported by Johny).
- Reword both errors: name the platform knob and the fact that image mode
  cannot window, instead of pointing at the adapter's 'pages to extract'
  setting — which is what sent a tester hunting through a field they had
  never set.
- configured_page_cap() in sdk1 is the single source of truth for the cap,
  read by both the extraction pre-check and the cloud consumer plugin, so
  the two checks cannot disagree.
- New IMAGE_REQUEST_TOO_LARGE bridge code for the band the cap cannot
  cover: a model's own per-request limit can be lower than 300 (e.g. 100
  images on a 200K-context model).

Tests: sdk1 772 pass (incl. 8 new: env accessor, reworded messages, and
the pre-submit cap with its billed-call assertion), bridge 27 pass.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
praveen-formido and others added 15 commits October 7, 2026 15:12
…e budget (#2323)

* UN-2646 [FIX] Set the image-mode page cap to 90 so it matches the byte budget

300 (#2322) turned out to be unreachable: the 14MB byte budget holds only
~38-89 pages at the 165-379KB per page measured from LLMWhisperer's 150 DPI
renders. Worse, it defeated the extraction-time pre-check, which only knows
the page COUNT — a 200-page document passed the 300 cap, was converted and
billed per page, then failed at answer time on the byte budget. Billed for
a document that could never be answered.

90, agreed with Hari, is the round number at the top of what the budget
delivers, so a document over it is refused before any conversion is billed.
It also stays under the strictest provider image count (100 per request on
Anthropic's 200K-context models) and context window (~135K tokens).

TestCapMatchesBudget pins the relationship: at the lightest measured page
the budget strictly holds 89 pages, 90 accepts a one-page gap, and the test
fails if the two drift further apart.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FIX] Stop the cap-vs-budget test claiming precision it cannot back up

Greptile P2: the sample page size came from the pdf-to-images ARCHIVE size,
while load_page_images counts extracted PNG bytes — a different
measurement. Asserting the budget holds exactly 89 pages, and calling 90 a
one-page overshoot, implied precision the data doesn't have.

The archive is a reasonable proxy (PNG data is already deflate-compressed
and per-entry headers are ~100 bytes, so it should sit within a few percent
of the image bytes), but that error is a couple of pages either side at
this cap. So:

- drop the exact 'capacity == 89' assertion;
- name the sample as approximate and say where it comes from;
- keep the drift guard, with explicit slack for the measurement error. It
  still catches the drift that matters — the cap raised back toward 300,
  or the budget cut without lowering the cap.

The source comment is corrected the same way. Proper calibration is
measuring extracted bytes on real documents.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [DOCS] Make sdk1 the single source for the image-mode limits; note LLMWhisperer coupling

Review feedback (Johny) on the cloud companion PR:

1. Keep the limit in a single place so it isn't an operational burden.
   The page cap and byte budget were duplicated between these sdk1
   defaults and the Helm chart. That isn't only upkeep: the two values
   are a coupled pair, and TestCapMatchesBudget can only check them when
   both live in this file — a chart override bypasses it, so a chart
   could raise the cap to 300 while the budget still holds ~90 pages,
   reopening the billed-then-rejected window. This file is now the stated
   single source of truth; the env vars remain as emergency overrides
   only, and the chart values are removed in the cloud PR.

2. Note that raising the limit needs LLMWhisperer work too. Recorded next
   to the constant: /pdf-to-images hardcodes a 999-page maximum (tied to
   page_001..page_999 naming) with no override, and bills per converted
   page — so any increase must be coordinated with LLMWhisperer, and the
   cap and budget raised together (or windowing added), never the cap
   alone.

Comment-only change; behaviour is unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [DOCS] Scope the LLMWhisperer warning to increases above 999 pages

Same correction Greptile raised on the cloud README: /pdf-to-images
accepts up to 999 pages, so a service change is only needed above that.
Narrowed the comment, keeping the guidance that applies to every
increase — raise the byte budget and cap together, since conversion is
billed per page.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…indexing); fix re-extraction of names with [ ] (#2324)

* UN-2646 [FIX] Image mode: fail oversized documents at indexing; fix re-extraction of names with [ ]

Two image-mode extraction fixes reported from staging.

1. Byte budget enforced at extraction, not only at prompt time (Gopal).
   A 90-page document indexed successfully, then failed only when a prompt
   ran ('more than 14 MB'), after the user had built prompts around it.
   verify_page_bytes() now checks the converted page set against the
   budget right after download, before anything is persisted, so the
   document fails at indexing. The conversion itself is still billed —
   image sizes are unknowable until pages are rendered — and the error
   says so. The answer-time check stays as the backstop, and both read
   the new configured_max_total_bytes(), so they agree by construction.

2. Re-extraction failed on GCS for documents named with [ ] * ? (Vikash).
   'Failed to clear previous page images ... provider=gcs' on a document
   named '... [CDIC] ...'. The page directory is named after the document,
   and fs.rm(dir, recursive=True) hands it to fsspec, which expands it as a
   glob on gcsfs/s3fs: '[CDIC]' became a character class, matched nothing,
   and raised FileNotFoundError. LocalFileSystem uses a plain rmtree and
   never globs, which is why this was invisible locally. The best-effort
   cleanup after a failed write had the same flaw and only logged it, so it
   silently left partial pages behind.

   New FileStorage.rm_exact(): find() + rm_file() per object, then
   directories deepest-first. Verified against gcsfs 2026.10.0 and s3fs
   2026.9.0 source that rm_file is a single delete-by-exact-key on both.
   Both page-store delete sites use it.

Tests (sdk1 791 pass, 15 new):
- ObjectStoreLikeMemoryFS models gcsfs/s3fs delete semantics exactly
  (globbing rm, exact-key rm_file, detail=False ls); plain
  MemoryFileSystem inherits a globbing rm_file and the wrong ls default,
  so it would model the wrong backend.
- Re-extraction into a '[CDIC]' directory, partial-write cleanup, and an
  extraction-to-answer round trip through the real loader. The first two
  FAIL on the previous code, with exactly the production error.
- rm_exact on LOCAL and object-store semantics, for [ ] * ? and plain
  names, nested trees, and a single file (a literal match only, never the
  glob's matches).
- Existing reset-invariant tests now fault-inject rm_exact, the method
  production actually calls.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FIX] Address review: loader reads configured limits; rm_exact surfaces real rmdir errors

- load_page_images now defaults page_cap and max_total_bytes to the
  configured values (env override or default) instead of fixed constants,
  so the answer-time check always agrees with the extraction-time checks.
  Explicit values and None still win.
- rm_exact tolerates only FileNotFoundError from rmdir (a prefix that
  vanished with its last object). Permission and not-empty errors now
  reach the caller instead of being reported as a successful delete.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FIX] Plain-language image-mode limit errors; pin rm's wrong-folder delete

- Rewrite the four image-mode limit errors (page cap and byte budget, at
  indexing and at answer time) in user terms: what is wrong, the numbers,
  what to do. Drop the operator-only env var names and the "however many
  pages it has" phrasing. Cap errors keep the note that "Pages to extract"
  does not apply in image mode.
- Add tests for the second failure mode of plain rm() on object storage,
  seen against MinIO and a GCS emulator: when another document's folder
  matches the name as a glob, rm() deletes that folder and leaves the
  target in place. rm_exact leaves it alone.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [MISC] Keep one call inside pytest.raises in the partial-write cleanup test

Addresses SonarCloud python:S5778.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FEAT] Reject image-mode PDFs over the page cap at upload

When the project's default profile is LLMWhisperer in image mode,
Prompt Studio's upload now counts each PDF's pages before storing
anything and rejects one over the page cap (400) with a plain message.
Image mode can never answer such a document, so the user learns at
upload instead of at indexing.

- Uses the SDK's page counter and configured_page_cap(), so a document
  accepted at upload also passes the extraction-time pre-check.
- All files in a request are checked before any is stored.
- Text modes, other x2text adapters, non-PDFs and unreadable PDFs pass
  through; extraction still checks them.
- Extracts _is_image_output_mode, now shared with the PDF-only guard.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FIX] Address code review: exact deletes for document files; safer upload check

- Prompt Studio document delete no longer globs the document's name.
  On GCS/S3, rm("Report [Final].pdf") missed itself and deleted
  "Report F.pdf" instead, and the related-files glob matched the other
  document's extract files (both seen on MinIO). Use rm_exact for the
  source and related files and the extract-text cleanup, and escape the
  name in the related-files glob.
- Upload check: decide image mode once per request and fail open if the
  default profile's adapter config can't be read (e.g. an old encryption
  key) so uploads keep working; sniff each file's type once.
- Count pages through a public SDK helper that reads the upload stream in
  place instead of copying the whole file into memory.
- Use LLMWHISPERER_ADAPTER_PREFIX instead of a second literal.
- rm_exact tolerates an object already gone between listing and delete.
- Correct the comment that claimed multi-file uploads are all-or-nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-2646 [FIX] Delete a failed upload's partial file by its exact name

write_streaming's cleanup used fs.rm, which expands the uploaded
document's name as a glob on GCS/S3: for "Report [Final].pdf" it missed
the partial file or deleted "Report F.pdf" instead. Use rm_file, an
exact delete on every fsspec backend.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* UN-2896 [MISC] Deprecate and remove the LLMWhisperer V1 adapter

Adds a single DEPRECATED_ADAPTERS registry that drives every guard, seeded
with LLMWhisperer V1, and deletes the V1 adapter package it retires.

Guards (adapter-type agnostic, so future deprecations are a one-line entry):
- excluded from supported_adapters, so it cannot be picked for creation
- POST /adapter/ and /test_adapters/ reject a deprecated adapter_id
- profile manager rejects pointing a profile at one; existing profiles on a
  deprecated adapter stay editable in their other fields
- platform-service rejects execution off the is_available column, which is
  every SDK adapter lookup's single choke point

Backfill migration marks existing V1 instances unavailable across all orgs
(0003 used .first(), which marked only one row per adapter).

Removes the V1 package, its icon, its dead env vars (POLL_INTERVAL,
MAX_POLLS, STATUS_RETRIES -- V2 uses WAIT_TIMEOUT/MAX_RETRIES/RETRY_*) and
the workflow-execution plumbing that forwarded them into tool containers.

Claude-Session: https://claude.ai/code/session_01DXuiGyUwXyU1EVQBeMHppe

* UN-2896 [FIX] Self-review: close the remaining deprecated-adapter paths

- platform-service: the adapter_instance route's blanket `except Exception`
  re-wrapped every APIError as a 500, so both the new deprecation error and
  the pre-existing "not found" reported as server errors and logged a
  traceback. Re-raise APIError untouched, as the neighbouring route does.
- Move the adapter_id check into AdapterInstanceSerializer.validate:
  adapter_id is writable, so update/partial_update could set a deprecated id
  that create rejected.
- Default profile creation and the project-import warning gated on is_usable
  alone, letting a deprecated default land in a new profile without passing
  through the serializer.
- set_default_triad accepted a deprecated adapter as a user default straight
  from the API.
- New is_adapter_selectable() states the rule once: usable, available, and
  not deprecated.
- DefaultTriad: disable deprecated options instead of dropping them. Filtering
  emptied adapterList for an org whose adapters are all deprecated, which
  gated the effect that loads the current defaults, and left the current
  default rendering as a bare UUID.

Claude-Session: https://claude.ai/code/session_01DXuiGyUwXyU1EVQBeMHppe

* UN-2896 [FIX] Silence Sonar S117 on the migration's get_model calls

Matches the repo's existing convention for this rule in data migrations.

* UN-2896 [FIX] Don't re-validate unchanged defaults in set_default_triad

The Default Triad UI submits all four defaults on every save, so validating
every submitted value locked a user out of changing any default once one of
theirs had been deprecated underneath them. Skip unchanged values, matching
ProfileManagerSerializer.validate, and cover it with a regression test.

Also drops the duplicated adapter name from the deprecation message and
promotes the inline logger in serializers to module level.

* UN-2896 [MISC] Correct the V2 adapter's env var docs

The README still listed ADAPTER_LLMW_POLL_INTERVAL and ADAPTER_LLMW_MAX_POLLS,
which only V1 ever read, and the WhispererEnv docstring quoted a 300s default
where the code uses 900. Document the four vars V2 actually resolves.

* UN-2896 [MISC] Revert the V2 env var doc edits

Out of scope: V2's own stale env table is pre-existing debt in a V2 file,
not V1 residue this PR is removing. Worth a separate tidy.

* UN-2896 [TEST] Cover the deprecated-adapter execution path

The 400-not-500 behaviour for an adapter already wired into a project had
no test; platform-service only had middleware and route coverage.

* UN-2896 [MISC] Drop the set_default_triad deprecation guard

Beyond the ticket's scope: the UI already disables a deprecated option, and
running one returns a clean 400 from platform-service. Restores the function
to its original form, removing the re-validation that locked a user out of
changing any default once one of theirs had been deprecated.

---------

Co-authored-by: harini-venkataraman <115449948+harini-venkataraman@users.noreply.github.com>
…l-not-found hint (#2325)

* UN-4248 [FIX] Render LLM provider errors as a readable line with status, class and a model-not-found hint

* UN-4248 [FIX] Self-review: cover litellm's other provider error shapes and never raise from the formatter

* UN-4248 [FIX] Self-review: no HTTP status without a response, multi-word provider tags, single period before the model hint

* UN-4248 [FIX] Address SonarCloud: string-based tag and hint stripping, pytest.raises in tests
…s restart (#2319)

* UN-4238 [FIX] Keep Prompt Studio results reaching the UI after a Redis restart

python-socketio 5.9.0's KombuManager listener only retried on OSError and
KombuError. A Redis restart raises redis.exceptions.ConnectionError, which is
neither, so the listener thread in each backend process died for good: the
pod kept accepting browser sockets but never delivered another pub/sub
event, and Prompt Studio results showed only after a manual refresh.

Upgrade to 5.17.0 (python-engineio 4.13.1 -> 4.14.0). Since 5.11.0 the
listener thread restarts itself, and since 5.16.3 _listen() retries on any
exception with backoff. A regression test pins that behaviour.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4238 [FIX] Give each gunicorn worker its own Socket.IO pub/sub host_id

python-socketio 5.11+ drops pub/sub messages carrying its own host_id,
assuming the publisher already delivered them locally. The backend runs
gunicorn with --preload, so log_events.py builds the Socket.IO server once
in the master and every forked worker inherited the same host_id: a Prompt
Studio result emitted by one worker was silently discarded by the worker
holding the browser's socket, leaving the prompt card stuck until refresh.

Regenerate host_id in each forked child via os.register_at_fork. Works with
or without --preload; a test forks and asserts the child's id differs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4238 [FIX] Harden the fork test and satisfy D403 under the pinned ruff

The forked child now runs inside try/finally os._exit, so an error there
can never unwind into the child's copy of pytest, and the parent asserts
the child's exit status. The docstring's first line is reworded so it is
capitalised (D403 under ruff 0.3.4).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…gs (#2321)

* fix: remove eval() on DB error messages and exec() in settings

ExceptionHelper.extract_byte_exception ran eval(str(e)) on exceptions
from the MSSQL and MySQL connectors. That text comes from the remote,
user-configured database server, so untrusted input reached eval. Read
the details from e.args instead, and fall back to str(e) for any other
shape rather than raising inside the exception handler.

Replace the exec() loop that set four SOCIAL_AUTH_* settings in
backend/settings/base.py with explicit assignments.

UN-4239

* fix: unwrap single-tuple driver args and harden the no-eval test

Address self-review on #2321:
- extract_byte_exception now also handles an exception whose single
  argument is a (code, details) tuple, which the old eval-based code
  accepted (pymssql raises this shape from connect()).
- test_message_is_not_evaluated used a payload that could never run
  inside the helper, so it missed eval reintroduced behind a fallback.
  Use a payload with an observable side effect instead.

UN-4239
…eam over Redis (#2316)

* UN-4077 [MISC] Stop requiring Celery broker credentials when logs stream over Redis

Nothing connects to RabbitMQ once LOG_TRANSPORT=redis, yet the backend
settings and the tool sidecar refused to start without CELERY_BROKER_*,
blocking removal of the broker credentials from the Helm chart.

- Backend: read the broker vars optionally; CELERY_BROKER_URL is built
  exactly as before when the base URL is set, else "" (Celery connects lazily).
- Tool sidecar: require the broker vars only on the Celery log transport,
  via unstract-core's now-public use_redis_log_transport().
- Runner: forward the broker vars to the sidecar only when set.
- run-worker.sh: CELERY_BROKER_BASE_URL is no longer a required var.

LOG_TRANSPORT semantics and its "celery" default are unchanged.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4077 Keep the broker required wherever the Celery log transport needs it

- Backend: broker settings stay required on the Celery log transport and are
  optional on Redis; a set base URL still needs its credentials. The rules live
  in backend/celery_broker.py so they are unit-tested without Django.
- run-worker.sh: CELERY_BROKER_BASE_URL is required unless every requested
  worker is a PG-queue member.
- WorkerConfig: no missing-broker error on the Redis log transport.
- Sidecar test: each broker setting is checked as missing on its own.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4077 Require the broker for PG-only workers on Celery logs and test the settings wiring

- run-worker.sh: CELERY_BROKER_BASE_URL is required when any non-PG worker is
  requested or LOG_TRANSPORT is not "redis"; the help line states the rule.
- Backend settings: the broker requirement and reads now run after the .env
  load, so a .env-only LOG_TRANSPORT=redis is honoured.
- Tests import the real settings module in a subprocess to pin what it
  enforces, and the URL cases assert literal expectations.
- celery_broker docstring no longer claims the module imports in isolation.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* UN-4077 Require no broker settings on the Redis log transport

- Backend: on the Redis log transport no CELERY_BROKER_* setting is required,
  even when a base URL is configured without its credentials; the Celery
  transport still requires all three.
- WorkerConfig: restore main's broker validation and drop its test, since the
  skip also silenced the warning for Celery workers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ger deadlock on httpcore's pool lock (#2318)

* UN-4237 [FIX] Close streamed LLM responses so garbage collection cannot deadlock executor workers on httpcore's pool lock

LiteLLM's sync CustomStreamWrapper has no close() and stops iterating
before the HTTP body ends, so every streamed completion left its response
checked out of httpcore's pool until the garbage collector finalised it.
When that happened on a thread inside ConnectionPool.handle_request, the
finaliser's PoolByteStream.close() waited on the same non-reentrant lock
the thread held, and the worker hung forever with its lease still renewing.

Add close_stream(), which closes the stream and the provider response
behind it (litellm's ModelResponseIterator, the OpenAI SDK Stream, plain
generators), and call it in a finally in collect_with_retry and
iter_with_retry: on success, on error and on early exit. The retry backoff
now runs after the stream is closed.

Tests run the real LLM.complete() path against a local Anthropic SSE
server with GC disabled, and force a collection inside the pool lock to
reproduce the deadlock; they fail against the previous code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb

* UN-4237 Close the Responses-API stream too, and warn when a close fails

Addresses the standardized review on #2318.

- close_stream now walks the stream's handle attributes to a bounded depth
  with a visited set, instead of one fixed level. On litellm's Responses-API
  bridge (responses/<model> and the models routed there) the HTTP response
  sits under an iterator with no close(), one level below where the old walk
  stopped, so an error after content or an early close left the pool slot
  checked out.
- A close that raises is logged at WARNING with the traceback: httpcore marks
  its stream closed before closing the socket, so that failure can hold the
  slot for good.
- Comments and docstrings no longer claim an abandoned generator is covered:
  only a caller that closes it is. stream_complete's docstring says callers
  that stop early must close it.
- Tests: socket tests for the Responses-API bridge (error after content,
  early close, drain), and unit tests for a generator completion_stream
  (Bedrock), an OpenAI-SDK-shaped Stream, the Responses chain, cycles, and
  the warning. Each was checked against a mutation of the fix.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb

* UN-4237 Keep close_stream from raising when reading close itself fails

Addresses Greptile's review on #2318: the close attribute was read outside
the try, so a handle whose close property raised would stop the cleanup
before the remaining handles and could replace the original stream error in
the caller's finally. The lookup now sits inside the guarded block, with a
test that fails without it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb

* UN-4237 Address review: traceable close-failure warning, version-agnostic comments

From Chandru's review on #2318:

- A failed close logs only its own traceback: close_stream runs in a finally
  while the provider error is propagating, so Python chained that error onto
  the close failure and exc_info printed it a second time.
- close_stream takes the caller's logger and adapter description, and both
  retry helpers pass theirs, so a leaked pool slot can be traced to the
  provider that leaked it.
- Comments: the explanation lives once, in close_stream's docstring (the only
  ticket reference left); the rest point at it. Dropped litellm version pins
  and "before the fix" wording, and described the Responses-API chain the
  same way everywhere (wrapper -> completion_stream -> streaming_response ->
  response).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VQoJFFiRZyDKyu14pg5hDb

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: ali <117142933+muhammad-ali-e@users.noreply.github.com>
chore: remove leftover debug console.log calls in Prompt Studio

DocumentManager logged file blobs, selected document and fetch metadata on
every render/fetch, and ImportTool logged dropped files. These end up in
users' consoles and in PostHog console-log capture.


Claude-Session: https://claude.ai/code/session_01K1H42KtrPNCMvAZxYmt1r1

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
…ne path (#2239)

* UN-3057 [FIX] Repair Prompt Studio projects left ownerless by the clone path

Since UN-2202, `_is_resource_owner` consults only ResourceMembership OWNER
rows for resources that expose `memberships` (CustomTool does) and no longer
falls back to `created_by`. The Prompt Studio clone path never created that
row, so every project cloned after 0009_absorb_shared_users ran has no owner
at all: still visible (the clone copies the parent's `shared_to_org`) and
profiles can still be created, but `IsParentToolOwner` denies every mutation
on them — deleting an LLM profile returned 403.

The clone helper itself is fixed in unstract-cloud; that stops new breakage
but cannot help rows already written. This adds the repair:

- `repair_ownerless_owner_rows()` grants `created_by` an OWNER row on
  resources that have zero OWNER rows. Only ownerless resources are touched,
  so a creator deliberately replaced by a co-owner is not resurrected, and a
  null creator or null organization is skipped. Idempotent.
- It iterates `_base_manager`: several resources' default manager is
  org-scoped by `UserContext`, which is unset during a migration and would
  silently filter every row out. Same guard `tenant_account_v2.signals` uses.
- Migration 0011 applies it to CustomTool; reverses to a no-op.

Regression tests pin all three branches (repair, leave-alone, skip). Verified
red-green: with the helper body stubbed to a no-op the behaviour test fails
and the two guard tests still pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* UN-3057 [FIX] Promote an existing non-owner creator row to OWNER in the ownerless backfill

Membership is unique per (user, resource), so get_or_create returned a creator's existing VIEWER row unchanged and left the project ownerless. update_or_create promotes it; only projects with zero OWNER rows reach this path. The repaired count now includes promoted rows.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* UN-3057 [FIX] Re-point platform-key owners after the ownerless repair, and keep the migration comments generic

Review follow-up on #2239:

- Migration 0011 now calls `repair_platform_key_ownership` after the
  ownerless repair, so an OWNER row written for a platform key's service
  account is re-pointed to the key's creator (service accounts are filtered
  out of every owner surface). It depends on `tenant_account_v2.0005` and
  `platform_api.0004` rather than `tenant_account_v2.0006`, so a backport to
  a release that predates 0006 cannot leave an inconsistent history.
- The `_base_manager` comment now states what holds under `migrate`
  (historical models' managers are unscoped) instead of the request-time
  org-scoping, which does not apply there.
- Module docstrings describe what the migration and test do; the ticket
  history lives in the PR.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Update backend/prompt_studio/prompt_studio_core_v2/migrations/0011_repair_ownerless_custom_tools.py

Signed-off-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com>

---------

Signed-off-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chandrasekharan M <117059509+chandrasekharan-zipstack@users.noreply.github.com>
… error message (#2331)

* UN-4069 [FIX] Raise upload limit on prompt_output to 10 MB with clear error

Agentic table extraction output with highlight coordinates can exceed
Django's default 2.5 MB DATA_UPLOAD_MAX_MEMORY_SIZE, causing a generic
500 that retries uselessly. Use request.read() to bypass the global
limit on this view only, enforce a 10 MB cap, and return 413 with an
explicit size message so the error is actionable in the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* UN-4069 [FIX] Update prompt_output tests for request.read() change

The view now uses request.read() instead of request.body, so mock
requests need read.return_value and META set alongside body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…#2327)

* feat: capture adapter test-connection and save results in PostHog

The existing adapter events fire on click (intent_add_*, test_connection_*,
intent_success_add_* on the Submit click), so a failed add and an abandoned
one look the same in funnels. Add adapter_test_connection_result and
adapter_save_result with result success/invalid/error. The error text is
sent only from SaaS prod, because it can echo customer endpoints.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1H42KtrPNCMvAZxYmt1r1

* fix: test the SaaS-only gate on adapter error text

Move the gate into saasOnlyErrorProps so it can be tested without
rendering ConfigureDs, and send only string alert content: subscription
errors carry an array that would stringify to [object Object].

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1H42KtrPNCMvAZxYmt1r1

* feat: capture Prompt Studio run results in PostHog

ps_prompt_run only records the click; ps_prompt_run_result records the
outcome (status, operation, prompt count, elapsed seconds) so a stall
after a few runs can be split into failing runs vs abandoned ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1H42KtrPNCMvAZxYmt1r1

* fix: send task_id with ps_prompt_run_result for deduping across tabs

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K1H42KtrPNCMvAZxYmt1r1

* docs: trim PostHog event comments

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
* UN-1924 [FIX] Reject unsupported files by sniffing MIME in API storage staging

API-deployment uploads were gated on the multipart Content-Type, which the
caller supplies and nothing verifies, with a fallback to
application/octet-stream that is itself in AllowedFileTypes. Any file passed
that check, reached the bucket, and failed at extraction with an error that
did not name the cause.

Detect the type from the file's own bytes with libmagic before writing
anything, matching the filesystem source path, and report a rejected file as
a failed entry in the API response instead of staging it under a placeholder
hash that later surfaced as an empty-file error.

* UN-1924 [FIX] Terminalise an API execution when every file is rejected

Rejecting files at staging means the dispatch set can now be empty, which
reached a path that was previously unreachable: the API worker's
_unified_api_execution short-circuits an empty file set and returns
status COMPLETED without ever writing that status back, so the row kept the
status it was dispatched with and the caller polled a PENDING execution
forever.

Skip the dispatch entirely when staging yields nothing, marking the execution
COMPLETED and returning the per-file rejection entries, and make the worker's
own short-circuit persist the status so an empty set from any other caller
cannot strand an execution either.

* UN-1924 [FIX] Address review findings on unsupported-file rejection

Resolving an 8 KiB sample alone rejected every legacy Office upload larger
than the window: libmagic reads .doc/.xls/.ppt through the OLE2 directory
sector at the end of the file, so the sample only ever showed the container
(application/x-ole-storage), which is not allow-listed. Reproduced on a
710 KB .doc and a 1.2 MB .xls, on the API path and the UI execute endpoint
alike. Container samples now escalate to a full-file classification, using
the upload's temp path when Django has spilled it to disk.

Also from review:

- Isolate the terminal status write on the all-rejected path so the rate
  limit slot and staging dir are released even if it raises, matching the
  staging-failure path above it.
- Report the execution's stored status instead of asserting COMPLETED; the
  row can be missing or the terminal guard can refuse the change, and
  claiming success only hides a stranded execution behind a 200.
- Write total_files/failed_files alongside the status, since a terminal row
  with a NULL failed_files reads as a clean success to is_failure_run and to
  run history.
- Distinguish an empty dispatch from a total conversion failure in the
  worker: convert_file_hash_data swallows per-file errors and returns {} for
  both, so the second was being reported as a zero-file success.
- Scope the guard comment on update_execution_completed to the PG transport;
  the legacy path applies the status unconditionally.

Tests: pin the short-circuit to the staging result rather than the upload
list (the previous test passed with the original bug reintroduced), cover
the cleanup-on-DB-error path, the container escalation, and the empty-upload
branch. Mutation-checked: reverting the escalation, gating the short-circuit
on file_objs, and dropping the cleanup isolation each fail the suite.

* UN-1924 [FIX] Release API rate limit slots by org id, not model instance

release_slot formats its argument into the Redis key, and acquire_slot built
that key from str(organization.organization_id). Passing the Organization
instance produced a different key, so the ZREM removed a non-member: it
returns 0 and raises nothing, leaving the slot held for the full TTL and
throttling every other API-deployment call for that org.

All three call sites in this module were affected, including the one added
for the all-rejected path. The two correct call sites in the codebase
(undispatched_sweep.py, models/execution.py) already pass the id string, and
the former carries a comment describing this exact trap.

The same instance-instead-of-id call remains in api_deployment_views.py and
in two places in mcp_server/tools/execution.py; those are outside this
change's surface and are left for a separate fix.

* UN-1924 [FIX] Fail one undetectable upload instead of the whole request

MIME detection reads the upload, so a broken stream raises inside the staging
loop and aborts every remaining file in the request. Rejection is already
per-file for an unsupported type; an unreadable one now behaves the same way.

The message says detection failed rather than naming a type, since an I/O
fault and an unsupported format need different follow-ups.

* UN-1924 [FIX] Acknowledge results and notify subscribers on an all-rejected run

The all-rejected early return reaches a terminal status without going through
WorkflowHelper, so it skipped two things the dispatch path does.

It serves the per-file results in its own response but never marked them
consumed, so a follow-up GET /status served them a second time with 200 where
the contract is 406. The synchronous dispatch path acknowledges at exactly
this point.

It also never reached PipelineUtils.update_pipeline_status, the only
dispatcher of API deployment notifications, so an all-rejected request
alerted nobody where a dispatched-and-failed run would have.

Both are wrapped so a failing webhook cannot turn a handled rejection into a
500, and the response is unchanged.

set_result_acknowledge loses its underscore: it is now called from another
module, so it is part of the contract rather than an internal detail.

* UN-1924 [FIX] Keep acknowledgement and notification independent

Sharing one try block meant a failed acknowledgement returned before the
notification ran, so an all-rejected execution could reach a terminal state
without alerting API deployment subscribers. They are independent
obligations; neither now depends on the other succeeding.

* UN-1924 [MISC] Drop formatter churn on two untouched assertions

Reformatted by a newer ruff than the v0.3.4 the pre-commit config pins, in
the opposite direction to how the file already reads. Restores both to the
committed formatting so the diff is additive.

* UN-1924 [FIX] Mirror LLMWhisperer's file type gate

The check exists so a file Unstract accepts is one LLMWhisperer can extract.
Any divergence is a bug in one of two directions: accept something it cannot
read and the failure just moves downstream to a 415, or reject something it
can read and a working file is lost.

Reconciled against the gate itself — Util.is_valid_file_type_from_path in
unstract-llm-whisperer, not the public docs page, which lists formats the gate
does not name and omits how it actually decides.

- Any text/* is accepted. That is how html, xml, tsv, rtf and markdown reach
  the extractor: as text, not as listed formats. They were being rejected here
  despite LLMWhisperer handling them.
- Dropped application/octet-stream. Nothing libmagic cannot name is extractable,
  and this entry is why a zip renamed .pdf reached LLMWhisperer and came back
  415 — the symptom this ticket was raised for.
- Dropped the macro-enabled xlsx type, which LLMWhisperer's gate does not list.

The two enums now match member for member, 20 MIME types. An empty upload has
no type to judge, so detection returns None and the gate is skipped rather than
passed through a placeholder.

LLMWhisperer additionally re-identifies octet-stream and zip with Magika and
falls back to scanning for a %PDF- header. We have neither, so this gate is
marginally stricter than theirs for unidentifiable bytes.

* UN-1924 [FIX] Recognise a PDF that does not start at offset 0

libmagic's PDF rule matches only at offset 0, so eight bytes ahead of the
header are enough to make a real PDF sniff as application/octet-stream.
Dropping octet-stream from the allow-list therefore started rejecting
stream-wrapped PDFs — and this allow-list also gates the filesystem connector
and the worker, so those files would have begun failing in ETL and Task
pipelines that process them today.

LLMWhisperer scans the same 1 KB window for the same reason, after giving
octet-stream and zip a Magika second opinion first. Without Magika this
covers the PDF case, which is the one that reaches us; anything else libmagic
cannot name is still rejected.

Applied at all three gates that share the list: API staging, the filesystem
source, and the worker's own check.

Also: the worker's two new terminal branches wrote the execution status but
never updated the pipeline, so a run that ended there left the pipeline's last
run stale and alerted no subscriber. They now notify the way the sibling
empty-files branch does.

* UN-1924 [FIX] Harden rejection reporting and the PDF rescue

Three faults in the staging loop, all raised in review.

The PDF rescue promoted on a bare `%PDF-` substring anywhere in the first
kilobyte, so any unidentifiable blob carrying that text near its start could be
staged as a PDF — a way past the gate, in a check whose whole purpose is to
close one. It now leaves a recognised zip alone and re-classifies from the
marker's own offset, so the promotion has to be corroborated rather than
guessed. Stricter than LLMWhisperer's equivalent scan, which is the safe
direction for something that moves a file into the allow-list.

Detection caught `Exception`, which turned any systemic fault — a broken
libmagic database, an unreadable upload temp dir — into a per-file rejection.
Every file in every request would fail that way, and the request still answers
200 COMPLETED, so a platform outage would read to callers as "your files are
invalid" and to metrics as success. Now narrowed to the faults that really are
about this upload's bytes; anything else propagates and fails the request.

Reporting a rejection wrote to the result cache inside the per-file loop
through an unguarded pipeline execute, so a transient Redis fault while
rejecting one bad file would fail the whole execution and discard the good
files already staged — contradicting the invariant the suite claims in
test_supported_files_survive_a_rejected_sibling. Both call sites now go
through one reporter that degrades to a log line.

Tests cover all three, plus the disk-backed detection branch, which no test
reached: SimpleUploadedFile has no temporary_file_path, so every existing
container test exercised only the in-memory path — while real legacy Office
uploads, the files that branch exists for, are usually over the 2.5 MB
threshold and take the other one.

* UN-1924 [FIX] Require a PDF version header before rescuing a file

Re-classifying from the marker's offset was not the safeguard it looked
like: libmagic's PDF rule is only the magic bytes, so "%PDF- not a pdf"
still came back application/pdf and the file was still promoted into the
allow-list.

A real header carries a version, so require %PDF-<n>.<n> before treating the
marker as evidence. The offset re-classification stays as a second check.

The test that was supposed to cover this passed for the wrong reason - its
blob sniffed as image/x-tga and never reached the rescue at all. It now
asserts the bytes are octet-stream first, so it fails if it stops exercising
the branch.

* UN-1924 [FIX] Resolve zip containers, which libmagic will not name from a buffer

Measured on the shipped libmagic 5.46: magic.from_buffer never returns
application/zip at any sample size, not even given every byte of the file —
only magic.from_file does. So the zip half of the escalation set could never
match, and the value a zip container actually reports from a buffer,
application/octet-stream, was neither allow-listed nor escalated. A valid
.docx whose first member is not [Content_Types].xml — what re-zipping and
streaming writers produce — was therefore rejected at upload, having worked
on main under the octet-stream allowance.

octet-stream now counts as undecided like the named wrappers, one shared set
so the API and connector paths cannot disagree, and classification always
goes through a path: an in-memory upload is spilled to a temporary file
rather than classified from a buffer that cannot answer the question.

from_file still only says 'application/zip' for a repackaged OOXML file, so
the archive's own entries decide — the same markers libmagic's msooxml rule
looks for. That recognises the document without taking on an ML classifier;
it can only return types already in the allow-list, so it widens what is
recognised and never what is permitted.

Tests now feed real container bytes with libmagic unmocked. Every existing
container test scripted libmagic's answers, which is precisely why a detector
asking a question libmagic cannot answer looked green.

* UN-1924 [REFACTOR] Move the shared MIME gate logic into unstract/core

The backend and the workers were each carrying a verbatim copy of the
inconclusive-type set, the PDF rescue and the zip inspection, kept together
by a 'keep in step' comment — which the quality gate then failed at 8.2%
duplication on new code against a 3% limit.

unstract/core already hosts exactly this kind of cross-cutting predicate
(is_failure_run), so the logic lives there now and both packages import it.
The allow-list enums stay where they are; only the interpretation of
libmagic's answer is shared, which is the part that must not drift.

identify_zip_container takes the allow-check as an argument rather than
importing an enum, so the shared module stays free of either package.

* UN-1924 [FIX] Bound the ODF mimetype read so a zip bomb cannot expand through it

Looking inside an archive to name the document meant reading its mimetype
member whole. That member comes from an unvalidated upload, and read()
decompresses whatever the archive declares, so a compact bomb could expand
into backend memory during synchronous staging and fail the request before
anything was dispatched.

An ODF mimetype member holds one media type string, so anything larger is
not what is being looked for: the declared size is checked first and the read
is bounded regardless.

* UN-1924 [FIX] Stop an archive nominating its own file type

Reading the ODF mimetype member and accepting any allow-listed value it
named handed the decision to the upload: a zip declaring application/pdf
would have been staged as a PDF and dispatched, which is precisely the
deferred extraction failure this gate exists to prevent.

The member can identify which ODF document an archive is; it cannot choose a
format. Only the three ODF types are honoured now, and only when the archive
meets the conditions the spec requires of a real one — first entry, stored
uncompressed, and small. A member bolted onto an arbitrary zip meets none of
them.

identify_zip_container no longer takes the allow-check, because every value
it returns is now a fixed constant rather than a string from the archive.

* UN-1924 [MISC] Leave one throwing call inside the raises block

Building the upload inside pytest.raises meant two calls could satisfy it,
so the test would still pass if constructing the file were what raised
rather than staging it. Sonar flags the shape (python:S5778) and it is the
same way a test passes for the wrong reason that bit two other cases here.

The upload is built outside the block, and the expected message is matched,
so only the staging call can satisfy it. Verified by mutation: widening the
narrowed except back to Exception fails this test with DID NOT RAISE.

* UN-1924 [MISC] Pin the zip-carrying-a-PDF-marker case

An entry stored uncompressed puts %PDF-1.7 in the archive's literal leading
bytes, so a scan of the head sees it exactly as it would in a real document.
Nothing pinned that the wrapper is resolved before the marker is consulted,
which is the order that keeps it from becoming a way past the gate.

Worth having as a test rather than a reasoned argument: checking the helper
on its own suggests the marker wins, and only the real entry point shows the
escalation to from_file naming the archive first.

* UN-1924 [FIX] Three small inconsistencies in the new terminal paths

The worker's COMPLETED arm wrote total_files=0 and left the other two
counters NULL — the exact shape update_execution_completed exists to avoid,
since a terminal row with a NULL failed_files reads as a clean success.
Nothing ran, so both are now written as zero.

Its ERROR arm named the reason under "message" while the function's other
two ERROR returns use "error", so a consumer reading .get("error") to learn
why an execution failed got None for this variant alone.

When the all-rejected status write raises, the response carried no error at
all, unlike the sibling staging-failure path — a bare 422 with no reason,
while the row is still PENDING so a follow-up GET /status says something
else. The swallowed exception is now reported.

Also corrects a comment that claimed a field-scoped save() is safe against
concurrent writers: it is, for the columns, but save() re-runs
_handle_execution_cache() regardless of update_fields, which another comment
in the same file already explains. No live bug at this call site — the row is
freshly created and undispatched — but the comment said otherwise.

* UN-1924 [FIX] Count and keep files rejected before dispatch

A partially-rejected run reported as a clean success: rejected files are not
dispatched, so the worker's batch aggregate never saw them, and a run that
turned away a file still finished failed_files=0. That reads as success to
is_failure_run, so subscribers who asked to hear about failures heard
nothing, and the only trace was a cache entry deleted on the first status
poll.

A rejected file now gets a terminal ERROR file execution carrying its real
MIME type and the reason. The execution serializer already derives
successful/failed by counting those rows, so run history and the UI become
correct for free, and the rejection survives long enough to answer a support
question.

The stored counters are then floored at what the rows say when the worker
reports its aggregate. That only ever corrects upward, and only to a number
already true in the database, so runs with nothing rejected are untouched —
which is what lets this stay inside the backend rather than changing the
counter contract the ETL and Task pipelines share.

The row write is guarded: bookkeeping must not cost a caller the good files
staged alongside the bad one. Content is hashed for the row's identity, since
the API path has no file_path and two rejects would otherwise collide.

Mutation-checked: stubbing the row write fails both new tests.

* UN-1924 [FIX] Keep two rejections apart when the bytes are identical

The row identity was the content hash alone, so the same bytes uploaded
twice under different names folded into one row — recording one rejection
instead of two, under-counting failed_files, and losing a durable record the
caller was owed. The API path has no file_path, so this value is all that
separates them.

The name is folded into the identity. Deliberately no longer a plain content
hash: a rejected file was never processed and should not look to file
history as though it had been.

The test missed this because it used different bytes for the two files, so
it never exercised the collision. It now sends identical bytes under
different names, and fails on a content-only identity.

* UN-1924 [MISC] Drop a line-number cross-reference from a comment

The comment pointed at undispatched_sweep.py:245 to explain the trap, and
nothing keeps that honest — it was already off by two lines when written.
Both reviewers flagged it.

Says the constraint concisely instead: pass the id string, never the model
instance, because release_slot builds its key from what it is given.

* UN-1924 [MISC] Shorten the new code comments

Many of them read like review-thread replies: they recorded line numbers,
paths into other repos, libmagic versions and the reasoning behind earlier
attempts. Those details rot, and two were already wrong — a cited method name
that does not exist, and a version-specific measurement that does not hold on
a newer libmagic.

Each now says why the code does something unusual, in a line or two. The
reasoning itself is not lost; it is in the PR and the commit history, which
is where it stays true.

* UN-1924 [MISC] Clear the two Sonar findings

Cognitive complexity on _update_file_aggregates had gone to 17 against a
limit of 15, so the flooring moved into its own method. Reads better split
anyway: one method decides which counters are being written, the other
reconciles them with the file rows.

The composite assertion in the rejection test is split, so a failure says
which half went wrong.

Note the extracted signature annotates with a quoted "list[str]": the
enclosing viewset defines its own list() action, and a class-body annotation
resolves in the class namespace, so the bare form binds to that method and
raises on import. Caught by importing the module rather than parsing it.

* UN-1924 [FIX] Require relationships before naming a zip an Office document

Marker directories alone were enough, so an archive carrying
[Content_Types].xml and a word/ member was named a document without being
loadable. Greptile was right about this one, and my own live run had already
shown it: an archive of exactly that shape was accepted here and then
rejected by the extractor, which is the failure this gate exists to prevent.

A real package always carries _rels/.rels, so that is required now. Only
affects the repackaged shape, which is what this code resolves — with
[Content_Types].xml first, libmagic names the package and this never runs.

The test fixture was the incomplete shape, which is why it failed extraction
when deployed. It is a real package now, and the incomplete one is pinned as
rejected.

* UN-1924 [FIX] Let neither recognised wrapper be renamed by its payload

The rescue returned early for application/zip but not for
application/x-ole-storage, so an OLE2 container libmagic could not classify
further was promoted to a PDF by a %PDF- marker sitting in its first
kilobyte. Both are wrappers libmagic positively identified, and neither
should be overruled by its own contents.

Also fixes a CI-only failure of my own making: a guard assertion pinned
from_buffer to application/octet-stream, which is build-dependent — CI
reports application/zip for the same bytes. It now asserts the property the
test needs, that the archive is not named a document and so gets inspected.
That disagreement settles an earlier one on this PR: the reviewer's
application/zip measurement was right and my local one was the outlier.

Mutation-checked: narrowing the guard back to zip alone fails the OLE case.
The first version of that test could not have caught it — a single mocked
from_buffer answered the rescue's own re-classification too, hiding the
promotion.

* UN-1924 [FIX] Require a declared main part, and one row per rejected part

Two holes, both confirmed rather than argued.

Member names alone were enough to name a zip an Office document, so an
archive carrying the right names with nothing behind them was accepted and
then failed conversion. Confirmed against the deployment: an archive of
exactly that shape came back error_in_ingestion. A real package declares its
main part's content type, so that declaration is required now — matched as
bytes, since the input is untrusted and an XML parser is more surface than
this check warrants.

Rejected rows keyed on content and name alone, so two byte-identical parts
sent under one name collapsed into a single record and the counts came up
short. The upload's position in the request is part of the identity now, so
every rejected part keeps its own row.

The test fixture was the shape that fails conversion, which is why it needed
the live check to expose. It declares a main part now, and the
names-only archive is pinned as rejected.

Mutation-checked: dropping either requirement fails its test.

* UN-1924 [FIX] Correlate the declared main part with what relates it

Requiring the marker was too loose: an archive with main+xml as stray text,
an empty _rels/.rels and any word/ member still passed as a document.

A package must now declare its main part's content type, relate that same
part from the root, and contain it. The reported type comes from the
declaration rather than guessed from a member-name prefix.

Still bytes and bounded reads, not an XML parser — the input is an untrusted
upload. Attributes are matched in any order and either quoting style, so a
real package is recognised however its XML is written.

Checked against three LibreOffice artefacts and four near-miss shapes: stray
marker, declared-but-unrelated, relationship disagreeing with the
declaration, and declared part absent from the archive. All four rejected,
all three real packages identified.

* UN-1924 [FIX] Parse package declarations instead of matching their bytes

Matching raw bytes kept admitting archives that are not documents: an XML
comment satisfied the Override match, and the ODF branch trusted the mimetype
member on its own. Tightening the patterns was not converging — a comment, a
namespace prefix or an escaped attribute each defeat matching, so the
declarations are parsed now.

OOXML must declare its main part, relate that same part from the root, and
contain it. ODF must have a manifest declaring the same media type for the
package root. The reported type comes from the declaration, not from a
member-name prefix.

Reads stay bounded, and a member carrying a DTD is refused rather than
parsed, so entity expansion never reaches the parser. Stdlib only:
defusedxml is present for the backend and the workers but is not declared by
unstract-core, and this did not warrant adding a dependency there.

Checked against four genuine LibreOffice artefacts and seven near-miss
shapes, including both bypasses Greptile found and an entity bomb.

The ODF cases are asserted against the helper directly: libmagic names that
archive shape itself, so staging never escalates it on this build — but its
container verdicts differ between builds, which is exactly why the branch
should not be left loose.

* UN-1924 [MISC] Split the OOXML check to clear the complexity gate

Parsing the declarations took _identify_ooxml to cognitive complexity 18
against a limit of 15. Finding the declared main part and confirming what
relates it are separate questions, so they are separate functions now.

All eleven verification shapes still behave the same.

* UN-1924 [FIX] Refuse a DTD whatever encoding it is written in

The guard searched the raw bytes for ASCII markers, but the parser detects
the document's encoding — so a DTD written in UTF-16 was invisible to the
search and still expanded once parsing began. Confirmed: a 1.1 KB archive
carrying a UTF-16 entity chain was accepted as a document.

The member is now checked against every encoding the parser accepts, so the
DTD is found regardless of how it was written.

Mutation-checked: restricting the search back to UTF-8 fails the UTF-16
case.
…ble highlights (#2333)

* UN-4069 [FIX] Raise upload limit on prompt_output to 10 MB with clear error

Agentic table extraction output with highlight coordinates can exceed
Django's default 2.5 MB DATA_UPLOAD_MAX_MEMORY_SIZE, causing a generic
500 that retries uselessly. Use request.read() to bypass the global
limit on this view only, enforce a 10 MB cap, and return 413 with an
explicit size message so the error is actionable in the UI.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* UN-4069 [FIX] Update prompt_output tests for request.read() change

The view now uses request.read() instead of request.body, so mock
requests need read.return_value and META set alongside body.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* UN-4069 [FIX] Raise prompt_output upload limit to 50 MB

Dense tables with 1000+ rows and per-cell highlight coordinates can
produce payloads of 10-20 MB, exceeding the previous 10 MB cap.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* UN-4069 [MISC] Fix stale docstring: 10 MB → 50 MB

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Signed-off-by: harini-venkataraman <115449948+harini-venkataraman@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
…#2334)

* UN-1412 [FIX] Mirror API deployment errors into message.error

Every 4xx/5xx raised on the deployment execute and status endpoints now
also carries {"message": {"execution_status": "ERROR", "error": ...}},
the key successful and failed executions already use. The existing
type/errors keys and status codes are unchanged, so clients parsing them
keep working.

* UN-1412 [FIX] Self-review: keep the status read untouched, match client error text

On GET, message is the result payload and the published Python client
copies it out as extraction_result, so an error block there would read
as a result. Limit the message block to the execute endpoint and its
spec, and join details without attr, as the client already renders a
refused request.
@sonarqubecloud

Copy link
Copy Markdown

@greptile-apps

greptile-apps Bot commented Oct 15, 2026 •

Copy link
Copy Markdown
Contributor

via Greptile

RetriggerView in GreptileConfidence Score: 3/5

[High impact] Broad backend changes across permissions, adapters, and database migrations.

Fix the S3/MinIO upgrade path and image-page cleanup before merging.

Fix All in Claude CodeFindings

  1. P1 Existing storage workflows break ▶
  2. P1 Deleted documents leave page images ▶
Fix with agent prompt
### Issue 1
unstract/connectors/src/unstract/connectors/filesystems/minio/minio.py:158-163
Existing S3/MinIO connectors have no `bucket` setting, so this constructor now raises before browsing or workflow execution can start. Adding `bucket` manually still leaves saved `folders` and `outputFolder` values containing the bucket name. `DirFileSystem` then adds that name again, making workflows read or write the wrong folder.

The updated test fixture changes both values only for newly created workflows. Preserve the old form during upgrade, or migrate connector settings and their workflow paths together.

### Issue 2
unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py:83-86
Image mode stores pages under `extract/<stem>/pages`, but `delete_for_ide` finds related files using `<stem>.*`. That pattern never matches the new `<stem>` directory. Cleanup reports success and the document row is deleted, while its page images remain in permanent storage.

Remove the exact page directory when deleting a document, and cover this layout in the deletion test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

This PR brings the feature branch up to main, including queue cleanup, Redis settings, image-mode extraction, sharing controls, dependency updates, and frontend fixes.

  • OSS UN-4108-zid now points to the current main snapshot.

Diagram

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  Saved[Saved S3/MinIO settings] --> Bucket[Required bucket]
  Paths[Saved bucket-prefixed paths] --> Wrapped[Bucket-scoped filesystem]
  Bucket --> Wrapped
  Wrapped --> Workflow[Workflow reads and writes]
  PDF[Image-mode PDF] --> Pages[Permanent page directory]
  Delete[Delete document] --> Pattern[Find stem.extension files]
  Pattern --> Row[Delete document row]
  Pages -. Not matched by cleanup .-> Left[Page images remain]
Loading

Reviews (1) · Last reviewed commit: "UN-1412 [FIX] Mirror API deployment exec..." · Reviewed by Greptile

Comment on lines +158 to +163
self.bucket = (settings.get("bucket") or "").strip()
if self._REQUIRES_BUCKET and not self.bucket:
raise ConnectorError(
"A bucket must be configured for this connector.",
treat_as_user_message=True,
)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Existing storage workflows break

Existing S3/MinIO connectors have no bucket setting, so this constructor now raises before browsing or workflow execution can start. Adding bucket manually still leaves saved folders and outputFolder values containing the bucket name. DirFileSystem then adds that name again, making workflows read or write the wrong folder.

The updated test fixture changes both values only for newly created workflows. Preserve the old form during upgrade, or migrate connector settings and their workflow paths together.

Prompt To Fix With AI
This is a comment left during a code review.
Path: unstract/connectors/src/unstract/connectors/filesystems/minio/minio.py
Line: 158-163

Comment:
**Existing storage workflows break**

Existing S3/MinIO connectors have no `bucket` setting, so this constructor now raises before browsing or workflow execution can start. Adding `bucket` manually still leaves saved `folders` and `outputFolder` values containing the bucket name. `DirFileSystem` then adds that name again, making workflows read or write the wrong folder.

The updated test fixture changes both values only for newly created workflows. Preserve the old form during upgrade, or migrate connector settings and their workflow paths together.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

Comment on lines +83 to +86
reference = output_file_path or input_file_path
base_dir = str(Path(reference).parent) if reference else "."
stem = Path(reference).stem if reference else "document"
return str(Path(base_dir) / stem / ImageOutputConstants.PAGES_SUBFOLDER)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Deleted documents leave page images

Image mode stores pages under extract/<stem>/pages, but delete_for_ide finds related files using <stem>.*. That pattern never matches the new <stem> directory. Cleanup reports success and the document row is deleted, while its page images remain in permanent storage.

Remove the exact page directory when deleting a document, and cover this layout in the deletion test.

Prompt To Fix With AI
This is a comment left during a code review.
Path: unstract/sdk1/src/unstract/sdk1/adapters/x2text/constants.py
Line: 83-86

Comment:
**Deleted documents leave page images**

Image mode stores pages under `extract/<stem>/pages`, but `delete_for_ide` finds related files using `<stem>.*`. That pattern never matches the new `<stem>` directory. Cleanup reports success and the document row is deleted, while its page images remain in permanent storage.

Remove the exact page directory when deleting a document, and cover this layout in the deletion test.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.