Repository navigation
Tags: Zipstack/unstract
Tags
UN-4480 [DOCS] Platform audit README: cloud clone, Agentic Studio ada… …pters and onboarding are now recorded (#2368) * UN-4480 README: cloud paths now record resource create/delete Clone, the Agentic Studio connector views and frictionless onboarding record resource.created/deleted (Zipstack/unstract-cloud#1880). Agentic Studio projects and lookups still record only their sharing. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4480 README: agentic projects and lookups also record co-owner changes Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: name the clone summary's project and add Agentic Studio create/delete (UN-4480) The clone summary names the source project, not the clone, so say so. And since cloud #1881 (UN-4483) Agentic Studio projects and lookups record create and delete too, so the line saying they don't is out of date. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4251 [FEAT] Prometheus /metrics endpoint on the backend for queue,… … execution and batch metrics (#2378) * UN-4251 Expose Prometheus platform metrics from the backend at /metrics Adds a platform_metrics app serving GET /metrics in Prometheus text format: queue depth by state and oldest-message age (pg_queue_message), active and recently-terminal execution counts (workflow_execution), in-progress and stuck batches (pg_barrier_state), plus last-refresh and refresh-failure metrics per group. Known queues and statuses are zero-filled. Each group's snapshot is cached in Redis for 15s behind a non-blocking lock, so one backend pod refreshes a group per interval however many are scraped. A failing group serves its last values and counts the failure without affecting the others; with Redis down each pod refreshes at most once per TTL. Mounted at the URL root next to /health, outside every public ingress prefix, with an optional bearer token (PLATFORM_METRICS_TOKEN). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Count recent completions by finish time; harden metric cache fallbacks - Recent terminal executions are now filtered on created_at + execution_time (stamped on the terminal transition) instead of modified_at, which later writes such as result acknowledgement bump. - A Redis failure on the re-read under the lock now serves the cached snapshot through the failure path instead of dropping the group. - A failed Redis write is counted as a refresh failure, and the newer local copy is preferred over the older Redis value, so the pod does not refresh the DB on every scrape while writes fail. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Share refresh-failure totals across pods and harden the metrics cache - Count refresh failures in Redis (HINCRBY + HGETALL in one transaction), so every pod reports the same monotonic total; omit it while Redis is down - Add unstract_metrics_cache_up to make a Redis outage visible - Fall back to modified_at as the finish time for swept executions - Size the refresh lock from each group's statement count and query timeout - Treat an unreadable cache entry as missing; per-group local locks - Note in HELP text that queue metrics cover the PG transport only Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Leave stale queue counts out, back off failed refreshes, add ?group= From review: - Queue counts older than PLATFORM_METRICS_QUEUE_MAX_STALENESS_SECONDS (60s) are left out of the scrape, so dashboards show a gap and KEDA falls back instead of scaling on a frozen value; the refresh timestamp stays visible - A failed refresh holds the lock for one TTL (and the Redis-less path remembers its attempt), so a struggling DB is retried about once per TTL - ?group=<name> serves only the named groups, so a queue-only reader never waits on the executions refresh; unknown names return 400 - Add the notification sub-queues to the default PLATFORM_METRICS_QUEUES Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Don't echo the exception text in the unknown-group response Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Type the execution-time duration explicitly in the finish-time expression Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Bound Redis waits on the metrics endpoint The shared default pool sets no socket timeouts, so a Redis that accepts connections but stops replying would hang a scrape and its worker thread. The metrics endpoint now uses its own pool, copied from the shared one (Sentinel included), with PLATFORM_METRICS_REDIS_TIMEOUT_SECONDS (1s) for connecting and reading. A failed Redis call also skips Redis for 5s, so a stall costs a scrape at most one timeout rather than one per group. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL * UN-4251 Bound Sentinel discovery in the metrics Redis pool Finding the master queries the sentinels on their own clients, which had no timeouts, so a stalled sentinel could still hang a scrape. The metrics pool now gets its own Sentinel manager with the same addresses and settings plus the metrics timeout. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016Hov58G5waUqpMHz9P6FmL --------- Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
UN-4524 [FIX] Make HITL rules scrollable in API connector modal (#2389) * fix: make HITL rules scrollable in API connector modal The modal body has a fixed height with overflow hidden, and the API branch wrapped RuleEngine in a plain div, so rule groups past the first were clipped on smaller screens. Wrap it in a flex child that scrolls, keeping the read-only class on an inner div so wheel scrolling still works when pointer events are disabled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CeVWzdft94BfE61ZSkNFM * fix: drop non-load-bearing flex rules from HITL scroll wrapper overflow-y: auto alone makes the wrapper shrink to fit and scroll; flex: 1 and min-height: 0 changed nothing under mutation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017CeVWzdft94BfE61ZSkNFM --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
[MISC] Pin GitHub Actions to commit SHAs and let Dependabot update th… …em (#2390) * ci: pin third-party GitHub Actions to commit SHAs and add Dependabot Pin the 12 action uses SonarCloud flagged under githubactions:S7637 to the commit their tag resolves to today, with the exact release as a comment, and add a github-actions Dependabot config (weekly, one grouped PR) so the pins stay current. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: keep Dependabot titles colon-free and record its CI precondition A prefix ending in "]" makes Dependabot add a colon, which the changelog parser does not accept; a trailing space suppresses it. Also note in the config that Dependabot PRs need Docker Hub credentials before ci-test passes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: correct the Dependabot config comments Name the e2e job and the read-only token as well as the missing secrets, and drop the changelog claim: generate-notes renders the author as @dependabot[bot], which update-changelogs' line pattern does not match. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * ci: let Dependabot PRs pass ci-test and pin setup-biome Dependabot-triggered runs get no Actions secrets and a read-only token, so skip the Docker Hub login (pull anonymously, as forks do) and the sticky PR comment for dependabot[bot]. Pin biomejs/setup-biome to v2.7.1 and give the two major-only pin comments in ci-test.yaml their exact versions. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4284 [FEAT] Per-user notification inbox with live unread count (#2365 ) * UN-4284 [FEAT] Add the notifications inbox with the resource-shared event New inbox app: one inbox per user per organization, listed newest first with the unread count, with mark-one-read and mark-all-read. Sharing a resource with a user, directly or through a group, adds one item for each newly given user, linked with the global search route shape. The item shape is provisional until agreed with PB-T18. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FEAT] Add an inbox item for the owners of a failed run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FEAT] Push the inbox unread count to the user's socket room Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FEAT] Purge inbox items after 90 days on the PG scheduler Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Link a failed-run inbox item to the run Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address review round 1 on the notifications inbox Close the socket room hole (a cookie shaped like a user room), keep the failed-run event off the socket on the status-write path, require authentication on the inbox views, skip share recipients who already had access, withdraw a failed-run item when ERROR is corrected to a clean run, purge in batches on a created_at index, type the route and open the kind in the schema, and document the inbox's route keys and the purge's dependency on the PG scheduler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address review round 2 on the notifications inbox Withdraw a failed-run item on any clean finish (an ERROR run can restart through EXECUTING), indexed on (organization, dedupe_key); publish live counts from one background thread with a bounded backlog so a broker outage never holds a request; insert inbox items in a savepoint and require an organization; notify nobody when prior access cannot be read; make the purge view non-atomic and log its count; and test the purge path, the migration indexes, org-wide shares, the PG transport and title truncation. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address review round 3 on the notifications inbox Keep one failed-run item per owner and resource, refreshed to the latest run and marked unread again, so a resource that keeps failing cannot flood an inbox; withdraw it only while it points at the run that finished clean, under a row lock so a correction cannot race the failure callback. Fix the list response schema (no second paginated envelope), refuse socket cookies not shaped like a session key before any lookup, log a run whose pipeline no longer exists, keep the push tests off the real publisher, and document the purge in converge_pg_scheduler. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address review round 4 on the notifications inbox Leave a failed-run item alone when it already shows this run or a newer one, so a repeated or refused status write cannot mark it unread and a late failure of an older run cannot point it back; lock the execution row only to record a failure, not on every clean finish; upsert recipients in pk order; skip runs with no organization; keep direct share notifications when the group lookup fails; warn once per stretch of dropped pushes; test that the unique constraint reaches a migration; and correct the docs on created, retention, push ordering and the converge release warning. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address review round 5 on the notifications inbox Serialise writers of one failed-run item on a transaction-scoped advisory lock, so two runs of a resource cannot race the newest-run check and a clean withdrawal cannot deadlock with a refresh; mark an item read only if it was not refreshed after the user loaded it; declare the list's 404 for a page past the end; give the push=False comment its real reason; and test every OSS share kind through the real event, index fields in the migrations, the warn-once reset and the failed-run text. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Address the last review round on the notifications inbox Skip the item lock on clean runs that have no item; judge the newest run across every owner's row; re-check the age when the purge deletes a batch; print the inbox.purge release note only with --periodics; pin the failed-files title exactly, the publisher thread's start and target, and the publisher's error handling; and document read-all, the 404 causes, the deleted-pipeline fallback and the undispatched-sweep gap. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Label the lock subtest by status value so xdist can serialise it Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Enforce an organization on every inbox item NULLs are distinct in the (organization, user, dedupe_key) unique constraint, so a NULL organization would escape it. A check constraint now refuses one in the database, beside the runtime check in notify(). The dedupe_key column comment now says that share items get a key per share action, while a failed run's key names its source. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Tell the client when a read finds a refresh it has not seen POST notifications/inbox/<id>/read/ takes an optional {"created"} with the item's created as the client listed it. When a later failure has refreshed the item since, the item stays unread and the response is 409 with the item as it is now and the unread count, so the client can update its row and badge. Without created, the item's current version is marked. The created_at match used to cover only a refresh that committed inside the same request. read-all now pushes the count only when it marked something. Tests cover read and read-all against another organization's items and a direct share that skips users who already had access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Keep older failures, warn on ownerless runs, push failed-run counts - A clean correction of the run an item shows now hands the item back to the newest earlier failed run of the same source within the 90-day retention, keeping its read state, and deletes it only when there is none. Before, it deleted the item and an owner who had not opened the inbox lost the earlier failure. - Owners who are no longer members of the run's organization get no item. A resource with no owners left logs a warning instead of adding nothing silently. - The internal batch status endpoint writes status without update_execution, so it now registers the same on-commit hook through notify_on_terminal_status. - Failed-run refreshes, hand-backs and withdrawals push the live count; the push=False path is gone. - The publisher calls sio.emit itself, so a failed push is logged with its room and traceback. Dropped pushes are counted under the lock, and the first push accepted afterwards logs how many were dropped. - The run id a failed-run item shows is read and written in one place. - had_access is required; route and kind are typed. - A socket whose session has no signed-in user logs why it joined no user room. - Tests: runs get explicit start times, the non-terminal test reads the registered callbacks, end-to-end writes through the internal update_status and batch endpoints, a lagging former owner's row, and comment wording from review. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FEAT] Lock the earlier failed run before handing an item back to it A clean run's hand-back chose the earlier failed run unlocked. If that run's clean correction committed in the gap, its callback saw the item still showing the newer run and did nothing, and the hand-back then pointed the item at a clean run. The candidate is now row-locked and re-read; one corrected first is skipped, and a correction that comes later waits and then hands the item on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FEAT] Re-read a clean run under its row lock before withdrawing its item The clean branch checked the run's status before taking the item lock and never again. A run that failed again while the callback waited kept its item through the new failure callback, then lost it to the late clean one. The run is now re-read under the row lock and its item is handed back or withdrawn only if it is still clean. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4284 [FIX] Check the session like Django before a socket joins its user room The inbox socket read the user ID straight from the session, so a deactivated user, or a session from before a password change, still joined user:<id> and received unread counts. It now applies the checks django.contrib.auth.get_user applies to an HTTP request: a configured backend that still returns the user, and a matching session hash (fallback secrets included). It never flushes or rotates the session. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4481 [FEAT] Show each organization's home region in the organizati… …on list (#2370) * UN-4481 [FEAT] Show the home region for organizations copied from another region When the identity provider metadata names the region an organization was created in, the organization list shows that region. For a local copy of an organization from another region it leaves out member_count, which on this deployment covers only the people who opened the copy. Organizations without the stamp keep the current behaviour until they are back-filled. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4481 [FEAT] Read the region stamp only where the deployment has regions Review fixes: OSS and on-prem ignore a region key in the provider metadata, so the contract that they never send region holds; a stamp that is not a non-empty string counts as no stamp. Tests for both, and a corrected test comment. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4481 [FIX] Compare region stamps without case or padding Trim and uppercase the metadata stamp and settings.REGION before comparing, so "us" or " US " on the home deployment no longer hides the member count. Log a stamp that is present but not a non-empty string. Pin a mixed list, a this-region organization with no data here, and non-dict metadata. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4481 [DOCS] Say what the list does for organizations without a stamp The schema, ReadMe and module docstring said member_count is absent for an organization that belongs to another region. The code can tell only from the metadata stamp, and ignores it where REGION is unset. Say that, and that an unstamped copy of another region's organization sends the copy's count. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4283 [FEAT] Add role, member count and region to the organisation … …list (#2361) * UN-4283 [FEAT] Work out role, member count and region for a list of organisations Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [FEAT] Add role, member count and region to the organisation list Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [FIX] Leave API key accounts out of the member count and pin the old payload Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [FIX] Settle the review findings on the organisation list contract - Document the 412 body ({domain, code}) and every cause of a 412 (R1) - Say absent means unknown, since a failed lookup returns the same rows (R2) - Log the user and organisation count when the details fail (R3) - metadata can be null in cloud too; it is required in the schema (R4, R9) - Pin absent-not-null and the wire key order, including an organisation this deployment has no data for (R5, R6) - Count an organisation with no people as 0 (R7) - Build rows from attributes, not asdict, and type the input (R8) - member_count is member records, not signed-in members (R10) - Correct the user_organizations docstring (R11) - Run the lookup in a savepoint when inside a transaction (R12) - Test the endpoint through the URL and auth middleware Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [FIX] Pin the 412 path, the session organisation and the empty list - Test the 412 body and logout for known and unknown listing errors (R16) - Endpoint tests sign in to an organisation, as the browser does, and use OSS auth with no shared-cache write (R17, R18) - Anonymous call: assert the body and that nothing was listed (R19) - Fallback rows keep metadata in fourth place (R20) - A user with no organisations lists none (R21) - Other members hold a role the caller never has (R22) - Cover the no-transaction branch of the savepoint guard (R13) - 412 body: domain and code are required; Protocol says metadata is read (R23, R24) - ReadMe: on-prem settings don't read REGION (R15) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [MISC] Tidy the organisation list tests Name the members-page rule the count copies, look up the operation id directly, and share the API key account fixture. No behaviour change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [DOCS] Say the organisation list schema is not in the published spec Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [FIX] Pin REGION in the helper tests and correct two contract notes - WithMemberDetailsTests starts from REGION=None, so the exact key-list test no longer depends on the deployment's settings - Drop the test docstring's claim that the contract is published - Say beside the 412 serializer why its fields are required Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4283 [DOCS] Say region is the answering deployment's Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4232 [FEAT] Agent-KV API serving the table extractor only (#2317) * UN-4232 [FEAT] Agent-KV API serving the table extractor only Tasks 2 and 3 of docs/superpowers/plans/2026-10-06-table-extractor-api-carveout.md. Stands the Agent-KV API up on a branch cut from main, routing `table` and nothing else, so table extraction reaches customers without waiting for the KV engine, the schema codegen path or the hardened sandbox to stabilise. WHAT CAME ACROSS The `agent_kv` Django app and its URL/settings mounts, the AGENT_KV storage type, the `agent_kv_callback` queue and its ide_callback tasks, the scheduler's sweep/TTL beat tasks, the internal API client, the webhook notifier, and the compose/worker wiring for all of it. FOUR SUBTRACTIONS, EACH REVERTIBLE IN ONE LINE 1. `kv` is out of `EXTRACTOR_ROUTES`. `SUPPORTED_EXTRACTORS` is derived from that table, so a `kv` submit now returns 400 at the serializer. This is the load-bearing line: leave `kv` routable with no `agentic_kv` plugin deployed and the submit returns 202, dispatches to `celery_executor_agentic_kv`, and sits in DISPATCHED forever -- no error at the producer, nothing in any log. `V1_EXTRACTOR_NAME`, `STAGE_NAMES` and `KVOptionsSerializer` all stay in the tree, dormant and still tested. 2. `celery_executor_agentic_kv` is out of both compose fleets and run-worker.sh, for the same reason. 3. `/validate` is unregistered. It compiles `kv` key schemas and nothing else; publishing it for an extractor this deployment refuses is an incoherent contract. The view class and `unstract/agent-kv-schema` stay. 4. No sandbox: not the worker, not its compose service, not PG_ROLE_SANDBOX, not its SANDBOX_* env block, not its registry/enum entries, not its coverage target. `agentic_table` keeps running generated code in the executor pod exactly as the IDE table path does in production today; UN-4215 is the fast-follow. worker-unified.Dockerfile is reverted to main: its UID pin exists so the sandbox pod can assert runAsNonRoot with a numeric UID, and its comments cite a chart template this PR does not ship. TESTS Two guards were INVERTED rather than deleted, which is the point of them: * `test_queue_consumer_wiring.py` used to assert the KV queue HAS a consumer; it now asserts no fleet advertises it. Mutation-checked -- re-adding the queue to compose fails the guard. * `test_registry.py` was entirely sandbox assertions; it now asserts the ide_callback worker subscribes to `agent_kv_callback` and that both terminal callbacks route there. A job whose callback is unconsumed runs to SUCCESS and then sits in RUNNING forever, result unpersisted and slot unreleased. Backend suite re-pointed from `kv` to `table`. `KVOptionsSerializer` is now tested directly rather than through a submit -- `kv` is refused at `validate_name` before any options validator runs, so driving those rules through `SubmitSerializer` would have asserted nothing while still passing. E2E: the generic scenarios (auth, cancel, concurrency slot release, delete, page cap, sync-wait, webhook, 429, unreadable PDF, Excel) re-pointed at the table extractor; the five kv-engine scenarios dropped (happy path -- the table twin already covers it, /validate, calculations, hostile calculations, and the KV document cache, which the table path does not have until spec step 5). Green: 191 backend agent_kv, 65 workers, 42 agent-kv-schema, 1 filesystem. ruff 0.3.4 (the pinned pre-commit version) clean on check and format. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Lock the cross-repo stage name; correct the API docs Task 5a and Task 6. THE STAGE NAME `TABLE_STAGE_NAMES[0]` (here) is the stage name the status endpoint will SHOW. `STAGE_TABLE_EXTRACTION` (cloud `agentic_table/src/api_binding.py`) is the name the executor actually SENDS. Nothing enforced their equality: StageReportView persists whatever arrives, and `_status_document` then filters a job's recorded stages through the list here. On drift the job completes normally, bills normally, and every status response returns an empty `stages` array. No test on either side could catch it -- each repo is internally consistent on its own -- and from a client's seat it reads as "the API is broken". Until now the only thing tying the two was a pair of comments. Both halves now assert the literal and name the other's file. A literal, not an import: the repos are separate checkouts that meet only in the merged tree, so an import would pass vacuously exactly where drift gets introduced. DOCS `docs/agent-kv-api.md` said the `name` field takes "`kv` or `table`". It now says `table` is the only supported value, with a note explaining why a `kv` submit returns 400 -- otherwise that refusal reads as a bug -- and that the wire format does not change when `kv` ships. The `/validate` section is removed along with its route, and every reference to it, and sections 7-13 are renumbered to 6-12 with their anchors and cross-links. Section 2 (the `kv` schema language) is kept but now opens by saying the extractor it describes is not routable here: the compiler ships and the language is frozen, so an integration can still be written against it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [MISC] Clear the six SonarCloud cognitive-complexity findings All six were `python:S3776` on code this PR introduces to `main`. Every one is a pure extraction -- no behaviour change, and the existing suites are the proof (193 backend, 42 schema, unchanged and green throughout). execution_views.py SubmitView.post 16 -> 8 internal_views.py FinalizeView.post 23 -> ~7 kv_schema.py _walk 25 -> ~9 compile.py compile_schema 20 -> ~4 constraints.py _aggregate 18 -> ~9 constraints.py _truth 18 -> ~6 The extractions follow the shape each function already had: * `SubmitView.post` -- `_subscription_denial`, `_dispatch_or_fail` (which folds the two identical except-branches into one exit) and `_sync_wait_response`. * `FinalizeView.post` -- `_complete`, `_fail` and `_drop_staged_input`, which flattens a four-deep nest into a dispatch and one guard. * `_walk` -- `_walk_array` and `_append_leaf`, one per node kind; the function now only does the depth/shape guards and dispatches. * `compile_schema` -- `_enforce_shape_caps`, `_enforce_leaf_caps` and `_validated_constraints`, split along the three things it was checking. * `_aggregate` -- `_parse_agg_call` takes the fail-closed call validation and the path split. * `_truth` -- `_compare_chain` takes the chained-comparison loop. Verified under flake8-cognitive-complexity at `--max-cognitive-complexity=15`: clean across `backend/agent_kv/` and `unstract/agent-kv-schema/src/`. The two functions that still measure 14-15 on that checker were analysed by Sonar in this same PR and not flagged, so they score at or under the threshold on its algorithm; they are untouched rather than churned for no gain. Also drops an orphan missed in the e2e re-point: `_CALC_ENV_GATE` and `_skip_unless_calculations_declared` outlived the two calculation scenarios they gated, and referenced a sandbox worker this PR does not ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] DELETE on a lost terminal race blanked the winner's result_ref Greptile P1 on PR #2317. Real, and it is data loss. The race: DELETE reads a RUNNING job whose `result_ref` is still "", the finalize callback wins the guarded UPDATE in between and writes COMPLETED plus a real `result_ref`, and `mark_terminal` here returns False. Execution then continued against the STALE in-memory job. `delete_job_files` reports an already-empty ref as "cleared" -- correctly, there was nothing to delete -- so the stale `result_ref: ""` came back in `cleared`, and `job.save(update_fields=cleared)` wrote "" over the ref the callback had just persisted. The outcome: a job that reports COMPLETED, a result that 404s (`JobResultView` reads COMPLETED-without-a-ref as swept), and the result object orphaned in the bucket with nothing pointing at it -- TTL cleanup selects on `result_ref > ""`, so a blanked row never comes back as a candidate. Fix: re-read the row when the terminal race is lost, before touching files. Deleting is still the caller's intent; it just has to act on the refs that actually exist rather than on a copy that predates the winner's write. Only on the lost-race path -- winning means nothing else wrote to the row, so the in-memory copy is current and the extra query would be waste on the common path. Both directions are asserted. Mutation-checked: replacing the refresh with `pass` fails `test_delete_refreshes_the_job_when_it_loses_the_terminal_race`. One existing test needed a stub: this suite touches no database, so the new read is mocked in `test_delete_does_not_release_the_slot_when_it_loses_the_terminal_race`. 195 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Four review findings: gate fail-open, unscheduled sweeps, page cap, cancel webhook All four traced and confirmed before changing anything. 1. SUBSCRIPTION GATE FAILED OPEN (`execution_views.py`) A plugin without `service_class` skipped the check and continued to dispatch. Reachable only on a cloud image predating the gate -- but the admitted request spends money, and this route's URL carries no org segment, so `SubscriptionMiddleware` cannot catch it downstream either: a mixed deploy ran unmetered paid work with nothing anywhere enforcing entitlement. Now refuses, via a new `SubscriptionGateUnavailable` (503). Deliberately not a 402 -- the subscription was never evaluated, and reporting it as denied would send an operator to the billing system for what is an image-pairing problem. `test_plugin_without_a_service_class_still_proceeds` is inverted to `..._is_refused`; it pinned the old behaviour as a contract, so it had to change rather than be deleted. 13 other submit tests now carry an admitting gate. 2. NOTHING SCHEDULED THE SWEEP OR THE TTL CLEANUP (migration 0004) Both tasks registered, both endpoints live, no `PgPeriodicTask` row anywhere -- the module docstring said an operator registers them by hand, which means they never ran. So an OOM-killed executor left its row non-terminal forever (`link_error` never fires for a killed process) holding a concurrency slot until the 6h Redis TTL, and `AGENT_KV_RESULT_TTL_DAYS` was advisory: every customer document retained indefinitely. Data migration following the dashboard_metrics precedent (0004-0006): same table, `update_or_create` so a re-run is idempotent, `pg_owned: False` so applying it does not itself start firing them. Sweep every 10 minutes (a stuck job holds a slot); TTL cleanup daily at 03:17, off the hour and off the metrics cleanups so two bulk deletes do not share a tick. The new test pins the migration's `task_name`s against the worker's registered wire names as literals -- an import would pass vacuously in the backend venv, which is exactly where the two sides drift. 3. PAGE CAP COUNTED THE DOCUMENT, NOT THE REQUEST (`execution_serializers.py`) Asking for pages 1-5 of a 400-page PDF was refused against a 100-page cap, despite requesting five pages of work. The cap now bounds the selected range. `pages_total` is unchanged -- it is what metering and the status document report -- and a new `pages_selected` carries what the cap compares. Also rejects a `page_start` past the end of the document, which previously selected nothing and billed for a job over zero pages. 4. CANCELLATION NEVER SENT THE DOCUMENTED WEBHOOK (`dispatch.py`, both cancel paths) Cancellation does not reach finalize, so no webhook fired; and a late executor callback could not send one either -- it loses the terminal guard, and `_maybe_webhook` correctly declines a non-fresh finalize. A caller who supplied `webhook_url` was simply never told, against docs §8. New `agent_kv_cancelled` worker task, routed on the existing callback queue, and enqueued by the backend from both `JobCancelView` and the DELETE-side cancel. Guarded on `won`, which is what makes the two paths mutually exclusive: a cancel that lost means a finalize won and will send. Exactly one path owns the notification. Best-effort enqueue -- the job is already cancelled and the caller already has their 200, so a lost notification logs rather than failing their request. The SSRF waiver moved into one `_send_webhook` helper so the cancel path cannot drift from the finalize path on what it permits. 211 backend tests pass (up from 195), plus ide_callback 18, registry 3, scheduler 7, task-imports 4, queue wiring 9. ruff clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 1.2 — the table API operation had no billing backstop `execute_extraction` logs when an op in `_LLM_BEARING_OPS` finishes successfully having emitted no usage records. That log line is the ONLY signal that a run's billing produced nothing: the cloud `flush()` returns an empty list rather than raising, so total loss of a job's usage rows is otherwise indistinguishable from a job that legitimately made no LLM calls. `table_extract_api` was absent from the set, so the entire Agent-KV table path shipped with no backstop at all — every table job drives two LLMs, and a run that lost all of its billing rows would have produced zero log lines. Adds the op, and a test file pinning the set against a declared list of paid operations in BOTH directions: a new paid op missing from the set fails, and an op added to the set without being declared fails too. One-directional would let the pair drift into a superset and quietly stop meaning anything. Also documents on the set itself that keeping a paid op out of it is a missing alarm rather than a missing log line — the next person adding an operation is the one who needs to know that. Note `kv_extract` is deliberately NOT added: `kv` is not routable on this deployment, so declaring it here would assert a backstop for a path that cannot run. It belongs with the branch that makes the extractor routable. Companion: the cloud half of 1.2 hardens `flush()` itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 2.1, 2.2, 2.4 — double-validated identity, an unrouted API, a swallowed finalize 2.1 — EXTRACTOR IDENTITY WAS DECIDED TWICE, UNDER TWO DIFFERENT VALUES `validate_keys` branched on raw `initial_data["name"]`; `validate_name` saw the value DRF had already trimmed (`CharField.trim_whitespace` defaults True). So `{"name": " table "}` passed the name check as `table` and then took the **kv** branch for keys, never running `TableKeysSerializer`. That is not merely untidy. A kv-shaped payload like `{"target_table": {"description": "x"}}` compiles cleanly as a KV schema, so the submit returned **202** and dispatched to `agentic_table` with a `target_table` that is a dict rather than the string the binding requires -- the document staged, the job billed, and it failed at the executor. Keys validation moves into `validate()`, where `data["name"]` is trimmed and already checked against `SUPPORTED_EXTRACTORS` exactly once, behind a `_KEYS_SERIALIZERS` table mirroring `_OPTIONS_SERIALIZERS`. An extractor absent from it falls through to the schema compiler, which is `kv`'s actual contract. Mutation-checked by restoring the raw-name lookup. 2.2 — /agent-kv/ WAS NEVER ROUTED THROUGH THE COMPOSE STACK traefik's backend rule matched `/api/v1`, `/deployment` and `/public`; the frontend rule is the negation of exactly those three. `base_urls.py` mounts the API at `/agent-kv/`, so every request through the stack was served by the SPA's nginx and never reached Django. The e2e lane could not catch it: `conftest.py` talks to `UNSTRACT_BACKEND_URL` (port 8000) directly, bypassing traefik. So the API was unreachable exactly as a customer would reach it, and green. Both rules updated. Three guards added to `test_queue_consumer_wiring.py` -- the established home for "configured-looking but unreachable" wiring. The third asserts the RELATIONSHIP rather than a hardcoded prefix: every prefix routed to the backend must be excluded from the frontend, so the next mount point cannot be added to one side only. Mutation-checked. 2.4 — A FAILED FINALIZE WAS REPORTED AS A SUCCESSFUL CALLBACK `agent_kv_error` caught, logged and returned None. The consumer records that as SUCCESS, deletes the message and moves on: no retry, no dead letter, no failed-task record. Since this task is the SOLE terminalizer of a failed job, a momentary backend blip during finalize stranded the job in RUNNING until the sweep terminalized it with "Job timed out" -- overwriting the real executor error, which existed only in the log line above. Now `bind=True` with backoff retries, raising on exhaustion, matching its sibling `agent_kv_complete` and the `process_batch_callback_api` precedent in `workers/callback/tasks.py`. Verified first that the PG transport honours task-level autoretry -- `queue_backend/pg_queue/consumer.py` relies on it explicitly for the executor task. `test_finalize_raising_is_swallowed_and_logged` asserted `result is None`, pinning the silence as a contract. Inverted rather than deleted: that assertion is exactly what a regression would restore. Mutation-checked. 214 backend tests (up from 211), ide_callback 20, queue wiring 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 2.5, 2.9, 2.11, 2.12 — an over-subscribed concurrency cap and three silent failures 2.5 — CANCEL RELEASED THE SLOT WITHOUT STOPPING THE EXECUTOR `mark_terminal` excludes only TERMINAL, so cancelling a DISPATCHED/RUNNING job returned won=True and the slot was released immediately. Nothing revokes a running executor -- `job.task_id` is written at dispatch and never read again -- so the engine kept running, kept calling LLMs and kept billing while its slot was handed to the next submit. Submit-then-cancel in a loop therefore ran arbitrarily many concurrent extractions against a ceiling of AGENT_KV_CONCURRENT_LIMIT, all paid for. This is a cost bug, not just a bookkeeping one. Release is now narrowed to jobs that never reached an executor, via a `_never_dispatched` helper applied at both cancel sites. Narrowing loses nothing: a mid-run cancel's slot is released by FinalizeView's `finally` when the callback lands, and release() is idempotent. `test_delete_on_running_job_releases_the_concurrency_slot` asserted the release -- it pinned the bug. Inverted rather than deleted, with the positive case (never-dispatched DOES release) added alongside, plus a unit test for the PENDING-but-dispatched edge: post-enqueue bookkeeping can fail after the task is queued, leaving a row that reads PENDING while an executor runs, and releasing that would over-subscribe exactly as before. 2.9 — maintenance.py HAD NO LOGGER AT ALL Not "logged too little": no `import logging`, no logger, in the module that can terminalize a thousand jobs as FAILED in one call. A backlog of stranded jobs was indistinguishable from a quiet, healthy system. Both entry points now report what they did, at WARNING when the counts are non-zero (jobs WERE stranded and slots WERE held) and INFO when there was nothing to do. The scheduler proxies log their result too -- counts alone cannot distinguish "ran and found nothing" from "never ran", which is precisely the state this task pair shipped in. Also pinned `retained` in the TTL return shape. The test asserted `{"cleaned": 2}` while the endpoint returns `{"cleaned": N, "retained": M}`, so the field that counts files it could NOT delete was outside the contract. 2.11 — `except Exception: pass` LOST THE REAL EXECUTOR ERROR An empty catch with no log, around the result-backend lookup. The common case is a kombu deserialization failure: the executor raised a cloud-plugin exception class not importable in the OSS ide_callback image, so the error exists and this process cannot read it. The job was then finalized with "Executor failed without an error message" -- the exact useless error the lookup exists to avoid -- and the cause was unrecoverable even from logs. 2.12 — AN UNKNOWN JOB AND A DUPLICATE RETURNED THE SAME 200 FinalizeView and StageReportView both answered a byte-identical 200 no-op whether the job was already terminal (ordinary) or no row matched the job/org pair at all (never ordinary). The module imported `logger` and never called it. That second case is what an org-slug-vs-FK-pk mix-up looks like, and dispatch.py documents that exact confusion shipping once already. If it recurs, every finalize is a silent no-op, every job stays non-terminal and is reaped as "Job timed out": a 100% failure rate presenting as timeouts, with nothing anywhere. Both views now log the unknown-job case and return a `reason` field so the worker can tell them apart. Three finalize tests asserted the exact response dict and gained the new key -- an additive contract change, not a loosened assertion. 221 backend tests (from 218), ide_callback 20, scheduler 7, queue wiring 12. Every fix mutation-checked by reintroducing the original behaviour. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 2.6, 2.7, 2.8, 2.13, 2.14, 2.15 — correctness group 2.14 — THE SWEEP'S NULL BACKSTOP COULD NEVER FIRE UNDER BACKLOG Phase 2's second Q arm exists to recover rows whose post-enqueue bookkeeping was lost -- they have `dispatched_at IS NULL`. But the batch was `.order_by("dispatched_at")[:500]`, and Postgres sorts ascending NULLS LAST, so with 500 non-NULL stuck rows ahead of them those rows were never selected. The backstop could not fire in exactly the situation it exists for. Now orders by `Coalesce("dispatched_at", "created_at")`. The test asserted `order_by("dispatched_at")` -- it pinned the bug -- so it is inverted with the reasoning recorded rather than deleted. 2.15 — TTL CLEANUP DELETED THE INPUT OF A RUNNING JOB The candidate query filtered on `expires_at` and a non-blank ref, with no status filter, so a job still RUNNING past its TTL had its staged input deleted out from under the executor. The TTL is a retention policy for finished work, not a kill switch for running work. Reachable whenever a job is stuck non-terminal longer than AGENT_KV_RESULT_TTL_DAYS -- which is what the sweep's phase 2 exists to catch, and which until this branch was never scheduled at all. TERMINAL-only now. The sweep terminalizes stuck jobs first; then this cleans them. The mocks follow the new three-filter chain and the status predicate is asserted, so it cannot be dropped silently. 2.13 — UNTRUSTED COUNTERS COULD REWRITE THE STATUS DOCUMENT `_status_document` builds each stage entry as `{"name": name, **entry}` with the spread LAST, so a persisted `name` counter overrode it -- renaming the stage in every later status response and breaking the `if name in stages_json` filter that decides which stages are shown. One counter could make a job's progress disappear. `name` is now reserved alongside `status`/`seconds`. Two more on the same endpoint: `seconds` was taken verbatim, so a dict or list was persisted and echoed back to the caller (now type-checked, with `bool` excluded since it subclasses `int`); and `stage` is varchar(32), so a longer name was an unhandled 500 and `job.stages` could grow unbounded distinct keys. 2.7 — THE `extractors` FILE PART WAS MATERIALISED BEFORE ANY SIZE CHECK The cap lives in `validate_extractors`, i.e. after the read, and `DATA_UPLOAD_MAX_MEMORY_SIZE` excludes file-typed parts -- so a 500 MB part was read in full (plus up to 4x again for the `str`) before being rejected at 256 KiB. One request per worker process OOMs the pod: a cheap denial of service. Now reads `limit + 1` bytes. Also `errors="strict"`: a latin-1 key name used to decode to U+FFFD and then compile cleanly, so a malformed payload became a job running against a schema the caller never wrote. 2.8 — A DB FAILURE AFTER STAGING ORPHANED THE UPLOAD PERMANENTLY If `stage_input` succeeded and `job.save()` raised, the upload existed with no row to carry its ref -- and `run_ttl_cleanup` selects candidates from AgentKVJob rows, so an object with no row is structurally unreachable by every cleanup path there is. Customer data in the bucket that nobody can find or delete. Now deleted on that path, with its own failure logged rather than swallowed. 2.6 — AN E2E ASSERTION AND THE DOCS BOTH ASSERTED THE WRONG CASE Both claimed the cancel 409 body carries the RAW uppercase enum, and the docs pre-empted correction with "not a typo in this document", citing a unit test as proof. `JobCancelView` returns `job.status.lower()`, and the cited test asserts `{"status": "completed"}`. The e2e lane had never run, so the wrong assertion was never executed. Both corrected, and the docs now record that they were wrong rather than quietly flipping. 228 backend tests (from 221). 2.8, 2.14 and 2.15 mutation-checked. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Six Greptile findings on this branch's own new code All six land on code added earlier in this branch, and all six are real. Two of them are consequences of narrowing the slot release, which is worth stating plainly: fixing the over-subscription opened the opposite hole at both ends. 1. CANCELLED BEFORE ENQUEUE -> PAID WORK WITH NO SLOT HELD (P1) A cancel can land between the submit's `job.save()` and `dispatch_job`'s enqueue. The cancel sees a PENDING, never-dispatched row, so it terminalizes it AND releases the slot -- correctly, nothing had been dispatched. But dispatch then enqueued anyway: paid work for a job the caller already cancelled, with its slot already handed to the next submit, so the concurrency ceiling is bypassed too. `dispatch_job` now re-reads the row immediately before enqueueing. Re-read, not `job.status`: the in-memory row predates the cancel by construction. 2. CANCELLED AFTER DISPATCH -> SLOT HELD FOR SIX HOURS (P1) The other end of the same narrowing. Cancelling a dispatched job deliberately leaves the slot to the finalize callback, because the executor is still running and still billing. If that executor dies, no callback arrives -- and neither sweep phase selects a CANCELLED row (phase 1 wants PENDING, phase 2 wants DISPATCHED/RUNNING), so the slot sat occupied until Redis expired it. New sweep phase 3, bounded on BOTH sides: older than the stuck grace, so a live executor is not cut short, and newer than the slot TTL, past which Redis has already dropped the entry and there is nothing left to release. Without that second bound it would rescan every cancelled job ever, forever. 3. A FAILED CANCELLATION WEBHOOK WAS ACKNOWLEDGED (P1) `agent_kv_cancelled` ignored `send_webhook`'s return, so a non-2xx or a connection failure was reported as success, the queue message acknowledged, and the caller simply never received the terminal notification the task exists to deliver. `_send_webhook` now returns the result and the task raises on a failed delivery, under the same retry budget `agent_kv_error` got. 4. THE ORPHAN CLEANUP DID NOT CHECK WHETHER IT WORKED (P1) The 2.8 fix called `delete_job_files` and ignored the result. That helper reports which refs are confirmed gone and swallows the rest -- normally the ref survives as the retry handle, but here there is no row to hold it. A failed delete therefore still orphaned the object, silently. Now checks, and logs the ref itself at ERROR: it is the only way anyone can clean it up by hand. 5. A NUMERIC `stage` RETURNED 500 (P2) A truthy non-string passed the presence check and then raised TypeError at `len(stage)` -- my own bounds check from 2.13, turning a malformed report into a server error. Now a 400. 6. CANCELLATION WEBHOOK CAN REPEAT ON REDELIVERY (P2) Acknowledged, not fixed here: it is inherent to at-least-once delivery and the finalize webhook has the same property. Deduplicating needs delivery state on the job row, which is the design question raised under 2.10 and not something to decide inside this commit. Test-mock changes worth flagging: `test_dispatch.py`'s positional `call_args_list[N]` assertions broke on every added query, so they are now content-based (`_filtered_with`). The two bookkeeping-failure tests had a blanket `filter.side_effect` that now hits the new terminal check -- a different, earlier failure -- so the failure is scoped to the bookkeeping call and their original meaning is preserved. 232 backend tests (from 228), ide_callback 20. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Webhook delivery results, and two comments that described the opposite of the code WEBHOOK DELIVERY RESULT (2.10, partial) `send_webhook` returns False for a non-2xx or a connection failure, and both call sites discarded it -- so a terminal notification could fail to land with no retry, no record, and for a non-2xx not even a log line. The cancellation task raises (its retry budget then applies). The finalize path logs at ERROR instead: it runs AFTER finalize has already terminalized the job, so raising would re-run a finalize that is no longer free. Three tests. Durable delivery -- attempt counts and a `webhook_delivered_at` the status document can expose -- is the open design question in 2.10 and is deliberately not decided here. TWO COMMENTS THAT SAID THE OPPOSITE OF WHAT THE CODE DOES `constants.py` line 1 -- the first line of the file that defines the routing table -- said "v1 accepts exactly one extractor (`kv`), so the job row does not carry which one it ran". Both halves are now false: `kv` is the one extractor this deployment does NOT accept, and `AgentKVJob.extractor` has carried the name since migration 0002. `test_queue_consumer_wiring.py`'s docstring described only "every queue we dispatch to must have a consumer" and then narrated how `celery_executor_agentic_kv` ought to be wired in -- while the file's actual assertion is that it must NOT be advertised. That is the one thing a guard with an inverted assertion must not say: a reader checking the file against its own description would have concluded the test was wrong. It now states both directions and why each exists. Both were mine, and both are the kind of stale comment that is worse than no comment -- a reader trusts them over the code. 232 backend tests, ide_callback 23, queue wiring 12. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Address the remaining open review threads on #2317 Nine open PR threads, in four groups. **Silent-skip bugs that reported success (2.19)** `constraints.py` called itself fail-closed in its module docstring and on the `except Exception` line, but `evaluate_constraints` records only `is False` as a violation -- so a constraint that raised was reported as "no violation", a positive assurance that nothing was checked. The path is reachable, not theoretical: `_BIN` has `truediv` and `_ALLOWED_NODES` has `ast.Div`, so a row with `quantity == 0` raises `ZeroDivisionError`. Two senses of "fail-closed" were conflated: the grammar IS closed (nothing outside the allowlist runs), the outcome is not. Relabelled, and every drop is now logged -- `exception` level for a raise, `debug` for the ordinary missing-operand skip, which is normal on a document with optional keys. `_aggregate` returned a PARTIAL sum/avg when some cells failed `coerce_number`, which is then compared against a scalar key covering the whole column and reports a violation on a CORRECT document. One blank cell in a 40-row invoice was enough. sum/avg now require every row to contribute; min/max/count are left total over a subset, which is what they mean. **Schemas accepted that could never validate (2.18)** `format: "Number"` is not a known format, so it became a free-text LLM hint, so `validate_format` returned True for every value forever and `/validate` reported `{"valid": true}` -- validation configured, none delivered. `format: "enum:"` and `format: "regex:"` are worse: they do not disable validation, they invert it, and no value can ever conform. An array `_key` naming a non-existent column silently falls back to positional identity, costing accuracy on exactly the arrays the author cared enough to key. All four are refused at `compile_schema` now, with the intended spelling in the message. Free-text format stays accepted -- that is deliberate. `KeySpec` and `ArraySpec` are frozen: they are compile output, the one derived variant already goes through `dataclasses.replace`. **Docs and docstrings that documented the opposite of the code** - docs §12 described both limiters as failing OPEN on Redis errors. They fail CLOSED, and have for the life of this branch. An operator sizing a Redis outage from that row would expect "requests get through" and get "every submit 429s" -- the opposite incident. Corrected, `AGENT_KV_LIMITER_FAIL_OPEN` documented with its real default, and added to both sample.env files. - `internal_client.agent_kv_stage_report`'s examples (`"extract"`, `"started"`, `"completed"`) are all rejected: `StageReportView` accepts only `running`/`done`, and an off-list stage name is stored then filtered out of every status response by `_status_document`. - `ValidateView` had no docstring. A reader of `execution_views.py` sees a complete, decorated, live-looking endpoint; the only thing that decided otherwise was `execution_urls.py`, a file they may never open. - The schema package's pyproject still called itself the "single source of truth for API validation and the extraction engine", which `compile.py:7-15` explicitly retracts. **Latent (2.16)** `mark_terminal`'s guarded UPDATE excludes terminal ROWS, not non-terminal ARGUMENTS, so `mark_terminal(..., RUNNING)` would stamp `completed_at=now()`: a row that reads as finished to the TTL filter and the sweep's cancelled-job phase while staying invisible to the terminal guard, so nothing could ever terminalize it again. No caller does this; the method is named for the invariant, so it enforces it. **Verification** 248 backend tests (was 233) and 128 schema-package tests, all green. Every new guard mutation-tested: reintroducing each bug fails the test that covers it -- the format/`_key` checks (14 tests), the partial-sum skip (3), the drop logging (1), the freeze (1), the `mark_terminal` guard (3), the ValidateView docstring (1) and the two docs rows (2). `unstract/agent-kv-schema/tests/test_constraints.py` was 0 bytes against a 258-line module -- an empty file with that name reads as coverage. It now carries the operator, arithmetic, boolean and aggregate matrix, plus a test that `compile._ALLOWED_NODES`/`_ALLOWED_CALLS` and `constraints._CMP`/`_BIN`/`_AGG` agree: two hand-maintained allowlists in two files, where a node accepted at submit but absent from the evaluator is a constraint silently skipped for every document (2.20). Not done here, and why: surfacing a skipped-with-reason third state in the QA output is a public contract change on the `kv` extractor, which this deployment does not ship. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Give agent-kv's `extractor` column choices and no default Review finding 2.17. `extractor` was the one stringly-typed field on the model without `choices=` while `status` has had them since 0001, so nothing but a reader's memory connected the column to `EXTRACTOR_ROUTES`. The sharper half is the default. Migration 0002 added the column with `default="kv"`, which was the historical truth then -- the API accepted exactly one extractor and it was always `kv` -- and became a mis-filing trap the moment `table` existed. A creation path omitting `extractor=` files a TABLE job as `kv`, and `kv` IS a valid key in `STAGE_NAMES_BY_EXTRACTOR`, so `_status_document` hands back the KV stage list and silently drops `table_extraction` from every status response. The job runs, the caller is billed, the stages array comes back empty, and `_status_document`'s unknown-extractor warning does NOT fire, because nothing is wrong as far as the filter can tell. Only the one production creation path exists today and it passes `extractor=` explicitly, so this is latent rather than live. Removed rather than re-pointed at `table`: which extractor ran is a fact about the job, not something with a sensible default. With no default an omission is loud -- `""` matches no route, so `dispatch_job` raises, the job terminalizes FAILED with an error the caller can see, and the status warning does fire. `JobExtractor` lists KV even though this deployment refuses it. The column records which extractor RAN, and the rows 0002 back-filled legitimately say `kv`; routability is `EXTRACTOR_ROUTES`' job. The two sets are deliberately different, so neither is derived from the other -- deriving would either quietly re-enable the extractor or make old rows unreadable. `test_recordable_extractors_are_a_superset_of_routable_ones` pins that relationship as a strict subset. Migration 0005 is a schema no-op on Postgres: Django keeps `choices` and a field `default` in Python only -- no CHECK constraint, no column DEFAULT -- so the AlterField changes model state and emits no DDL touching data. `makemigrations --check --dry-run` reports no changes, and the leaf check shows a single leaf per app. Two existing tests failed on this and were right to: - `test_the_extractor_column_defaults_to_kv` asserted `== "kv"`, pinning the defect as the contract. Renamed, inverted, and it now also asserts `kv` remains a legal value to READ back, which is what the retired-extractor status test below it depends on. - Two `test_job_views` tests built jobs with KV stage names and `kv`-namespaced result assertions while relying on the column default to supply the extractor. Both now pass it explicitly: the filtering is load-bearing for what they assert, and riding on a default made that invisible in the test that depends on it. 251 backend tests (was 248). Mutation-checked: restoring `default=V1_EXTRACTOR_NAME` fails 2, dropping `choices` fails 3. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [REFACTOR] Delete agent-kv's dead `compiled` serializer attribute Review finding 2.20. `ExtractorSerializer.compiled` was populated during validation and collected by `SubmitSerializer.validate_extractors` into a `{extractor_name: compiled schema}` dict that no non-test code read. `dispatch_job` sends `schema=entry["keys"]` -- the raw dict -- and the engine compiles it itself. Plumbing the `CompiledSchema` through instead was the other option the finding offered, and the queue does not allow it: the compiled form would have to survive a JSON round-trip to the executor, which is precisely why the engine recompiles (`compile.py`'s docstring already says the caps are a submit-time gate, not an invariant the engine re-checks). An attribute that is written and never read reads as plumbing that exists, so it is gone. On this deployment the dict was always `{"table": None}` anyway: `table` has a dedicated keys serializer, so the branch that set `compiled` never ran. `compile_schema` is still called in the no-keys-serializer fall-through, for what it REFUSES -- the 400 is the only reason it is there, and the comment now says so, so a later reader does not delete the call as unused. That branch is DORMANT here: `kv` is the only extractor without a keys serializer and `validate_name` refuses it first, so nothing in the request path reaches it. It is still the contract for the next extractor added without one, and nothing covered it, so three tests call it directly -- the bad-schema 400, the raw spec passing through identically, and that neither serializer carries a `compiled` attribute any more. Also fixed the now-wrong comment in `test_submit_view.py`, which said the options dict "must ... carry the compiled schema". 254 backend tests (was 251). Mutation-checked: deleting the `compile_schema` call fails the 400 test, reintroducing the attribute fails the third. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [MISC] Clear the eight SonarCloud findings on #2317 - **S3776** `SubmitView.post` at cognitive complexity 20 against a limit of 15. Extracted the two self-contained blocks as module-level helpers next to the ones already there (`_subscription_denial`, `_dispatch_or_fail`, `_sync_wait_response`): `_request_data_with_extractors_inlined` (the bounded file-part read) and `_discard_orphaned_input` (the staged-object cleanup for a submit that failed before its row landed). Behaviour unchanged; the rationale comments moved with the code they explain rather than being left behind in `post`. - **S5778 x3** in `test_compile.py`: `pytest.raises` blocks containing two calls, because the spec was built inside them. Hoisted `_one_key(fmt)` out, so what the block asserts about is `compile_schema` alone. - **S5799 x2**: implicitly concatenated string literals in `dispatch.py` and `agent_kv_tasks.py` -- ruff-format artefacts that landed as `"... not " "enqueueing"` on one line, which is exactly the shape of a forgotten comma. Merged; both stay inside 90 columns. - **S117 x2** in migration 0004: `PgPeriodicTask = apps.get_model(...)` is the standard Django idiom but is a local variable, so S117 reads the CamelCase as a naming violation. Renamed to `periodic_task` with a comment saying why it departs from the idiom, so the next person does not "fix" it back. Cloud #1828 has zero SonarCloud findings. 254 backend tests, 128 schema-package tests, 23 ide_callback tests, all green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FEAT] The table extractor runs on the caller's adapters, not operator env vars Reverses spec D6 for the `table` extractor. D6 specified "no platform adapters by design" -- models and OCR credentials in operator env vars, "no end-user model control". That was right while the consumer was hypothetical and wrong once the consumers are existing customers with accounts: * There was NO operator-level LLM credential anywhere in the platform to reuse. LLM credentials live in per-org `AdapterInstance` rows, encrypted; the only LLMWhisperer key in a namespace is `LLMWHISPERER_PORTAL_KEY`, which is the portal ADMIN key that mints per-org extraction keys -- wrong credential, and over-privileged for executor pods. So the six required env vars had no source, and a deployment could not be completed. * Env configuration put every customer's model choice and every customer's LLM spend on one shared operator key. * The IDE table path in the SAME plugin already resolves `llm` / `lite_llm` / `x2text` from adapter instances. This makes the two paths agree. Wire format follows the platform's own conventions rather than inventing one: * `adapters: {llm, lite_llm, x2text}` -- a SIBLING of `keys`/`options`, named by ROLE. `prompt_profile_manager_v2.ProfileManager` declares `llm` / `x2text` / `embedding_model` / `vector_store`, as fields on the owning object rather than inside a knobs blob, so neither `*_adapter_id` naming nor burying them in `options` would have matched. Keeping them out of `options` also leaves that member optional. * Types validated against `AdapterTypes` (`unstract/sdk1/constants.py`). * Errors are DRF `ValidationError`s -> 400, like the rest of the submit caps. **Validation is split, and the split is the point.** Shape (`_validated_adapter_shape`) is in the serializer and touches no database -- `test_submit_serializer.py` is a unit suite, and my first attempt put an `AdapterInstance` lookup there and reddened 22 tests. Ownership and type (`_resolved_adapters`) are in the VIEW, where the Bearer key is, for the same reason `_subscription_denial` lives there. Both run before the job row, the concurrency slot and the staging write -- the first version of the view gate sat AFTER staging while its own comment claimed otherwise. **The org filter is the tenant boundary and nothing downstream repeats it.** The executor resolves these ids through the platform service and does not re-check whose they are, so without `AdapterInstance.objects.filter(id=..., organization_id=key.organization_id)` a caller names any adapter UUID and the run spends ANOTHER TENANT'S LLM credential. Same class as the `organization_id` filter in `_get_job`, higher consequence. No platform helper fitted: `AdapterProcessor.get_adapters_by_type` filters `.for_user(user)` and this path has no user (a key resolves to an ORGANIZATION), and `get_adapter_by_name_and_type` is unscoped. Three deliberate choices in that gate, each documented at the code: * "No such adapter" and "not yours" return the IDENTICAL message, with a test asserting the bodies are byte-equal -- distinguishing them makes the endpoint an oracle for which adapter ids exist in other organizations. * Type is checked because an `X2TEXT` id in the `llm` slot resolves fine and then fails deep in the engine as a provider error, which reads like a broken model rather than two swapped UUIDs. * Per-user adapter visibility is NOT honoured. `AdapterInstance` is `HasMembersMixin` with `ResourceMembership` VIEWER rows, and an org-scoped key has no user to evaluate them against -- so a key reaches any adapter in its own org. A key is already an organization-level credential, so this is a decision, not an oversight. `kv` is untouched and stays env-configured: one credential model per extractor, never both for one extractor. `ExtractionConfig` keeps its credential fields for it. Docs: new §3a covering the three roles, what each is for (lite_llm runs once per page, so it is the cost-critical one), the four 400 cases, and the D6 reversal with its consequences stated -- the API is no longer callable by someone who has never signed in, and a table-only deployment can leave `global.sharedConfigs.agentKv.enabled: false` entirely. Added a `table` curl example; the existing `kv` one is now labelled as wire-format-only, since this build 400s it. 259 backend tests (was 254). Mutation-checked: removing the `organization_id` filter fails 4. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Two P1 review findings on the adapter change — one of them broke every submit **P1: the adapter lookup hid every valid adapter.** `_resolved_adapters` used `AdapterInstance.objects`, which is an `AdapterInstanceModelManager` and inherits `DefaultOrganizationManagerMixin.get_queryset()` -- that filters EVERY query by `UserContext.get_organization()`, a request-local thread-local the tenant middleware sets. `/agent-kv/` is deliberately whitelisted past that middleware (the org comes from the Bearer key, not the URL), so the thread-local is unset and the ambient filter became `organization=None`. Result: the gate refused EVERY submit, including a correctly-configured caller naming their own adapter, with "no such adapter in this organization". The API would not have worked at all. I wrote it by analogy to the `organization_id` filter in `_get_job`, and the analogy is false: `AgentKVJob` uses `BaseModelManager`, which does not auto-filter, so its explicit filter is the only one. `AdapterInstance` sets a different default manager. Verified empirically rather than assumed -- `type(AdapterInstance.objects)` is the filtering manager and `type(AdapterInstance._base_manager)` is a plain `Manager`. Fixed with `_base_manager`, Django's unfiltered manager, which exists for exactly this: internal lookups that must not inherit a custom default manager's filtering. That makes the explicit `organization_id` the ONLY org filter, which is why it stays required rather than optional. NOT fixed by setting the thread-local instead: that would silently re-scope every other org-managed query in the request, and `UserContext` keys on the org SLUG while the key carries the FK pk -- the confusion `dispatch._platform_api_key` documents shipping once already. Extracted as `_lookup_adapter`, because `_base_manager` is a read-only property that cannot be patched, and because mocking `.objects` is what hid this: every view test replaced the manager wholesale, so the manager's own behaviour never ran. **The test that was missing.** `tests/test_adapter_scoping.py` is DB-backed (`TestCase`, so the integration tier picks it up by inference) and exercises the real manager with the thread-local unset -- the only arrangement that reproduces the production path. Four cases: the org's own adapter resolves with no request context (the regression), another org's does not (bypassing the AMBIENT filter must not bypass the explicit one, or the fix would have opened the hole it exists to close), an unknown id is None, and `.objects` really does hide it -- so the explanation on `_lookup_adapter` stays truthful if that manager ever changes. Run against a real Postgres: 4 passed. Mutation-checked by reverting `_base_manager` to `.objects`, which fails the regression case. The unit suite could not have caught this at all. **P1: the e2e lane's submits carried no adapters**, so its shared helper got 400 where it asserted 202 and the lane could no longer validate table extraction on a deployed stack. `submit_raw`/`submit` now take `adapters`, defaulting to ids read from `AGENT_KV_E2E_LLM_ADAPTER` / `_LITE_LLM_ADAPTER` / `_X2TEXT_ADAPTER`. These have to come from the environment: they are rows in the deployed stack's database carrying real provider credentials, and an e2e client cannot mint them. Nine scenarios whose submit must be ACCEPTED now take a `require_adapters` fixture, which skips naming the unset variables -- following the lane's existing gate convention (`AGENT_KV_E2E`, `require_llm`, `AGENT_KV_E2E_BAD_KEY_JOB`) rather than inventing one. The seven scenarios that expect a rejection BEFORE the adapter gate (bad key 403, rate limit 429, absent `extractors`, bad schema 400) deliberately do not take it, and keep working unchanged. Also corrected the module docstring, which still described `AGENT_KV_LLM_API_KEY` as the gate for real extraction -- on the table path that credential now lives on the adapter, so a real-adapter run satisfies it whether or not the variable is exported. 259 unit + 4 integration backend tests. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Round-2 review: a billing control the gate skipped, two dead guards, and one false premise A1 (CONFIRMED, the one with teeth). The adapter gate checked organization and adapter_type and nothing else. `is_usable` is how billing cuts off a frictionlessly onboarded org's operator-funded trial, and the platform service does NOT check it -- it hands the credentials back regardless. So the IDE, workflows and Prompt Studio all refuse an exhausted trial (tool_instance_helper.py:533, prompt_studio_helper.py:149/203/3292) while an Agent-KV submit naming the SAME adapter id got a 202 and ran on credentials the operator pays for and has already cut off. Added `is_usable` and `is_available` (deprecated -> `InValidAdapterId` deep in the engine, now a 400 at submit), reusing the platform's own message. A3. Migration 0004 seeded two PgPeriodicTask rows on the stated premise that there was "no PgPeriodicTask, no beat entry, no CronJob". That premise was false about its own tree: the cloud chart already ran the same two management commands, added four commits earlier on this branch. The rows were `pg_owned: False` so nothing double-fired yet -- but that flag exists to be flipped, and a flip would have put a */10 sweep against the chart's */15, colliding at :30 on the same rows, with TTL cadences disagreeing 24x. Only the CronJob side has `concurrencyPolicy: Forbid` and a deadline. 0004 now DELETES the rows (idempotent, name-scoped, reverse restores them inert) and the CronJob is the sole owner. Verified forward/reverse/re-apply against a real DB, including that the five dashboard_metrics periodics are untouched. Self-hosted OSS operators are told to schedule the two commands themselves -- docs §12. C1. `_never_dispatched` read the in-memory row, fetched BEFORE `mark_terminal`. A dispatch completing in that gap made the cancel release a slot whose executor was running and billing -- a smaller instance of the over-release bug the narrowing was written to fix. Now re-reads `dispatched_at` from the DB. The residual enqueue-to-bookkeeping window is documented rather than papered over: bounded at one slot, self-correcting via the idempotent finalize release. C2. Sweep phase 3 counted slot releases, logged them, and returned only `{swept, timed_out}` -- so neither `SweepView` nor the scheduler log could ever show a slot was recovered. Now returns `released` too. E9. Nothing recorded which adapters a run spent. Adapter choice is now per-request, caller-controlled and cost-bearing, so "which model did job X use?" needed a usage_v2 join the API cannot do and the customer cannot see. New `AgentKVJob.adapters` (migration 0006), populated at submit and reported in the status document. Ids only, never adapter_metadata -- that column is returned to the caller. B1. The queue guard was literal-pinned, so re-enabling `kv` would have required DELETING its inverse assertion to go green. Added `tests/test_queue_wiring_is_derived.py`, which derives both directions from `EXTRACTOR_ROUTES` (and so flips on its own), covers `run-worker-docker.sh` (guarded by nothing -- dropping `agent_kv_callback` there passed every test while a docker-launched fleet drained no terminal callbacks), and asserts the derivation is non-vacuous. Also derives the billing backstop: every routed operation must be in `_LLM_BEARING_OPS`, which the two hand-maintained literals could not catch when both were wrong together. The workers-side suite keeps its literals as a second opinion, now cross-referenced. The dead-Celery-var test is NOT parametrized -- review asked for that and dev compose legitimately sets the variable; generalized to "never advertises an unserved queue" instead, over both files. B2. The format-typo guard missed the two formats that take an argument: `_parse_format` matches the `enum:`/`regex:` prefix case-sensitively, so `format: "Enum:paid,unpaid"` never became kind `enum`, slipped the guard as free text, and validation was silently off for that key -- the exact failure the round-1 fix closed for bare names. Now splits on the colon. Rejected rather than auto-corrected, matching the bare case. B7 / C3. Pinned two round-1 fixes that no test could detect: `maintenance.py`'s logging (nothing used caplog on it; all five lines were deletable) and the `except Exception: pass` in `ide_callback/agent_kv_tasks.py` (its test was byte-identical before and after). Fixed the same bare `pass` in the sibling `ide_callback/tasks.py`. Replaced `test_agent_kv_error_matches_its_siblings_failure_posture`, which asserted `agent_kv_complete is not None`, with one that actually drives both callbacks through a failing finalize. Corrected the `retry_backoff` docstring: the PG consumer runs tasks via `task.apply(throw=True)` -- eager -- so eager retry recurses synchronously and ignores countdown; the three attempts are back-to-back and ride out no blip. The real delay comes from vt-redelivery after the raise. C4. `require_llm` gated the only two scenarios that poll a job to a genuine COMPLETED on `AGENT_KV_LLM_API_KEY` -- a variable the table path does not read, since the credential is on the adapter. A correctly configured lane SKIPPED them. Removed; `require_adapters` is the whole gate. `test_bad_llm_key_ends_failed` had lost its trigger entirely (`AGENT_KV_E2E_BAD_KEY_JOB` is unread, so an operator following the docs got a job that reached `completed` and failed the test's own assertion) -- re-pointed at `AGENT_KV_E2E_BAD_LLM_ADAPTER`, where the credential now lives. Part D. `test_submit_unreadable_pdf_is_400` and `test_page_cap_rejects_oversized_document` went RED, not skipped, without adapter env: the adapter shape check runs in a FIELD validator, so DRF raises it before `validate()` and the blamed attr is `extractors`, not `file`. Both now take `require_adapters`. Replaced the identical-refusal test that ran the same configuration twice and compared the body to itself -- the oracle property is now asserted from two genuinely different DB states in `test_adapter_scoping.py`, which also gained the `adapter_type` comparison against a real row (previously only ever against a mock that chose its own value) and explicit `is_usable`/`is_available` on every stand-in, since a bare `Mock` satisfies any attribute the gate reads. E1. Four places across both repos asserted the submit gate was the only tenancy check and were cited as the justification for its design. It is not: platform-service resolves adapters with `WHERE id=%s and organization_id=%s` against the org of the key `dispatch._platform_api_key(job)` mints from the job's org, so org A cannot spend org B's credential even with this gate removed. Corrected to defence-in-depth, with what the gate actually buys: a clean 400, plus the two refusals the platform service does not make. E7. Documented that the page cap bounds `pages_selected` (after page_start/page_end), not the document's length -- an unlisted behaviour change. A4. Deploy-order skew: a new backend against an OLD worker sends validated `adapters` to a worker with no `ParamKeys.ADAPTERS`, which ignores them and calls `from_env()`. Where the six AGENT_KV_* values are set it runs on OPERATOR credentials and returns a correct-looking 202. Documented: deploy cloud first, and leave that group unset. Also: the stale "see the backend migration that schedules them" in workers/scheduler, and `_resolved_adapters`' discarded return value (now assigned back, so future normalization reaches dispatch). New: `unstract/sdk1/tests/test_page_usage_metering.py` pins that non-PDF inputs meter as ONE page while PDFs meter per real page. That under-counts Excel, which the table engine splits into virtual pages -- product-wide, rooted in the SDK's own TODO, and affecting the IDE path identically. Pinned rather than changed: correcting it re-prices every spreadsheet extraction and needs sign-off. Verification: agent_kv 294 passed; workers (ide_callback, config, webhook, client, scheduler, queue-wiring, llm-bearing-ops) 74 passed 1 skipped; agent-kv-schema 138 passed; sdk1 766 passed. Migrations 0004 forward/reverse and 0006 applied against a real Postgres; `makemigrations --check` clean. ruff 0.3.4 check + format clean. Every fix above was mutation-verified by reverting it and confirming the new test fails. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] Four round-2 Greptile findings, two of them holes my own fixes opened P1. `_never_dispatched` lost a case when I dropped its `status == PENDING` arm. Dropping it was right for the reason given -- the cancel has already rewritten `status`, so that test could only ever be answered from the stale object -- but `dispatched_at` alone is not enough. `dispatch_job` wraps `_record_dispatch` in a try/except on purpose: the task is already queued, so a bookkeeping failure must not be reported as a failed dispatch. A job can therefore be RUNNING, with an executor spending money, while `dispatched_at` is still NULL, and the executor's first stage report is the only thing that recorded it. Cancelling there released a live executor's slot and the next submit could take it, exceeding the per-org cap. DELETE had the same path. The old `status == PENDING` arm had been catching this by accident, off the stale row. Now consults BOTH durable markers, read fresh: `dispatched_at`, and `stage` (written by `StageReportView` on the first progress report, and never touched by the cancel). Five DB-backed cases in `test_adapter_scoping.py` -- including the RUNNING-with-NULL-dispatched_at row and the original stale-snapshot interleaving -- plus a view test asserting the query consults both fields, so neither can be dropped again. Mutation-checked: removing the `stage` marker fails three. P1. The migration fix could not reach the environments that needed it. Django records a migration as applied and never re-runs it, so rewriting 0004's forward operation to DELETE the seeded rows did nothing at all in every dev namespace that had already applied the seeding version -- while changing behaviour only for fresh installs. Its own docstring claimed to clean up environments it could not touch. 0004 is restored to seeding (with a note that 0007 supersedes it) and the removal is now `0007_remove_agent_kv_periodic_tasks`. A fresh install seeds then deletes; an already-seeded one just deletes; both land on zero rows. Verified against a real Postgres in all three states -- fresh, pre-seeded, and reverse-then-forward -- with the five dashboard_metrics periodics untouched each time. 0007 reads the row names out of 0004 rather than re-declaring them, and a test asserts 0004 still seeds, so folding the removal back into it fails loudly. P2. My `Enum:`/`REGEX:` fix over-rejected valid free text. Comparing the pre-colon segment against every name in `_KNOWN_FORMATS` meant `format: "number: of items"` -- and `"date: of issue"`, `"currency: USD"`, `"string: free text"` -- raised SchemaError, forcing authors to reword hints that are a SUPPORTED way to describe a key. That is a worse failure than the one being fixed: the original bug silently disabled validation for one key, this refused the whole schema. Scoped to `_ARGUMENT_FORMATS` (`enum`, `regex`) -- the only two that take an argument, so the only two where `name:arg` can be a mis-cased format. There is no such thing as `number:`. Verified across a 14-case matrix; mutation-checked. P2. The e2e module docstring still told operators to set the removed `AGENT_KV_E2E_BAD_KEY_JOB=1` and run `-k bad_llm_key`, a selector that matches nothing since the rename. Rewritten for `AGENT_KV_E2E_BAD_LLM_ADAPTER`, with the three adapter ids the accepted-submit scenarios need, and noting the new arrangement needs no special stack -- the bad credential is swapped in for the `llm` role on one submit, so the rest of the module still completes. Same correction in `docs/agent-kv-api.md` §11. Verification: agent_kv 302 passed, workers 74 passed 1 skipped, agent-kv-schema 143 passed. Migrations 0004/0007 exercised forward, reverse and re-forward against a real Postgres from both a fresh and a pre-seeded database; `makemigrations --check` clean. ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 0007 must not delete a schedule a self-hosted operator owns Greptile, P1, and correct. 0007 deleted the two `PgPeriodicTask` rows by NAME. These rows are seeded inert, so deleting an inert one removes the accidental second owner 0004 added and nothing else -- but a self-hosted operator running the PG scheduler has every reason to have flipped `pg_owned: True` on them. That is exactly what `docs/agent-kv-api.md` §12 tells them to do, and there is no CronJob outside the cloud chart to replace it. Deleting by name alone would silently remove their only maintenance mechanism: stuck jobs stop being swept and hold their concurrency slots until the 6 h Redis TTL, and expired customer documents stay in the bucket indefinitely, with nothing reporting either. The delete is now scoped by name AND `pg_owned=False`. A row the operator has taken ownership of is left exactly as they set it, which is the correct reading of this migration's purpose: it removes a second owner that 0004 created by mistake, not any schedule carrying those two names. The no-double-fire argument is unaffected -- the collision requires a `pg_owned` row, and on cloud the chart is the owner and no row is ever flipped. Verified against a real Postgres: with one row flipped to `pg_owned=True` and the other left inert, applying 0007 leaves exactly the operator-owned row and removes exactly the inert one. Pinned so the scope cannot quietly widen back. Verification: agent_kv 303 passed. ruff 0.3.4 check + format clean. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * UN-4232 [FIX] 0007's reverse must not reset ownership on a row it preserved Greptile, P1, and a real bug in the previous commit. Forward leaves a row alone when the operator has adopted it (`pg_owned=True`) -- so on a rollback that row is still there, and the reverse's `update_or_create` carried `"pg_owned": False` in its defaults. Rolling back 0007 therefore reset the operator's live schedule to inert, silently disabling a self-hosted deployment's only maintenance mechanism as a side effect of rolling back an unrelated release. Exactly the failure the forward narrowing was added to prevent, reintroduced on the way back. `get_or_create` instead: restore what forward REMOVED, never normalize what it deliberately preserved. New rows are still created inert. Verified against a real Postgres: adopt `agent_kv_sweep` (`pg_owned=True`), leave `agent_kv_ttl_cleanup` inert, migrate forward -> the adopted row survives and the inert one is deleted; roll back -> the adopted row is STILL `pg_owned=True` and the sibling returns inert. Pinned, and mutation-checked by restoring `update_or_create`. The c…
UN-4402 [FEAT] Global search API (#2343) * feat: add additive global search API (UN-4402) GET /api/v1/unstract/{org_id}/search/ returns matches across Prompt Studio projects, workflows, API deployments, ETL and Task pipelines, adapters and connectors, grouped by type in rail order. Each type reuses its list's for_user() visibility, ranks name prefix > name > description prefix > description, and fetches limit+1 rows to derive has_more. Cloud apps contribute their own types through register_provider(), so OSS never names a cloud type. The contract (README + JSON Schema) is checked by tests, and a 2,000-rows-per-type fixture bounds queries per type. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 1 on global search (UN-4402) - Isolate each provider: a failing type is logged and dropped instead of failing the whole search; a savepoint keeps a DB error from poisoning the next provider under ATOMIC_REQUESTS. - Validate type keys against the schema pattern at registration; route params are now required on SearchHit; name may be blank. - Tests: ranking fixtures now disagree with recency so tier order is pinned; per-type context and route params, owner-e-mail rules and DB-error isolation are asserted; the query bound must not grow with limit; the p95 wall-clock assertion is opt-in (GLOBAL_SEARCH_ASSERT_P95=1). - Docs: scope claims to what is tested and fix inaccurate statements. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 2 on global search (UN-4402) - A failed type is now reported in a new `failed_types` envelope field instead of looking like "no matches"; when every searched type fails, or a failed savepoint leaves the transaction unusable, the request fails rather than returning an empty 200. - SearchHit requires an `id` route param (schema now requires it too). - Tests: the shared search helper fails on any unexpected failed type, the budget test requires every type to answer at scale, and the earliest-owner rule is pinned independently of insert order. - Docs: budget section states CI gates on query count, not latency; other wording fixes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 3 on global search (UN-4402) - Search that cannot answer at all now returns a fixed 503 `search_unavailable` (standard error envelope) instead of re-raising the last provider's exception: when every searched type fails, when a failure leaves the transaction unusable, and when the DB connection is lost in autocommit. README documents both triggers. - The all-failed path no longer logs the last cause twice. - Owner-context test pins the service-account exclusion and the created_at ordering independently. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: run the 5xx search tests with DB access (UN-4402) The project's error handler reads the DB while rendering a 5xx, so these tests failed under xdist when they landed on a worker without DB access. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 4 on global search (UN-4402) - Escalate to 503 only when the connection is actually unusable (failed savepoint rollback, or is_usable() false after a database error). A statement/lock timeout or deadlock on a healthy connection now drops only its own type into failed_types. - When every searched type fails: 503 search_unavailable only if every failure is a connection-class error; otherwise 500 search_failed, so a bug is not labelled retryable. - The failing provider is logged by name before escalation. - Declare 400/500/503 in the OpenAPI annotation; README documents both. - Tests drive the escalation rules through a patched autocommit connection, and the django_db comment states the real reason (Django's 5xx reporter imports the URLconf, which queries on first import). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 5 on global search (UN-4402) - A database error with no open connection (connect failed) now fails the request with 503 instead of letting every later type dial a dead database; is_usable() is never called on a missing connection. - The 503 is described as "a database error a retry may clear (unreachable, timed out, locked)" everywhere, matching behaviour; the README no longer claims a slow query always only drops its own type. - The failure-handling tests are DB-free again: the real cause of the xdist DB access was an organisation left in UserContext by an earlier test, which made an org-scoped class-level queryset query during the URLconf import Django's 5xx reporter triggers. An autouse fixture clears it. - New tests: failed connect without probing, and a bug mixed with database errors is a 500. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix: address self-review round 6 on global search (UN-4402) - Gate the connection-unusable check on django.db.Error: InterfaceError ("connection already closed") derives from Error, not DatabaseError, so it previously skipped the check and blamed later healthy types. - OpenAPI 500/503 descriptions now state the same conditions as the README. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * docs: drop an inaccurate OpenAPI 503 sentence (UN-4402) A single failure on a healthy connection is a 200 only while other types still answer; the README states that condition, so the sentence goes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4402 fix: route adapters by edit rights and drop frictionless ones Adapter retrieve is gated by IsFrictionLessAdapter, stricter than the adapter list, so search returned adapters that 403 on click: - frictionless adapters are refused to everyone, so search no longer returns them; - callers who can list but not edit an adapter (direct viewer, group or org share) now get route key adapter.info, the read-only view backed by adapter/info/<id>/, instead of adapter.detail. SearchHit gains an optional per-hit route_key; ModelSearchProvider.annotate now receives the user. Tests, from Athul's review: - the budget test counts the real admin-role lookup (no patched predicate, fresh user per request) and the overhead allows for it; - a URL org other than the session org is refused (403), through the real middleware; - service accounts and admins never get frictionless adapters. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4402 docs/test: tighten the adapter route contract From the review of the adapter-route commit: - README: route.key lists both adapter keys in the type table, and says a client that does not know a key renders the result unlinked rather than guessing a screen; - frictionless wording: nobody can open their detail view (adapter_info does not refuse them for owners or admins); - the adapter route test now checks each caller against the real IsFrictionLessAdapter, so search and retrieve cannot drift apart; - the budget test asserts its admin really is an admin, so an auth plugin with another admin role name fails loudly instead of measuring a member. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * UN-4402 test: make the budget admin subtest distribution-independent d95e166's setUp guard failed under the cloud settings, where the admin role is "unstract_admin", not "admin". Pin only the role-name check (AuthenticationController.is_admin_by_role, pure Python); the membership query the budget counts still runs. Passes under OSS and test_cloud. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
UN-4176 [FIX] Cut idle PG-queue polling to one autocommit claim per c… …ycle (#2349) * UN-4176 [FIX] Cut idle PG-queue polling to one autocommit claim per cycle Idle PG-queue consumers were the largest steady source of DB statements: every poll cycle sent one claim per queue, each wrapped in BEGIN/COMMIT. - PgQueueClient: connections the client opens itself run in autocommit, so each single-statement operation (claim, ack, lease renewal, send) is one statement instead of three. Injected connections are untouched (they may be the caller's open transaction), and create_pg_connection stays non-autocommit for pg_barrier. - PgQueueClient.read_any: claims across several queues in one statement, with an indexed per-queue probe (LATERAL on the partial claim index) so cost does not grow with the backlog. - PgQueueConsumer.poll_once: multi-queue consumers make one read_any claim per cycle. The first queue probed rotates every cycle so no queue starves; priority still orders messages within a queue. Single-queue consumers keep the existing claim SQL. No queue lists change. Measured locally with all PG workers idle: queue polling 89.0 -> 9.2 statements/s; API deployment still completes. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Pin the contended read_any claim deterministically in tests The concurrent-readers test could pass with one reader draining every message before the other read, and a reader's exception died in its thread. Add a test that holds reader A's claim transaction open while reader B claims (lock_timeout turns a wait into an error), and release the parallel drain from a barrier with results collected through futures. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address round-1 self-review: fair rotation, partial counts, docs - Rotate the multi-queue claim from one past the queue actually served (QueueMessage.queue_name), so busy queues take equal turns; advancing by one per cycle gave a busy queue an extra turn per empty queue before it. - poll_once separates the claim from handling: a handler failure logs the msg_ids left unstarted and still counts the messages processed. - Document the accepted read_any trade-off (the probe locks every queue's head; a racing reader can see [] and back off) and the autocommit one-statement rule; fix stale "each queue read" comments. - Tests: read_any lease (vt), priority order within a qty>1 batch, queue_name on read/read_any, reconnect after pg_terminate_backend on an owned autocommit connection, equal turns around an empty queue on real Postgres; bad-args test no longer needs a database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address round-2 self-review: batch failures, accepted trade-offs - poll_once skips a message whose handling fails and runs the rest of the batch, so a deterministic failure cannot strand its batch-mates (each redelivery costs them a read_ct attempt); a connection-dead failure still stops the batch, since the rest could not be acked either. - Warn when a claimed message's queue_name is unknown instead of silently rotating by one. - Document the autocommit trade-offs: a claim lost in flight stays claimed until its lease lapses and counts toward read_ct; send()'s ambiguous post-commit window is the INSERT's own reply. Fix the _cursor NOTE and the HEALTH_STALE comment (a batch, not a task). - Tests: rotation follows the last message of a batch, the unknown-name fallback, skip-and-continue, connection loss stopping the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address round-3 self-review: drop batch abort, bare DatabaseError - poll_once no longer stops a batch on a connection-classified handler error: the client drops the dead handle and reconnects for the next ack (and the classifier also matches cancellations and deadlocks), so every failure is now logged and skipped. - On a client-owned (autocommit) connection, rollback() can no longer prove a connection dead, so a bare psycopg2.DatabaseError is treated as dead and the handle dropped, keeping what the failed-rollback path did. - read_any trade-off text names single-queue consumers too; comments no longer claim rollback() cannot fail. - Tests: owned conn dropped on a bare DatabaseError, kept on a statement error; injected conn kept; a connection error does not stop the batch. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address round-4 self-review: keep main's batch-stop semantics A handler failure stops the batch again, as each queue's batch did on main: rounds 2-4 showed both alternatives cost something (stopping burns a read_ct attempt for the unstarted tail; continuing runs work whose ack cannot land during a DB outage, so it runs twice). Matching main keeps this PR to its scope. Still counts the messages handled and logs the msg_ids left unstarted; the trade-off is written in poll_once. Reword the bare-DatabaseError comment as the heuristic it is. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address round-5 self-review: non-object payloads, failure log - _handle drops and acks a payload that is not a JSON object instead of raising on payload.get(); with the batch stopping on a handler failure, such a row would otherwise stop its batch on every delivery. - The handling-failure log names the queue and lists unstarted msg_ids only when there are any. - Fix the _cursor NOTE: an injected connection is never dropped. - Tests: the failure position parametrised over the batch (return value, work done, log text, rotation), and the non-object drop. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Address review: required queue_name, read_any dedupe, simpler rotation - QueueMessage.queue_name is required (both constructors always set it), so the consumer's unknown-name fallback and its test are gone; message is annotated Any, since a raw writer can store any JSON. - read_any de-duplicates queue names: a repeated queue's second probe returned the same rows and crowded another queue out of the batch. - Rotation is done in Python by binding the queue list already rotated; the SQL orders by ord and takes 4 parameters instead of 10. - Comment accuracy: unlocked when the claim commits; rollback() can't detect a dead server connection; drop the "before this loop" wording. - Tests: end-to-end multi-queue claim, execute and ack on real Postgres; duplicate queue names still fill the batch; pg_terminate_backend waits for the backend to exit. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax * UN-4176 Revert out-of-scope review suggestions Keep the PR to its scope and to required changes only: - Restore the SQL-side rotation (cosmetic simplification, not a defect; the de-duplication of queue names stays). - Restore QueueMessage.message's dict annotation and docstring, a pre-existing public field this PR does not need to change. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017Ymcknu7CYVmAt7QoRTTax --------- Co-authored-by: Claude <noreply@anthropic.com>
PreviousNext