Skip to content

fix: bill a chat embedding to the user who caused it, not to the shim account (issue #1696) - #1712

Merged
sakibsadmanshajib merged 3 commits into
mainfrom
fix/1696-chat-search-attribution
Sep 2, 2026
Merged

sakibsadmanshajib merged 3 commits into
mainfrom
fix/1696-chat-search-attribution

Conversation

@sakibsadmanshajib

@sakibsadmanshajib sakibsadmanshajib commented Sep 2, 2026 •

Copy link
Copy Markdown
Owner

Closes #1696

What was wrong

Open WebUI's Python retrieval path is configured with RAG_OPENAI_API_BASE_URL=http://edge-api:8080/v1 and RAG_OPENAI_API_KEY=${OWUI_SHIM_KEY} (deploy/docker/docker-compose.yml), so every embedding it produces is a real, metered call on Hive's own gateway. Web search indexing is the loudest: one search fetches several pages, chunks them and embeds every chunk, which is where issue #1609's burst of roughly two hundred embedding calls came from.

All of it settled against whichever account owns OWUI_SHIM_KEY. So the spend was real and it was metered, and it landed on a platform account: the searching customer's balance and usage showed nothing they had done, one account absorbed the embedding spend of every tenant at once, and the per-tenant budget work was defeated because the spend never reached the tenant the cap applies to.

Where attribution now comes from

The carrier this deployment already has, not a second scheme. X-Hive-Upstream-Auth is read by OWUIUnwrap (apps/edge-api/internal/auth/owui_unwrap.go) and honoured only when Authorization is exactly the shim key, which is the same gate the body carrier sits behind. deploy/docker/owui-patches/hive_agent_proxy.py already uses it for the agent-task endpoints. The credential on it is the signed-in user's own OAuth access token, verified by the JWT middleware against JWKS like any other session bearer, so this is a verified principal rather than an asserted identity.

Two halves, both required, and useless apart.

edge-api. /v1/embeddings joins requiresPerUserAuth, so a shim-key embeddings call carrying no per-user token is refused instead of billed to the shim, and one that does carry a token has its Authorization rewritten onto that token. The API-key handler cannot then serve it: inference.Orchestrator.Authorize resolves an hk_ key out of the Authorization header and a Supabase JWT is not one. So /v1/embeddings becomes JWT-aware through the existing jwtAwareChatHandler, exactly as /v1/chat/completions already is, and the new apps/edge-api/internal/embeddings package serves the session arm.

open-webui. A build-time splice attaches the carrier inside agenerate_openai_batch_embeddings. That is the single function every embedding on this deployment leaves through: RAG_EMBEDDING_ENGINE is openai, so generate_embeddings takes the openai arm, and both producers reach it, the get_embedding_function closure (retrieval queries, and web search indexing through save_docs_to_vector_db, which builds its own function with the same factory) and document ingest. The user argument was already threaded there by upstream for ENABLE_FORWARD_USER_INFO_HEADERS, which is what makes this a header injection rather than a plumbing change: the identity was already present, it was simply never turned into a credential the gateway could bill against.

This lands in deploy/docker/owui-patches/, not in vendor/open-webui, because the chat image builds only the frontend from the vendored tree and takes the backend from the pinned image, so a backend edit under vendor/ would be inert. The splice literal was verified against the pinned image itself (ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6...), where it occurs exactly once, and the patch was applied to that extracted copy as well as to the vendored one.

Coordinating with PR #1699 rather than inventing a second scheme

PR #1699 charges the Go web_search and web_fetch tools per call through sessionbilling. One attribution mechanism does serve both, and this uses it: the hold, the refusals and the single terminal state all come from internal/sessionbilling, the same lifecycle session chat, RAG chat, the agent-task gate and now the web tools settle through. What differs is only the unit, and it differs for a real reason rather than by preference: a web tool call is a per-call charge against an internal price-carrier alias with no route, while an embedding is token metered against a real catalog alias with a route, so this path charges with inference.CreditsForTokens (D-031's per-million arithmetic) and takes sessionbilling.Reserve, not ReserveCharge.

The chokepoint differs for the same reason #1699 gives: rag.HTTPEmbedder.EmbedBatch is edge-api's own outbound embedder to LiteLLM and sees none of this traffic. The gap it covers, unmetered RAG ingest and query embeddings, is issue #1644 and stays open. This PR does not touch it.

Bounds, stated because this is the money path

No customer may be charged for another customer's search. The principal is read from auth.UserFrom, which only the JWT middleware populates, and the tenant it carries is what sessionbilling resolves a billing account from. TestTwoTenantsAreChargedSeparately drives two searches through one handler with two billable tenants and asserts the two charges land on the two different accounts. Mutating TenantID: user.TenantID to a constant turns it red, which was confirmed rather than assumed.

No spend may silently land on a platform account after this change, and the paths that still do are named. Embeddings no longer can: a shim-key call with no carrier is refused at the middleware, and a session call with no principal, no billing account, an unreadable billing position or an unwired accounting seam is refused in the handler, each with a test asserting the upstream recorded zero dispatches.

Three paths still bill a platform account. Two are audio and inside the shim family, one is outside it and was found by the independent review; none of them is silent any more:

The two audio ones are named in requiresPerUserAuth's own comment, in checkOWUIShimKey's, and in a test that asserts they still pass through, so the next reader finds them rather than rediscovering them. Filed as issue #1713. GET /v1/models also presents the shim key and carries no spend at all.

One more, not a platform account but the same family of quiet absence: an embeddings 200 carrying a vector document and no usage block settles at zero on both arms and is served free, with one log line as the only signal. Pre-existing, confirmed on both paths during review, filed as #1724.

Existing chat behaviour must not break. A user who can chat today already has a resolvable OAuth session, because /v1/chat/completions has required the same credential since #1567 and would otherwise 401. Web search happens inside a chat turn, so requiring that same credential for the search's embeddings breaks nothing that works. A customer calling /v1/embeddings with their own hk_ key is untouched: requiresPerUserAuth is only ever consulted when Authorization is exactly the shim key, and TestOWUIUnwrap_EmbeddingsWithARealAPIKey_PassesThrough holds that in place.

The one behaviour that does change for a solvent user is which balance is consulted, which is the point of the issue.

What the carrier may be forwarded to, and what this PR does not keep

Added after the independent security review, which found both of these.

A user credential only ever goes to the gateway named in the environment. agenerate_openai_batch_embeddings takes its destination from app.state.config.RAG_OPENAI_API_BASE_URL, and POST /api/v1/retrieval/embedding/update lets any instance admin rewrite that at runtime. On this shared chat instance every tenant OWNER is an instance admin. Before this PR that knob leaked one shared platform key; attaching a per-user session bearer to the same knob without a check would have turned it into a cross-tenant session harvest, which is a strict escalation and the exact outcome the brief calls worse than the billing defect. attach therefore compares the destination's origin against RAG_OPENAI_API_BASE_URL and OPENAI_API_BASE_URL read from os.environ, never from the persistent config, and refuses anything else before a credential is even resolved. Same property hive_agent_proxy.py already relies on. Proved inside the pinned image with the gateway and the hostile destination on genuinely different origins.

Two producers that had no user now carry one. embed_knowledge_base_metadata (six callers) and the query_knowledge_bases builtin both called the embedding function with no user, so the new refusal would have broken them, and the first fails inside its own swallowed except: knowledge base metadata embedding would have stopped working with no visible error and the admin reindex would have reported zero of N. Both are threaded through the patch rather than left broken or merely named. Fixing mis-attribution by silently breaking ingest is not a trade this PR makes.

The rate limit that made #1609 visible is gone on this path, and the credit hold is what replaces it. authz.NewLimiter is reachable only through inference.Orchestrator.Authorize, which resolves an hk_ key, so JWT session traffic never touches it. #1609's burst was rate limited because those calls carried the shim key; after this PR they carry a user JWT and nothing rate limits them. What bounds them instead is the per-call DefaultHoldEmbeddings hold against the searching customer's own balance, which is a real bound and arguably the more useful one for an expensive burst, and which bounds a solvent tenant not at all. What makes that survivable is the other half of #1609: RAG_EMBEDDING_BATCH_SIZE defaults to 100 rather than 1, so a five page search is a handful of requests per turn. If that default is ever taken back to 1, this path loses its rate limit and regains its burst on the same day.

The per-tenant budget motivation in #1696 is only half delivered, and this PR does not claim otherwise. Control-plane budget windows are keyed by API key id, so a JWT settled charge still does not reach them. What is delivered, and it is the important half, is that the spend lands on the correct billing account and consumes the correct balance, which makes the account level cap and the customer's own usage correct.

Fail closed, per D-034

Six refusals, all before any provider is reached, each with a test asserting the upstream recorded zero calls:

Condition Verdict
Shim key, no per-user carrier on /v1/embeddings 401 at the middleware, nothing forwarded
No session principal on the request context 401, no hold taken
Tenant has no billing account 403 billing_not_configured
Billing position cannot be read 503, not served
Accounting seam not wired 503 billing_unavailable
upstream_actual alias refused: CreditsForTokens answers zero by design for that mode and an embeddings response has no content to price instead, so serving one would charge nothing while looking completed

The Python half raises rather than degrading when no user resolves. That raise is redundant with edge-api's refusal and is kept anyway, because it names the real cause in the chat container's own log, which is the one place the gateway's 401 cannot explain itself.

The hold reaches a terminal state exactly once: charged, or released by the deferred call on every other exit, never both. TestReleasesTheHoldWhenTheUpstreamFails asserts one hold and one release with no charge.

Proof: a real ledger, read out of the database

Live stack, a real chat account, a real search, and the balances read back from Postgres rather than from a log line. Figures and method are in the proof comment on this PR.

Tests

  • apps/edge-api/internal/embeddings/handler_test.go: attribution to the searching account with the exact charge magnitude (1,500 prompt tokens at 2,000,000 credits per million is exactly 3,000 credits, so a settlement that priced the wrong quantity fails even though "a charge happened" would pass), two tenants charged separately, the six refusals above, the hold released on a failed upstream, the Enterprise posture served unheld, and the response carrying the Hive alias rather than the provider.
  • apps/edge-api/internal/auth/owui_unwrap_test.go: the refusal without a carrier, the rewrite with one, an hk_ customer key passing through untouched, and a non-JSON shim-key body on this path still refusing. Confirmed RED against the pre-change predicate: the request was forwarded with a 200 and the shim key intact.
  • scripts/test_owui_embed_attribution.py: the module driven for real against a stubbed resolver (carrier attached, refusal when no user resolves, no mutation of the caller's headers, no cross-user cache leak, no caching of a failure), the splice asserted by AST to sit inside the embedding function and before the POST, and the Go half, the compose wiring and the image build asserted to ship together.
  • scripts/test_owui_task_upstream_auth.py's requiresPerUserAuth guard was a frozen copy of the whole function body, so it went red on a change that NARROWS what the shim key may do. Rewritten to assert every required path is still listed, which still fails on a removal (the shortcut it exists to catch) and does not fail on an addition.

Green: go vet ./apps/edge-api/..., go test ./apps/edge-api/... -count=1 -short, and make test-scripts.

No UI surface changes, so the visual proof rule does not apply to the diff itself; the ledger proof above is the evidence for the behaviour claim.

Buglog entry

{"id":"bug-2026-09-02-chat-search-billed-to-shim","date":"2026-09-02","title":"chat web search embeddings were billed to the shared OWUI shim account instead of the user who searched","error_message":"none: the failure was silent by construction. Open WebUI's Python retrieval path posted to edge-api /v1/embeddings with RAG_OPENAI_API_KEY, which is OWUI_SHIM_KEY, so every hold and every charge resolved to that key's account and the searching customer's usage showed nothing.","root_cause":"OWUI's embedding calls carry no request body, so the __metadata.upstream_auth carrier that attributes a chat completion cannot reach them, and requiresPerUserAuth in apps/edge-api/internal/auth/owui_unwrap.go deliberately excluded /v1/embeddings for that reason: the shim key was treated as the intended credential there. The header carrier X-Hive-Upstream-Auth already existed for the same problem on bodyless agent-task calls and was never applied here. Compounding it, edge-api had no JWT-session embeddings handler at all, so even a correctly attributed request could not have been served.","fix":"Add /v1/embeddings to requiresPerUserAuth so a shim-key call with no per-user token is refused rather than billed to the shim. Add apps/edge-api/internal/embeddings, a JWT-session handler that holds, charges and settles through the existing sessionbilling lifecycle at the alias's catalog token price. Attach the signed-in user's access token to X-Hive-Upstream-Auth inside agenerate_openai_batch_embeddings via deploy/docker/owui-patches/apply_embed_attribution_1696_patch.py, raising rather than falling back to the shim when no user resolves.","tags":["billing","money-path","attribution","fail-closed","open-webui","web-search","embeddings","edge-api","issue-1696"]}

… account (issue #1696)

Open WebUI's Python retrieval path is configured with RAG_OPENAI_API_BASE_URL
pointing at edge-api and RAG_OPENAI_API_KEY set to OWUI_SHIM_KEY, so every web
search index, every document ingest and every retrieval query reached Hive's own
metered gateway as one shared platform principal. The spend was real and it was
metered, and all of it settled against that account.

Two halves, both required.

edge-api: /v1/embeddings joins requiresPerUserAuth, so a shim-key embeddings call
that carries no per-user token is refused rather than billed to the shim, and one
that does carry a token is rewritten onto it. A new internal/embeddings package
then serves the JWT-session request, because inference.Orchestrator resolves only
an hk_ key. It holds, charges and settles through the existing sessionbilling
lifecycle, prices with inference.CreditsForTokens and normalizes with
inference.NormalizeEmbeddings, so this adds no second money path.

open-webui: a build-time splice attaches the signed-in user's own access token to
the X-Hive-Upstream-Auth carrier inside agenerate_openai_batch_embeddings, the
single function every embedding on this deployment leaves through. It raises
rather than falling back to the shim key when no user resolves.

Customer API keys are untouched: requiresPerUserAuth is only ever consulted under
the shim key.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@coderabbitai

coderabbitai Bot commented Sep 2, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 6 minutes.

Check out review usage here.

View limit details

Limit details: You’ve used the included review currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Team

Run ID: e160d286-2cde-4b3b-a1e7-0ec66e40be21

📥 Commits

Reviewing files that changed from the base of the PR and between b89a15f and e64509c.

⛔ Files ignored due to path filters (2)
  • docs/proof/issue-1696-chat-search-attribution/chat-container-carrier.log is excluded by !**/*.log
  • docs/proof/issue-1696-chat-search-attribution/ledger-before-and-after.log is excluded by !**/*.log
📒 Files selected for processing (16)
  • Makefile
  • apps/edge-api/cmd/server/main.go
  • apps/edge-api/internal/auth/owui_unwrap.go
  • apps/edge-api/internal/auth/owui_unwrap_test.go
  • apps/edge-api/internal/embeddings/handler.go
  • apps/edge-api/internal/embeddings/handler_test.go
  • apps/edge-api/internal/inference/embeddings.go
  • apps/edge-api/internal/inference/embeddings_test.go
  • apps/edge-api/internal/inference/sync_unconfirmed_usage_test.go
  • apps/edge-api/internal/inference/usage_identity_test.go
  • deploy/docker/Dockerfile.open-webui
  • deploy/docker/docker-compose.yml
  • deploy/docker/owui-patches/apply_embed_attribution_1696_patch.py
  • deploy/docker/owui-patches/hive_embed_attribution.py
  • scripts/test_owui_embed_attribution.py
  • scripts/test_owui_task_upstream_auth.py

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

…1696 ledger proof

Finalize is a synchronous control-plane call bounded at the settlement timeout
and retried once, so settling before the write put up to two of those in front
of a customer who had received nothing. internal/rag states the same reason at
the identical point. Open WebUI embeds a batch at a time and issues several of
these per search turn, so the latency is multiplied rather than paid once.
TestTheResponseIsOnTheWireBeforeTheChargeIsSettled holds the ordering in place
through billingtest.OnFinalize, which is the only point that can observe the
two relative to each other.

Also refreshes the OWUI_SHIM_KEY comment in docker-compose.yml, which still
carried the pre-fix story that embeddings authenticate and bill as that key
(CodeRabbit finding), and commits the two proof captures.
@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

Proof: the ledger, before and after, read out of Postgres

Not a log line and not a boolean. A throwaway Postgres with all 132 migrations applied by scripts/ci-throwaway-db.sh, control-plane and edge-api built from source, and edge-api built twice: once from origin/main and once from this branch, both pointed at the same database. Two stand-ins and only two, a LiteLLM that answers /v1/embeddings with prompt_tokens: 1500 and an HTTPS JWKS the real Supabase validator fetches and verifies. Nothing about attribution is stubbed.

The searching user's JWT deliberately carries no tenant_id claim, which is the shape Open WebUI's SSO tokens actually have, so the tenant is resolved through the real OWUI-scoped tenant fallback rather than handed to the handler.

hive-embedding-default is priced at 10,000 credits per million input tokens in the seeded catalog, so 1,500 tokens is exactly 15 credits, and 15 is what each leg is checked against.

Leg Request Result Searching customer OWUI shim account
baseline 999,999,970 999,999,985
1. before (origin/main) shim key, no carrier: byte for byte what the chat container sends today HTTP 200 999,999,970 (unchanged) 999,999,970 (−15)
2. after (this branch) the same request, still no carrier HTTP 401 999,999,970 (unchanged) 999,999,970 (unchanged)
3. after (this branch) shim key plus X-Hive-Upstream-Auth with the user's own token HTTP 200 999,999,955 (−15) 999,999,970 (unchanged)

Leg 1 is the defect, reproduced on a real ledger: the platform account paid for the customer's search. Leg 2 is the fail-closed rule: a call that cannot say who it belongs to is refused, and neither balance moves. Leg 3 is the fix: the same 15 credits, on the customer.

The usage_events rows follow the money. After leg 3 the searching customer has two rows (reservation_created, then completed with hive_credit_delta -15, input_tokens 1500, output_tokens 0) on endpoint=embeddings, model_alias=hive-embedding-default. That is what a customer's own usage page reads, and before this change it had nothing on it.

Every hold reached a terminal state exactly once across all three legs: each reservation_hold of −10,000,000 (DefaultHoldEmbeddings) is matched by one usage_charge and one reservation_release of the full hold, never both a charge and a kept hold, never neither.

The chat container half, run inside the pinned image

The patch was applied to /app/backend/open_webui/retrieval/utils.py inside ghcr.io/open-webui/open-webui:v0.10.2@sha256:9fcea9c6..., which is where the chat image takes its backend from, and agenerate_openai_batch_embeddings was then imported and called for real. Only Open WebUI's own OAuth session resolver and the upstream endpoint were stood in for.

--- call 1: a signed-in user ---
Authorization         : Bearer hk_REDACTED_PROOF_FIXTURE
X-Hive-Upstream-Auth  : Bearer SEARCHING-USER-ACCESS-TOKEN
--- call 2: a user with no resolvable session ---
refused: hive (#1696): no signed-in user credential for this embedding call
request reached the upstream: False

The shim key stays on Authorization, which is what gates the carrier at edge-api, and the searching user's own token rides on the carrier. A user with no resolvable session is refused before the request is made, so nothing goes out under the shared key.

Both captures are committed under docs/proof/issue-1696-chat-search-attribution/, so npm run lint:proof-tokens scans them; it reports ok over 291 files. The shim key literal in the logs is a throwaway fixture on a database that no longer exists and is redacted anyway.

No UI surface changes in this diff, so the screenshot rule does not apply; the ledger is the evidence for the behaviour claim.

@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

Adversarial review, pipeline mode

Streams that ran, and the ones that did not, stated rather than left as an absence.

CodeRabbit CLI: RAN, one finding, addressed

coderabbit review --base main, 16 files, one minor finding.

deploy/docker/docker-compose.yml:1505-1518 Update the OPENAI_API_KEY note, which now contradicts this block. Line 1513 lists "document-RAG embeddings (RAG_OPENAI_API_KEY below)" among the calls that authenticate "as this key and nothing else", and line 1514 says an unregistered value "breaks all three". apps/edge-api/cmd/server/main.go lines 1483-1488 were revised to draw this distinction; this file was not.

Valid, and exactly the failure this repository has paid for before: a stale comment that a later reader believes. Fixed in e566c44. The block now says embeddings still PRESENT the key, which is what gates the carrier, and are BILLED to the signed-in user, so the key resolving is necessary for them rather than sufficient.

Security review: RAN, performed directly, no agent dispatch available

Stated plainly: this reviewer has no Skill or Agent tool in its toolset, so the security-reviewer and database-reviewer agent dispatches the contract asks for could not be made. That is a capability gap, not a decision to skip, and it is reported rather than papered over. What follows is the pass performed directly against the pushed diff, and it should be treated as one reviewer rather than as the independent dispatch.

The carrier is a verified credential, not an asserted identity. The token on X-Hive-Upstream-Auth is validated by the same SupabaseJWTValidator every session bearer goes through, against JWKS over HTTPS. The proof above exercised that: a real RS256 token, a real JWKS fetch, and the tenant resolved from the token's own sub. Nothing in this change lets a caller name the account it wants billed. The obvious cheaper design, an X-OpenWebUI-User-Id style assertion honoured under the shim key, was rejected for exactly this reason and is worth saying so, because it is the shortcut the next change here will be tempted by.

A client cannot smuggle the carrier. OWUIUnwrap deletes X-Hive-Upstream-Auth from the inbound request on every branch, before any handler, log or audit sink, including the branches that reject without calling next. That is pre-existing and untouched (owui_unwrap_header_test.go), and it is what makes the header safe to trust from our own forwarder and impossible to trust from anyone else. It is also honoured only when Authorization is exactly the shim key, which is unchanged: hasShimAuthorization still compares the whole token.

The change to requiresPerUserAuth can only refuse more. Adding a path narrows what the shim key may do alone. TestOWUIUnwrap_EmbeddingsWithARealAPIKey_PassesThrough pins that a paying customer's own hk_ key is untouched, because that predicate is only ever consulted under the shim key.

No confused deputy downstream. The user's token never leaves edge-api: Dispatch is litellmClient.Embeddings, which sets the LiteLLM master key on its own request. The customer's alias is rewritten to the route's LiteLLM name by rewriteModel, and the response's model is rewritten back to the Hive alias by NormalizeEmbeddings, so no provider identity reaches the customer (asserted by TestResponseCarriesTheHiveAlias).

No credential reaches a log. The Python module logs user.id and nothing else on the refusal path; the token is never formatted into a message. The Go handler logs the alias, the request id and the tenant, never the bearer.

The token cache is the one piece of new shared state, and it is bounded. Keyed by user id, per process, 30 second TTL against a resolver that already refreshes five minutes before expiry, capped at 512 entries with a hard reset past that. A failed resolution is deliberately not cached, so one transient database error is not thirty seconds of refused embeddings for that user. scripts/test_owui_embed_attribution.py asserts both the no-cross-user-leak property and the not-caching-failures property.

Input bounds. The request body is read through a LimitReader at 8 MiB with an explicit 413 past it, under the 16 MiB the unwrap middleware already allows in front of it. The upstream response is bounded at 32 MiB, sized above the 11.7 MB a 256-chunk batch at a native 4096 dimensions measures, so a legitimate large batch is not refused and an unbounded one cannot be buffered.

Residual, named rather than left to be found. /v1/audio/speech and /v1/audio/transcriptions still authenticate and bill as the shim account (AUDIO_TTS_OPENAI_API_KEY and AUDIO_STT_OPENAI_API_KEY are both OWUI_SHIM_KEY). Filed as #1713, named in requiresPerUserAuth's own comment and in a test that asserts they still pass through, so the next reader finds them.

Database review: RAN, performed directly, same caveat

No schema change. Nothing in this diff touches supabase/migrations.

Per-request cost. Two additional indexed single-row reads on the path that previously took none: PGBillingAccountResolver.ResolveState (tenants LEFT JOIN tenant_billing_accounts on the primary key) and, because Open WebUI's SSO tokens carry no tenant_id claim, TenantFallback.Resolve (tenant_users JOIN tenants, covered by tenant_users_user_idx, bounded at 3 seconds). Both are the same reads the session chat path already makes per turn.

Volume, checked rather than assumed. The issue describes roughly 200 embedding calls per search, which would have made this change roughly 400 extra indexed reads per search on a pool that already couples CI, agents and live chat. It is not 200 any more: RAG_EMBEDDING_BATCH_SIZE has defaulted to 100 since the other half of #1609, so a five-page search is a handful of requests per turn. Worth writing down, because if that default is ever taken back to 1 the cost of this path changes by two orders of magnitude, and nothing else would say so.

Ledger writes are unchanged in shape. One reservation_hold, one usage_charge, one reservation_release and two usage_events rows per call, exactly what the API-key path already wrote. The only column that changes is account_id, which is the whole point. Verified live: 17 ledger rows across three legs, every hold matched by exactly one terminal outcome.

Reporting reachability. usage_events.account_id now carries the customer, so per-tenant usage, the customer's own usage page and the per-tenant budget all see the row. Before this change the row existed on an account no customer can see.

No RLS surface change. edge-api connects as the app role and reads two tables it already reads on the chat path.

Streams NOT run, and why

  • ecc:code-review and a plain adversarial pass as separate agent dispatches: SKIPPED, no Agent tool in this reviewer's toolset. The plain adversarial pass was performed directly and is folded into the two sections above.
  • /codex:adversarial-review: SKIPPED, same reason.

Treat the two direct passes as one reviewer's work, not as the independent dispatch the contract asks for. An independent pass on the pushed diff is still worth having before merge.

Comment thread deploy/docker/owui-patches/hive_embed_attribution.py
Comment thread deploy/docker/owui-patches/apply_embed_attribution_1696_patch.py Outdated
Comment thread apps/edge-api/internal/auth/owui_unwrap.go
Comment thread apps/edge-api/cmd/server/main.go
Comment thread apps/edge-api/internal/embeddings/handler.go
Comment thread apps/edge-api/internal/embeddings/handler.go
@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

Independent security and money path review

First independent eye on this diff. The implementer had no Agent tool, so the mandatory security reviewer and database reviewer streams were marked SKIPPED and performed by the author on its own work; the CodeRabbit bot and the Codex reviewer both hit usage limits on this PR and posted rate limit notices rather than reviews, and the one CodeRabbit finding on record came from the author running the CLI locally. This pass exists to close that gap. Reviewed as a security change first and a billing change second, per the brief.

Verdict: request changes. One HIGH security finding and one MEDIUM regression, both with concrete fixes posted inline. Everything else checked out, and the money path work is genuinely good.

The forwarded user token

It cannot leak through edge-api. OWUIUnwrap deletes X-Hive-Upstream-Auth in place on every branch before any handler, rejection paths included, so no handler, log or audit sink sees it. The token then sits on Authorization, and nothing in edge-api logs that header. It does not travel upstream: LiteLLMClient.dispatch builds a fresh request and sets Authorization: Bearer <master key>, so LiteLLM and the provider never see the user credential. WriteProviderBlindUpstreamError sanitizes what reaches the customer and the operator log carries only the upstream body, which never contained the token.

It can leak on the chat container side, and that is the HIGH finding. attach never looks at where the request is going, and the destination is RAG_OPENAI_API_BASE_URL read from Open WebUI persistent config, which POST /api/v1/retrieval/embedding/update lets any instance admin rewrite at runtime. On this shared instance every tenant OWNER is an instance admin, which apply_router_authz_family_patch.py states in its own header. One tenant owner can therefore point the embedding endpoint at a host they control and collect one live Supabase session token per user who searches. Before this PR the same knob leaked a single shared platform key; afterwards it leaks per user session bearers, which is a strict escalation. Detail and a short fix are in the comment on hive_embed_attribution.py, and the one line splice change is on apply_embed_attribution_1696_patch.py. Note that the existing carrier user, hive_agent_proxy.py, reads its destination from the environment and does not have this exposure.

Is a legitimate caller broken by the new refusal

Yes, two, and neither is named in the PR. embed_knowledge_base_metadata (routers/knowledge.py:76) and the knowledge base search builtin (tools/builtin.py:2741) both call EMBEDDING_FUNCTION with no user, so they now raise on the Python side and would 401 at the gateway even without that raise. The first fails inside its own swallowed exception, so knowledge base metadata embedding stops working with no visible error and the admin reindex endpoint reports zero of N. Fix or naming, either is acceptable, but not neither. Detail in the comment on owui_unwrap.go.

The paths the PR does claim are fine. Web search indexing, document ingest and retrieval queries all carry a user. A user with no resolvable OAuth session cannot chat today either, since attach_upstream_auth degrades to the same 401 (hive_upstream_auth.py:106), so that claim holds. A customer calling /v1/embeddings with their own hk_ key is untouched, because requiresPerUserAuth is only consulted under the shim key.

Fail closed and the hold lifecycle

Verified rather than taken on trust. Six refusals all land before dispatch. Finalize and Release each build their own context.Background() with the settlement timeout, and the hold is taken on context.WithoutCancel(ctx), so a client disconnect can cancel neither. Every exit between Start and the Finalize that sets settled reaches the deferred release with a distinct reason, including the finalize_failed fall through, so exactly one terminal state. I could not construct a stranded hold. The upstream_actual refusal is correct and, as a side effect, it makes the missing EnforceVariablePriceBounds call harmless, since only that mode sizes a hold from the body.

The reorder

Cannot drop a charge, for the context reasons above. TestTheResponseIsOnTheWireBeforeTheChargeIsSettled does go red if the order is restored, so it is a real guard. It slightly overclaims in its name: a ResponseRecorder has no wire, and for a body under about 2 KiB net/http holds everything until the handler returns anyway, so the property is true by payload size rather than by construction. One optional Flush fixes that. Comment on handler.go.

The remaining platform paths

The list of shim billed paths is complete. Only four Open WebUI upstreams point at edge-api (docker-compose.yml lines 1493, 1560, 1787, 1807), and after this PR they are chat plus models, embeddings, STT and TTS. The two audio ones are #1713. GET /v1/models carries no reservation and no charge, so the no spend claim holds by reading; there is no ledger evidence for it and none is needed.

Outside the shim family, one more platform absorbing path that nothing tracks as far as I can see: HIVE_AGENT_ENGINE_LLM_API_KEY is a single static key on the host launcher, so every sandboxed agent's inference settles against whichever account owns it, exactly the shape of this issue on a different surface. Not this PR's scope. Raising it because the brief asked whether the list was complete or merely the part that was found.

Patch mechanism

Deterministic and loud. The patch asserts the literal occurs exactly once and the marker zero times, the Dockerfile re-checks the marker count is one and AST parses both files, so a digest bump that moves the literal fails the image build. The caveat is where it first runs: Docker build smoke is fork PRs only and skipped here, so the assertion's first real execution is the demo box deploy.

Smaller notes

The rewrite of the requiresPerUserAuth guard in test_owui_task_upstream_auth.py is the right call, and it does still fail on a removal. It no longer pins the shape, though: an early return false inserted above the return statement would leave all four literals present in the extracted body and pass. Cheap to close by also asserting the body contains no return false.

The proof comment is strong work and I read it rather than skimmed it. Two edge-api binaries against one database, real migrations, real JWKS validation, balances read from credit_ledger_entries with the same SQL GetBalance runs, and the shim account's balance shown not moving on leg 3. That is the shape this repository asks for. No credentials in either committed log.

@sakibsadmanshajib sakibsadmanshajib added internal No customer visible effect. money-path Touches billing, credits, pricing or the ledger. priority:critical Demo blocker or live outage. Drop everything. labels Sep 2, 2026
…ation, and thread the two producers that had no user (PR #1712 review)

HIGH, from the independent security review. `attach` put the signed-in user's
Supabase access token on the carrier without looking at where the request was
going, and that destination is `app.state.config.RAG_OPENAI_API_BASE_URL`, which
`POST /api/v1/retrieval/embedding/update` lets any instance admin rewrite at
runtime. On this shared chat instance every tenant OWNER is an instance admin,
so one tenant owner could repoint the embedding endpoint and collect a live
session bearer from every other user who ran a search. Before the carrier
existed the same knob leaked one shared platform key; attaching a per-user
credential to it without a destination check would have been a strict
escalation, and closing it belongs in the change that introduces the credential.

The destination is now compared against the ENVIRONMENT, never against the
persistent config, which is the same property `hive_agent_proxy.py` relies on
when it reads its own destination from `os.environ`. The check runs before a
credential is resolved, so a hostile destination cannot even cause a token to be
minted or a cache entry to be filled. Compared as an origin rather than as a
whole URL, because the path differs legitimately and the host and port are what
decide who receives the credential.

MEDIUM, from the same review. Two producers call the embedding function with no
user at all, so the new refusal broke them: `embed_knowledge_base_metadata`,
whose six callers all reach it inside a swallowed `except`, so knowledge base
metadata embedding would have stopped working with no visible error and the
admin reindex would have reported zero of N, and the `query_knowledge_bases`
builtin. Every call site already has the identity in scope, so both are threaded
rather than left broken. The builtin carries `__user__` as a plain dict, which
the module resolves through Open WebUI's own model layer rather than wrapping in
a stand-in that assumes what the resolver reads.

Also taken from the review, both optional there: the response is flushed after
the write so the settle-after-respond ordering holds by construction rather than
by payload size, and the guard test is renamed to say written rather than on the
wire, since a ResponseRecorder has no wire. The `requiresPerUserAuth` guard in
`test_owui_task_upstream_auth.py` now also rejects an early `return false`,
which would otherwise have left all four literals present and the predicate
exempt.
@sakibsadmanshajib

Copy link
Copy Markdown
Owner Author

Review round two: both blockers addressed

Pushed as e64509c. Thank you for the pass; the HIGH is a real escalation I introduced and did not see, and the MEDIUM is the exact trade the brief warned against.

HIGH, destination check. attach now takes url and refuses any destination whose origin is not in the set read from RAG_OPENAI_API_BASE_URL and OPENAI_API_BASE_URL in os.environ, never from app.state.config. The refusal runs before the user is resolved and before the token cache is touched, so a hostile destination cannot cause a credential to be minted. Compared on origin, so a different path on the gateway is allowed and a different host, port or scheme is not. Five refusal shapes plus a no-credential-was-resolved assertion are pinned in scripts/test_owui_embed_attribution.py, and the "reads the environment, never the config" property is asserted on the AST of _gateway_origins rather than on the file's text, because the module has to name app.state.config in prose to explain the threat.

MEDIUM, the two producers with no user. Both threaded rather than named. embed_knowledge_base_metadata gains a user parameter and all six call sites hand one over; the query_knowledge_bases builtin passes __user__, which the module resolves through Users.get_user_by_id rather than wrapping in a stand-in that assumes what get_system_oauth_token reads. The patch script now asserts an expected COUNT per anchor, because two of the knowledge.py anchors are byte-identical pairs and a silent drop from two to one is how half a feature stays broken behind a green build. The Dockerfile pins all three marker counts.

Optional items, both taken. Flush after the write so the ordering holds by construction rather than by payload size, and the guard test renamed to say written rather than on the wire with its doc comment stating what a ResponseRecorder can and cannot observe. The requiresPerUserAuth guard in test_owui_task_upstream_auth.py also rejects an early return false now.

PR body updated with the three things you asked be stated rather than discovered: the loss of the authz rate limiter on this path and that the credit hold is what replaces it, the dependence of that on RAG_EMBEDDING_BATCH_SIZE staying at 100, and that the per-tenant budget motivation in #1696 is only half delivered because control-plane budget windows are keyed by API key id.

Two issues filed for the notes you offered to raise: #1723 for HIVE_AGENT_ENGINE_LLM_API_KEY, which is this issue's shape on the agent surface, and #1724 for the embeddings 200 with no usage block being served free on both arms with one log line as the only signal.

Re-proof

The chat-container capture was re-run inside the pinned image against the new patch, with the gateway on one port and the host a hostile admin repointed at on another, so the two origins genuinely differ:

marker counts after the splice:
  retrieval/utils.py: 1
  routers/knowledge.py: 7
  tools/builtin.py: 1

--- 1. a signed-in user, to the gateway ---
Authorization         : Bearer hk_REDACTED_PROOF_FIXTURE
X-Hive-Upstream-Auth  : Bearer SEARCHING-USER-ACCESS-TOKEN
--- 2. the same user, after an instance admin repointed the persistent
       RAG endpoint at a host they control ---
refused: hive (#1696): the embedding endpoint is not the Hive gateway
request reached the attacker: False
--- 3. a user with no resolvable session, to the gateway ---
refused: hive (#1696): no signed-in user credential for this embedding call
request reached the upstream: False

Worth recording that the first run of leg 2 printed LEAK: the call went out anyway, and it was the harness rather than the code: I had pointed the "attacker" at a different path on the same port, which is the deliberately allowed case. The committed log is the corrected run.

The ledger proof stands unchanged. The only Go behaviour change in this round is the Flush, which moves no money; the before and after figures and the exactly-once hold lifecycle are the same run.

Green: go vet ./apps/edge-api/..., go test ./apps/edge-api/... -count=1 -short, and make test-scripts.

@sakibsadmanshajib
sakibsadmanshajib merged commit 234837c into main Sep 2, 2026
33 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/1696-chat-search-attribution branch September 2, 2026 17:31
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
…s merged

PR #1712 landed while this branch was open and moved Open WebUI's document RAG
embeddings onto the signed-in user's own token, so a dead shim key no longer
breaks them. What it still breaks is text-to-speech and speech-to-text, both of
which are OWUI_SHIM_KEY, plus the bodyless model listing. The alert summary and
description, the gauge's help text and the .env.example list all said RAG and
are corrected here, so the page an operator receives names what is actually
down. The promtool case was re-run against the new annotation text.
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
The PR was unmergeable, which is why GitHub built no refs/pull/1730/merge and
created no pull_request CI run at all for the last three commits: the required
checks were not failing, they were never triggered.

One conflict, in scripts/test_owui_task_upstream_auth.py. PR #1712 rewrote the
requiresPerUserAuth guard from a frozen copy of the whole function body into a
presence check over the paths, for exactly the reason this branch hit: a frozen
body cannot tell a removal (the relaxation the check exists to catch) from an
addition that narrows what the shim key may do. Main's shape is the better one
and is taken whole, with this branch's /v1/tools/ arm added to the list it
checks.

Verified after the merge: the vendored middleware still matches the pinned image
digest, every patch that writes middleware.py applies in Dockerfile order and
the result parses, and make test-scripts is green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WbVmp2Uh7FCgnqKB2TuBb5
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
…ge when it dies (issue #560) (#1734)

Closes #560

## The live state first, because the issue's headline claim needed
re-measuring

**The demo box's shim key is alive right now. This is not an active
outage.**

```
hive-demo-owui-shim | hive-demo-owui-shim-key | active | 2026-07-26 13:07:28+00 | NOT REVOKED
```

Read out of `public.api_keys` on the box, keyed on the sha256 of the
value in the box's own `.env`, and the running gateway agrees: `owui:
OWUI_SHIM_KEY resolves to an active Hive API key on a tenant-provisioned
account`. The correct invocation, since the issue asked for it to be
recorded and the self-hosted data plane publishes no host port:

```bash
cd ~/hive
raw=$(grep -E '^SUPABASE_DB_URL=' .env | head -1 | cut -d= -f2-)
eval "$(python3 scripts/derive-pooler-dsn.py --dsn "$raw" --emit-libpq-env | sed 's/^/export /')"
scripts/stack-psql.sh -tA -F'|' -c "select ... from public.api_keys ..." </dev/null
```

`scripts/derive-pooler-dsn.py --emit-libpq-env` for the libpq variables,
`scripts/stack-psql.sh` from the repository root on the box so psql
joins the stack's compose network, and stdin redirected because that
wrapper consumes it.

The key lives on `hive-demo-owui-shim`, its own billing account, not on
`owui-e2e-shim`, the account the nightly rotates. So the specific chain
the issue describes is currently broken in the box's favour, and PR #558
is why: it added `--account-slug` and deferred revocation until the
consumers are updated. What #558 did not do is make any of that
enforceable. The account boundary was a sentence in a docstring, and the
loud signal it added was a log line.

## What is actually still wrong

**The scoping is a convention, not a boundary.** Nothing stopped a run
from being pointed at CI's rotated account while a deployment carried a
key on it, and nothing stopped a run from revoking keys on a deployment
account without updating that deployment. Either one reopens the issue,
and the second reintroduces the exact failure #558 fixed.

**The signal is a log line.** `watchOWUIShimKey` probes at boot and
every five minutes and logs the verdict with its cause and remedy. That
is the right thing to read once you are already looking, and it is not a
signal: nothing tails a container log on a schedule, so a revocation at
03:00 is still discovered by a customer. Issue #1728, filed today, is
about this pattern in general.

## The fix, in three layers

**1. `assert_account_scope` in `scripts/seed-owui-e2e-user.py`,** called
as the first statement of `main()`, before a credential is read and
before the first request. It refuses both configurations in which this
script could revoke a credential something else is still carrying, so a
refused run costs nothing and in particular cannot mint a key it then
declines to finish wiring up. The invariant, stated once: a key that a
long-lived consumer carries may only ever be revoked by the run that
just replaced it in that consumer.

| Configuration | Verdict |
|---|---|
| Reserved CI account (`owui-e2e-shim`) with `--env-file` or
`OWUI_ADMIN_*` set | refused, exit 2 |
| Any other account with no consumer configured | refused, exit 2 |
| Reserved CI account, no consumer: the nightly's shape | allowed |
| Deployment account with a consumer this run updates: the documented
deployment shape | allowed |

The first row is the one that closes the hole for good. It makes "CI's
rotation account and a deployment's account are the same account"
unreachable through the supported path, rather than a mistake the
docstring asks you not to make.

**2. The stale-key cleanup is filtered to this script's own nickname.**
One line on the `DELETE /api_keys` params. It matters because of what
the proof turned up: the box's live key is nicknamed
`hive-demo-owui-shim-key`, not the `owui-e2e-shim-key` this script
writes, so it was minted by hand. Layer 1 governs every path through
this script; layer 2 covers the key nobody minted through it. A foreign
key on the account is now left active for an operator to revoke
deliberately, which is the safer of the two wrong answers available:
revoking a credential whose holder was never told is the whole of this
issue.

**3. `hive_owui_shim_key_usable`,** exported by the probe that already
exists, and read by the new `OWUIShimKeyUnusable` rule in
`deploy/prometheus/alerts.yml`.

Two properties, both deliberate. The gauge is **not registered at all**
when no shim key is configured, because a gauge sitting at its zero
value on a deployment with no chat front-end reads as "the key is dead"
to every alert; with no series, `absent()` can tell "not measured here"
from "measured and failing". And it is **not written at all** on a
transient probe failure, so it holds the last real verdict: an
unreachable control plane is not a verdict on the key, and paging
someone to rotate a working credential over a cold container is the
false alarm that gets an alert muted. It nearly happened live on
2026-08-14.

## How a human actually notices

Not by reading a log, and not by opening a dashboard nobody opens. The
chain was checked on the box rather than assumed:

* `edge-api:9102` is an active Prometheus target and healthy, with no
scrape error (`deploy/prometheus/prometheus.yml`, job `edge-api`).
* `hive-prometheus-1`, `hive-alertmanager-1` and `hive-grafana-1` have
all been up for over a week, and `--profile monitoring` is in the deploy
workflow's compose invocation, so this is not a profile someone has to
remember to start.
* The `hive-critical` group is loaded (`/api/v1/rules` reports 8 rules
in it today, 9 after this).
* `deploy/docker/docker-compose.yml` routes it to the `hive-ops`
receiver, whose `email_configs` sends to `ENTERPRISE_SMTP_ADMIN_EMAIL`
over the relay this stack authenticates against.
* The deploy workflow already asserts that every `- alert:` name in
`deploy/prometheus/alerts.yml` appears in Prometheus's `/api/v1/rules`,
so a rule that fails to load fails the deploy instead of going quiet.

**An email to the ops mailbox, roughly ten minutes after the key stops
resolving:** one five-minute probe interval plus the rule's six-minute
`for`.

The `for: 6m` is a deliberate trade. Shorter would fire on a single
missed probe; longer would push past the "visible within minutes" the
issue asks for.

## Interaction with PR #1712, which merged while this was in review

It landed as `234837cef` mid-flight, which is what made this branch
conflict: it edits the same comment block in
`apps/edge-api/cmd/server/main.go`. Rebased onto it and resolved in its
favour.

The interaction runs one way only, and every claim here survives it.
**#1712 narrows what the shim key is for.** Open WebUI's embeddings now
carry the signed-in user's token on `X-Hive-Upstream-Auth` and
`/v1/embeddings` joins `requiresPerUserAuth`, so document RAG no longer
depends on this key resolving. What still does, and is named in #1712's
own description as still settling against the shim account, is
text-to-speech and speech-to-text (`AUDIO_TTS_OPENAI_API_KEY` and
`AUDIO_STT_OPENAI_API_KEY` are both `OWUI_SHIM_KEY`), plus the bodyless
`GET /v1/models`.

So the silent-death surface shrinks from three features to two, and the
probe, the gauge and the alert stay both correct and necessary. What the
merge did make wrong was the wording: the alert, the gauge help text and
`.env.example`'s list of what this key authenticates all still said
document RAG. All three are corrected in the third commit, so the page
an operator receives names what is actually down. `promtool test rules`
was re-run against the new annotation text and still passes.

The seeder half is orthogonal to #1712 entirely.

## Tests

Written first, and each one confirmed red against the pre-change code
rather than assumed.

`scripts/test_seed_owui_e2e_user.py`:
* both refusals, across all three ways a consumer can be configured
(`--env-file`, `OWUI_ADMIN_TOKEN`, `OWUI_ADMIN_EMAIL` plus
`OWUI_ADMIN_PASSWORD`)
* both supported shapes still pass
* `main()` driven with a refused configuration and
`urllib.request.urlopen` replaced by a function that fails the test if
called, so "the guard runs before anything is minted or revoked" is
asserted rather than described. Deleting the `assert_account_scope` call
turns it red with `main() reached the network on a refused
configuration`.
* the revocation's params parsed out of the source by AST and asserted
to filter on nickname and account. Deleting the nickname filter turns it
red.
* a static guard that the nightly workflow still passes the reserved CI
account slug, because the seeder-side guard cannot catch a nightly
repointed at a deployment account (a GitHub runner configures no
consumer, so that run would be allowed).

`apps/edge-api/cmd/server/main_test.go`, all reading the gauge through a
real `Gather` rather than the collector, since a value that never
reaches `/metrics` is the same invisibility wearing a different hat:
* zero on a key that does not resolve
* held at its last value across repeated transient failures, then zero
when a real verdict arrives, then back to one on recovery
* no series exported at all without a shim key
* the alert rule reads the series, which is the assertion that keeps
this from becoming another unread metric

## Proof

`docs/proof/shim-key-revocation-560-2026-09-02/capture.md`, and the two
halves the issue asks for:

**The seeder runs without revoking the key.** Executed on the box
against the live database. Before: `active`, `NOT REVOKED`. The
invocation that could previously have revoked it (`--account-slug
hive-demo-owui-shim` with nothing to update) exits 2 with a refusal
naming the issue. The nightly's own shape is still allowed, passing the
guard and then dying on a deliberately unreachable database having
touched nothing. After: byte-identical row, still `active`, still `NOT
REVOKED`.

**The loud signal fires.** The rule was evaluated by Prometheus itself,
not asserted by inspection: `promtool test rules` over a series that is
usable for two minutes and then zero asserts nothing firing at 5 minutes
and exactly one `OWUIShimKeyUnusable` at 9 minutes with
`severity=critical` and the full annotation text. Mutating the 9-minute
expectation to "no alerts" turns it red, and that was run.

Green: `go vet ./apps/edge-api/...`, `go test ./apps/edge-api/...
-count=1 -short`, `make test-scripts`, `npm run lint:proof-tokens`,
`promtool check rules`.

No UI surface is touched, so the visual-proof rule does not apply to
this diff; the live database readings and the promtool evaluation are
the evidence for the behaviour claims.

## What this does not do

It does not restore or rotate any live credential, and it changed none.
If the box's key is ever found revoked, the remedy is in the alert's own
description and in `.env.example`: `python3
scripts/seed-owui-e2e-user.py --account-slug hive-demo-owui-shim
--tenant-slug <its tenant> --env-file .env` with `OWUI_BASE_URL` and
`OWUI_ADMIN_TOKEN` set, then recreate `open-webui`. Both consumers are
updated before the old key is revoked, and the guard added here now
refuses that command if either is missing.

It also does not revoke the leftover
`hive-717-diagnostic-2026-08-04-delete-me` key found on the box's
account during the proof. It is already revoked, and touching live
credentials is out of scope for this PR.

## Review round two (security review 5093605279, commit 62ebb40)

All five threads applied. What changed in the description above:

**Layer 2 is no longer a constant.** `key_nickname` derives the nickname
from the account slug (`SHIM_KEY_NICKNAME` for the reserved CI account,
`<slug>-key` otherwise), used at both the mint and the cleanup. The old
constant spared the box's `hive-demo-owui-shim-key` only by coincidence,
and the remedy documented here would have destroyed that coincidence on
first use, since the mint writes the nickname unconditionally. Derived,
it reproduces the box's existing name exactly, CI's cleanup and a
deployment's can never name the same key, and the remedy run revokes the
key it just replaced. A key minted under any other name is still left
alone, and `.env.example` and the alert now tell the operator to revoke
that one deliberately instead of leaving it active forever with nobody
tracking it.

**`--env-file` now has to prove itself.** It proved a rewrite, not that
the file belonged to the deployment carrying the key about to be
revoked, so `/tmp/scratch.env` or another deployment's `.env` reached
the revocation. The value the file held before the rewrite must now hash
to a key on this account, which is the machine check for the invariant
this PR states in prose. Plus: an empty `--account-slug` is refused
rather than minting on an empty-slug account, and the reserved-account
comparison is case insensitive.

**The three documents agree, on the right reasoning.** The alert said
document RAG was unaffected because resolution is "necessary but not
sufficient" for embeddings. It is neither: `hasShimAuthorization`
matches the header by string comparison and `forwardUnwrapped` swaps in
the per-user token before authz, so revocation is invisible on
`/v1/embeddings`. The empty model picker, the loudest symptom, is now
named in the alert, the log line and `.env.example` alike.

**An absent verdict and an absent scrape can no longer read as
healthy.** `hive_owui_shim_key_last_verdict_seconds` carries the time of
the last real verdict (never written on a transient failure, 0 until the
first one) and `OWUIShimKeyVerdictStale` pages at thirty minutes, which
closes the gap where the usable gauge sits at its initial 1 with nothing
ever measured. `EdgeAPITargetDown` (`up{job="edge-api"} == 0`) joins
`AlertmanagerDown` in `alerts/monitoring.yml`, since a target that stops
being scraped took every `hive-critical` rule quiet with it.

Verified: `promtool check rules` (10 rules in `alerts.yml`, 4 in
`monitoring.yml`) and a four-case `promtool test rules` run covering
both new rules and the discriminating negatives; `go vet` and `go test
./apps/edge-api/... -count=1 -short` green; `make test-scripts` green
with each new seeder guard confirmed red against a mutation of the code;
`lint:proof-tokens` clean. No live credential was read, changed or
revoked in this round, and the live rows recorded in the capture are
unchanged. Section 7 of
`docs/proof/shim-key-revocation-560-2026-09-02/capture.md` carries the
detail.

## Buglog entry

```json
{"id":"bug-2026-09-02-owui-shim-key-revocable-by-seeder","date":"2026-09-02","title":"a scheduled seeder run could revoke the shim key a long-lived deployment carries, taking document RAG and voice down with no signal","error_message":"none on the operator side, which is the defect: Open WebUI's document RAG embeddings, text-to-speech and speech-to-text answer a generic invalid-key error that names no cause, while sign-in, the model picker and chat completions stay healthy because they carry the signed-in user's own token.","root_cause":"Two gaps left open after PR #558. First, the account boundary that keeps the nightly OWUI rotation off a deployment's key was documented in scripts/seed-owui-e2e-user.py's docstring and enforced nowhere: nothing refused a run pointed at CI's rotated account while a deployment carried a key on it, nothing refused a run that revoked keys on a deployment account while updating no consumer, and the stale-key cleanup deleted any key on the account regardless of who minted it or who carries it. Second, the health probe added for the same issue wrote its verdict only to the edge-api container log, and nothing reads a container log on a schedule, so a mid-life revocation stayed invisible until a customer hit it.","fix":"Add assert_account_scope, called first in main() before any request, refusing the reserved CI account with a long-lived consumer configured and any other account with none, exit 2. Filter the stale-key DELETE to the script's own key nickname so a key minted by another route is never revoked. Export hive_owui_shim_key_usable from watchOWUIShimKey (registered only when a shim key is configured, and not written on a transient probe failure so it holds its last real verdict) and add the OWUIShimKeyUnusable rule to deploy/prometheus/alerts.yml, which routes through the already-delivering Alertmanager hive-ops receiver to the ops mailbox in about ten minutes.","tags":["credential-lifecycle","open-webui","shim-key","observability","alerting","silent-failure","edge-api","seeder","issue-560"]}
```
sakibsadmanshajib added a commit that referenced this pull request Sep 2, 2026
#1727)

Closes #1726

Implements the tracking discipline the owner asked for on 2026-09-02:
pull requests, milestones, the Kanban board and the labels are not being
maintained, so this writes the rule down and then wires it so it is
enforced rather than aspirational.

## The rule

`.claude/rules/tracking-discipline.md`, in the voice of its neighbours
in that directory, terse and imperative with each reason given once:

- **An issue exists before a fix does.** Every change starts from a
GitHub issue, including a one line fix noticed while doing something
else. A defect discovered mid task gets filed, not folded silently into
an unrelated pull request. The body links its issue with `Closes #N`, or
`Refs #N` when it delivers only part of it. Never `Closes` an issue
whose acceptance criteria are not all met, because an issue closed early
is how work is lost.
- **Exactly one priority label**, using the four that already exist in
the repository: `priority:critical` for a demo blocker or live outage,
`priority:high` for needed before the demo, `priority:medium` for real
work to schedule, `priority:low` for correct but not urgent. The retired
`priority:P0` through `priority:P3` set does not satisfy it, and is
rejected by name so it gets corrected rather than quietly counted.
- **At least one area label** from `demo-surface`, `money-path`,
`internal`. Their definitions live on the labels themselves and are
deliberately not copied into the rule.
- **A scheduled issue carries a milestone**, from the four open ones. No
milestone means unscheduled, which is legitimate; a critical or high
with no milestone is a tracking failure, because it claims urgency and
names no release that carries it.
- **Pull requests wear the labels of the issue they close**, so the
board reads the same from either side.
- **Priority is judged against the demo spine**: chat, embeddings, voice
to text, knowledge work, Cowork, the coding agent. Breaking one of those
on stage is critical regardless of diff size; an internal correctness
issue is not critical however elegant the fix would be.
- **The orchestrator re-triages every session**: unlabelled open issues,
priorities that no longer match reality, and pull requests older than
two weeks. A backlog that is not navigable by priority is the same as no
backlog.

## The enforcement

`scripts/check-pr-tracking.py` reads the pull request body and every
issue it links, and fails when:

1. the body links no issue, using the documented verbs. A bare `#N` in
prose is deliberately not a link, since bodies in this repository cite
issue numbers in passing constantly and counting those would pass
everything;
2. a linked issue carries no valid priority label, or carries two, or
carries only a retired one;
3. a linked issue carries no area label;
4. a linked `priority:critical` or `priority:high` issue has no
milestone;
5. the pull request does not carry the priority and area labels of an
issue it closes;
6. a link points at a pull request rather than an issue, or the target
cannot be read. An unreadable target fails rather than passing quietly.

`.github/workflows/pr-tracking-gate.yml` runs it on every pull request.
It is cheap by construction: checkout plus two python invocations, no
toolchain, no network beyond the GitHub API. It listens for `edited`,
`labeled` and `unlabeled` on top of the four types the merge gate
requires, because every way to fix a failure of this gate is one of
those three events, and a gate whose green state cannot be reached
without an unrelated commit gets worked around rather than satisfied.

It is deliberately not a step in `ci.yml` and not affected by that
workflow's docs-only allow-list. That list decides whether the heavy
suite runs; this gate has to run on a documentation-only pull request
too, since a documentation change needs an issue exactly as much as a
code change does. Folding it into `repo-policy-lints` would also mean
giving that job `issues: read`, which the rest of it has no business
holding.

## Can it be bypassed

Two answers, both honest.

**By branch protection, no, once the config is applied.** `PR is
attached to a triaged issue` is added to
`required_status_checks.contexts` in
`.github/branch-protection-main.json`, and
`.github/ci/lint-workflow-check-names.mjs` already verifies on every
pull request that the context has exactly one producer, in an `if:
always()` job, in a workflow with no trigger path filter that reacts to
`ready_for_review`. It reports seven required contexts green on this
branch. **That file is only the checked in copy.** Applying it to live
branch protection still needs the documented call, which the
orchestrator runs after merge:

```bash
gh api -X PUT repos/sakibsadmanshajib/hive/branches/main/protection \
  -H "Accept: application/vnd.github+json" \
  --input .github/branch-protection-main.json
```

Until that runs, the gate is red on a non-compliant pull request but
does not block the merge API. One operational note for when it does run:
an already-open pull request will not publish the new context until some
event fires on it, so nudge the open ones rather than assuming they are
stuck.

**By a determined author, yes, in one specific way.** The gate confirms
that an issue is referenced and that the issue is triaged. It cannot
confirm the link is honest: `Refs #N` pointing at any well labelled
issue passes. That gap is unfixable by a linter, which is why the rule
is addressed to the agent writing the pull request and the gate only
catches the accident. This is stated in the rule file itself rather than
left for someone to discover.

The two carve-outs are narrow and printed in the run log rather than
applied silently: Dependabot, which cannot file an issue and whose
updates would otherwise stall until a human wrote one, and a pull
request whose entire diff is `.wolf/buglog.jsonl`, which is the
buglog-only pull request `.claude/rules/openwolf.md` mandates for a fix
that already had its own issue. One extra file and the buglog carve-out
is gone, so it cannot be used to attach an unrelated diff to an exempt
path.

## Verification

- `python3 scripts/test_check_pr_tracking.py` passes. Every assertion
that matters is a negative one: an untracked body, an unlabelled issue,
a retired `priority:P1`, two priorities at once, a missing area label, a
critical with no milestone, a pull request missing its closed issue's
labels, a link pointing at a pull request, an unreadable issue. It is
registered in `make test-scripts`, so it runs inside the required `Repo
policy lints (tenant + audit)` job and the comparator cannot silently
stop being able to fail.
- `node .github/ci/lint-workflow-check-names.mjs` reports `Merge-gate
integrity OK: 36 check names across 15 pull-request workflows, 10
required contexts each published by exactly one always()-running job in
a workflow with no trigger path filter that reacts to every pull_request
type the merge gate needs.`
- Run against three live pull requests, not fixtures. #1714 exits 0 as
an exempt Dependabot update. #1712 and #1709 each exit 1 with the real
finding that they close a labelled issue whose labels they do not carry,
which is precisely the drift the owner named.

## Review follow ups

Four findings from the CodeRabbit pass, all taken:

- A full issue URL was matched for its number alone, so `Closes
https://github.com/someone-else/repo/issues/7` passed whenever this
repository had a triaged issue 7. `links()` now takes the repository it
is validating and skips a URL pointing elsewhere; a bare `#N` is
untouched, since a bare number is always local. A body linking only
foreign issues now fails with that reason instead of the vaguer "links
no issue".
- The Dependabot carve out matched any author ending `[bot]` or starting
`app/`, which handed the bypass to every other app installed on the
repository. It is now an explicit list of the Dependabot identities
GitHub reports.
- `oauth-scope-gate.yml` said branch protection lists seven required
contexts. It lists ten.
- `CLAUDE.md` said an issue carries one area label. The rule and the
validator both allow more than one, so it now says at least one.

Regression cases for the first two: a foreign URL rejected beside the
same URL shape pointed here and accepted, and six non-Dependabot bot and
app authors that get no exemption. `python3
scripts/test_check_pr_tracking.py` passes.

## Billing gate evidence

CodeRabbit asked for test evidence under the rule that changes touching
billing need it. This diff contains no billing, credits, payments, auth
or tenancy code: `git diff origin/main...HEAD --name-only` is eleven
files, all of them workflows, documentation,
`scripts/check-pr-tracking.py` and its test, and
`tools/verify-spec-wiring.mjs`. What it does touch is *when* the `Refuse
to bill a paid completion alias` step in the `live-integration` job
executes, so that is what the evidence below is about.

The condition did not change. It moved, verbatim, from the job's own
`if:` to a `gate` step whose output every step then reads:

```
needs.changes.outputs.run == 'true' &&
(github.event_name == 'push' ||
 github.event_name == 'schedule' ||
 (github.event_name == 'pull_request' &&
  github.event.pull_request.head.repo.full_name == github.repository &&
  contains(github.event.pull_request.labels.*.name, 'run-live-integration')))
```

Disabled path, executed. Run 33675965786 on commit `f95b60b`, this pull
request, which carries no `run-live-integration` label: `Live
integration (SDK tests + smoke)` concluded success in 6 seconds with the
guard step and every other real step skipped. Before this change the
same pull request produced no check run for that context at all, which
is the reason for the conversion.

Enabled path, not executed here, and deliberately so: it spends a
provider allowance and is opt in by label. Two pieces of evidence stand
in for it. First, the same gate-step pattern in the same workflow and
the same run did take the enabled branch for the two other converted
jobs, `Agent console (type + unit + build)` in 35 seconds and `Web E2E
(full stack)` in 6 minutes 20, both of which run their steps only when
their gate output is `true`. Second, the push to `main` that merging
this creates is itself an enabled-path run, since `github.event_name ==
'push'` satisfies the first arm, and it fails loudly if the guard
stopped running.

The guard's own coverage is unchanged and still enforced from the Go
side by `TestNoCISurfaceCallsAPaidCompletionModel` in
`apps/control-plane/internal/routing/ci_paid_model_guard_integration_test.go`,
which runs in `Go tests (control-plane)` and passed on this branch.

## Also in this diff

`.github/MERGE-POLICY.md` lists all ten required contexts, nine from
`ci.yml` and the tracking gate, and its note that `ci.yml` is the only
workflow allowed to publish a required check is corrected. The stale
count in `oauth-scope-gate.yml` becomes ten. `CLAUDE.md` gains a two
sentence pointer beside the orchestrator contract and duplicates none of
the rule.

No `.wolf/` file is touched.

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

- **New Features**
- Added pull request tracking validation requiring links to triaged
issues with appropriate priority and area labels.
- Added automatic checks for required milestones on urgent issues and
matching pull request labels.
- Added limited exemptions for automated pull requests and bug-log-only
changes.

- **CI & Workflow Improvements**
  - Added the tracking validation as a required status check.
- Required additional console, integration, and end-to-end checks for
the main branch.
- Updated checks to report clear results even when changes do not affect
their areas.

- **Tests**
  - Added coverage for tracking validation and exemption scenarios.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

internal No customer visible effect. money-path Touches billing, credits, pricing or the ledger. priority:critical Demo blocker or live outage. Drop everything.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Chat web search bills the shared shim account instead of the user who searched

1 participant