Skip to content

fix: make the Anthropic surface conform to the real SDK wire contract (#1274, #1260, #1261, #1259) - #1296

Merged
sakibsadmanshajib merged 3 commits into
mainfrom
fix/anthropic-stream-content-block-start
Aug 29, 2026
Merged

sakibsadmanshajib merged 3 commits into
mainfrom
fix/anthropic-stream-content-block-start

Conversation

@sakibsadmanshajib

@sakibsadmanshajib sakibsadmanshajib commented Aug 28, 2026 •

Copy link
Copy Markdown
Owner

Closes #1274. Closes #1260. Closes #1261. Closes #1259.

Are these one root cause or four?

Two families, not one, and not four independent fixes.

Family A, Go zero-value JSON encoding dropping values the Anthropic contract requires to be present (#1274 and #1260). Both are the gateway emitting a valid Go zero value where Anthropic requires an explicit empty one, and in both cases the Go-side assertion a normal test would write cannot see the defect at all: len(content) == 0 is true for a nil slice and an empty one alike, and a Text field set to "" looks correct right up until encoding/json runs. The mechanism differs (omitempty on a string in one, a nil slice with no tag in the other), so it is one class with two instances rather than a single line to change.

Family B, an auth model that only ever covered POST /v1/messages (#1261 and #1259). Every other route on the Anthropic surface was left to inherit an auth path built for a different dialect. count_tokens is the only route here that does not delegate to the chat chain, so it never gained the API-key authority that chain provides, and GET /v1/models was never given the leaf APIKeyNormalizer that /v1/messages has carried since #954. The two share a cause but not a line of code.

#1259 additionally carries a response-shape defect that belongs to neither family: the route answered an Anthropic client with the OpenAI list envelope and the OpenAI error envelope. Fixed here in the same pass, since fixing only the auth half would have moved the failure one step later.

Ground truth

API shapes were verified against the live specification on 2026-08-28, not from model memory, per the repository rule.

  • https://docs.claude.com/en/docs/build-with-claude/streaming shows content_block_start as {"type":"text","text":""} for a text block and {"type":"tool_use","id":...,"name":...,"input":{}} for a tool_use block, in every one of its five worked examples.
  • https://docs.claude.com/en/api/models-list shows the list envelope as data (entries of type/id/display_name/created_at) plus has_more, first_id and last_id, the last two typed "string or null".
  • The SDK's own accumulator (anthropic/lib/streaming/_messages.py, accumulate_event) was read directly to confirm the failure mechanism: it does content.text += event.delta.text for a text_delta, which is the None += str crash, and for an input_json_delta it assigns content.input from its own jiter buffer rather than reading the block-start value. That is why the missing text was fatal and the missing input was not, and it is why this PR treats the two differently in its claims.

openwolf bug search anthropic and openwolf bug search omitempty both returned no prior entries.

What changed

Issue Change
#1274 StreamContentBlock.Text is now *string so the empty string serializes, following the precedent StreamEvent.Index already set in this same struct for the identical reason. Input json.RawMessage added and set to {} on a tool_use block start.
#1260 FromOAIResponse seeds Content with an empty non-nil slice at construction and appends into it, which covers the early len(resp.Choices) == 0 return as well as the normal path.
#1261 anthropic.Deps.AuthorizeAPIKey added; count_tokens accepts a JWT session principal or an API-key principal. Nil leaves it session-only and fail-closed. The refusal is the authorizer's own verdict run through the shared WriteAuthFailure status mapping and then reshaped into the Anthropic envelope, so no status logic is duplicated.
#1259 GET /v1/models is registered through a named modelsHandler applying anthropic.APIKeyNormalizer at the leaf and anthropic.ModelsCompat for shape.

Why the leaf normalizer on /v1/models is not redundant

authSelectorMiddleware already wraps every /v1/* route in APIKeyNormalizer (#954), but only when JWT auth is wired: handler = authSelectorMiddleware(jwtMW, handler) sits behind if jwtMW != nil. On a deployment where Supabase JWT config is absent, edge-api logs "JWT auth wiring skipped" and mounts no selector at all, so nothing normalizes x-api-key and handleModels reads an empty Authorization header. /v1/messages kept working there purely because it carries its own leaf wrapper. That asymmetry is exactly the reported symptom in #1259 (same key, same connection, /v1/messages fine and /v1/models 401) and it is why the fix is a leaf wrapper rather than a change to the selector.

Provider-blind invariant

The ModelsCompat translation reads id, created and name and writes type, id, display_name and created_at. owned_by and every other OpenAI field are dropped rather than passed through, so this strictly reduces what reaches an Anthropic client. TestModelsCompat_AnthropicClientGetsTheAnthropicListShape asserts owned_by is absent from the body. Nothing on any path touched here emits a provider name, an upstream id or a system_fingerprint; the existing TestHandleModelsDoesNotLeakProviderNames and TestSSETranslator_MessageID_NeverLeaksUpstreamID guards are unchanged and still pass.

Mutation check

Every added test was proved load-bearing by reverting the fix it guards and observing it fail for the right reason, then restoring and observing it pass. Full transcript, six mutations, all red, then green on restore:

# Mutation applied Test Result
1 StreamContentBlock.Text back to a plain string, call site back to Text: "" TestSSETranslator_TextBlockStartCarriesExplicitEmptyText FAIL: text content_block_start omits the "text" key entirely ... map[type:text]
2 Delete Input: json.RawMessage("{}") from the tool_use block start TestSSETranslator_ToolUseBlockStartCarriesExplicitEmptyInput FAIL: tool_use content_block_start omits the "input" key: map[id:call_1 name:get_weather type:tool_use]
3 Delete the Content: []ResponseBlock{} seed TestFromOAIResponse_EmptyCompletionSerializesContentAsArray FAIL on both subtests: got {... "content":null ...}
4 if h.deps.AuthorizeAPIKey == nil forced to if true, i.e. count_tokens back to session-only TestHandler_CountTokens_AcceptsAPIKeyPrincipal, ..._RejectedAPIKeyKeepsTheAuthorizersOwnRefusal FAIL: want 200 got 401 body={"type":"error","error":{"type":"authentication_error","message":"missing user"}}, and error.message: want the authorizer's own refusal got missing user
5 if !IsAnthropicClient(r) forced to if true, i.e. never re-shape TestModelsCompat_* (3 tests) FAIL: OpenAI body passed through, first_id/last_id nil, envelope: want top-level type=error got <nil>
6 modelsHandler unwrapped to return handleModels(client, authorizer) TestModelsHandlerServesAnAnthropicSDKClient FAIL: x-api-key caller: want 200 got 401: {"error":{"message":"You didn't provide an API key...

After restoring all six, go test ./apps/edge-api/internal/anthropic/... ./apps/edge-api/cmd/server/... is ok for both packages, and the wider go test ./apps/edge-api/... -count=1 -short passes with no failures. gofmt -l reports none of the touched files.

Four of the added tests are deliberately non-regression guards rather than defect guards, and do not go red under any mutation above. The count said two on first posting and was corrected in review; the table is the record anyone reads later, so it should say what is actually true.

  • TestModelsCompat_OpenAIClientIsUntouched and TestModelsHandlerKeepsTheOpenAIShapeForOpenAIClients exist to fail if a later change starts re-shaping unconditionally and empties Open WebUI's model picker. Both compare byte-identical bodies, so the inverse mutation of always reshaping does turn them red.
  • TestHandler_CountTokens_WithoutAnAPIKeyAuthorityFailsClosed stays green under mutation 4, since a handler wired without an API-key authority writes 401 either way. It pins the fail-closed default and goes red if someone makes a nil authority permissive.
  • TestHandler_CountTokens_SessionPrincipalDoesNotConsultTheAPIKeyAuthority also stays green under mutation 4, since the session branch returns before the nil check is reached. It goes red if the two principal checks are ever reordered.

TestIsAnthropicClient is a unit test of a new pure function that none of the six mutations touch, so it is neither a defect guard nor a non-regression guard for anything in the table.

Review round two

Three findings from review, all fixed in 25b2820, all in apps/edge-api/internal/anthropic.

Refusal retry headers were being dropped. apierrors.WriteAuthFailure is the shared source of truth precisely so a retryable 429 is never collapsed into a non-retryable refusal, and it delivers the retryable part through headers rather than the body: retry-after plus the four x-ratelimit-* values on a real 429, and retry-after on both the degraded-limiter branch and the upstream_unavailable branch. Recording the refusal in a headerlessRecorder and reshaping only its body threw all of that away. count_tokens lost it latently, and GET /v1/models lost it live through authorizeAliasRequest, which also left the two client shapes disagreeing about retry metadata for the same refusal on the same route. headerlessRecorder.reshapeInto now carries the headers over and reshapes in one call, so a call site cannot take one half of a refusal without the other. Content-Type and Content-Length are deliberately not carried: the reshaped body is a different envelope of a different length, and a stale Content-Length would truncate it on the wire. Everything that is forwarded is retry metadata and carries no provider identity, so the provider-blind invariant is unchanged.

GET /v1/models served two representations for one URL without declaring it. Nothing on the route sets Cache-Control and the route needs a credential, so no correct cache stores it today and no live exploit exists. The declaration is what keeps a later edge cache in front of Caddy, or an intermediary keying on URL alone, from handing an Anthropic-shaped body to Open WebUI and emptying its model picker, which is the regression TestModelsCompat_OpenAIClientIsUntouched exists to prevent. Vary: anthropic-version, x-api-key is now set on both branches, before either writes.

Finish could emit message_delta before any message_start. Translate calls Finish unconditionally and Finish did not check t.started, so an upstream stream that yielded no parseable chunk at all produced a terminal pair with nothing before it, and the SDK accumulator raises an unexpected-event-order error when a message_delta arrives while its snapshot is still nil. Review suggested a follow-up issue for this rather than widening the PR; the fix turned out to be three lines in a file this PR already changes, which is smaller than the issue describing it, so it is taken here. FeedLine and Finish now share one ensureStarted, which emits message_start exactly once per stream whichever of them reaches it first.

Four more mutations, each applied alone, each restored after:

# Mutation applied Test Result
7 reshapeInto's header loop forced to skip every key TestHandler_CountTokens_RefusalCarriesTheAuthorizersRetryHeaders, TestHandler_CountTokens_UpstreamUnavailableCarriesRetryAfter, TestModelsCompat_RefusalCarriesTheRetryHeaders FAIL: retry-after: want 30 got "", retry-after: want 5 got "", and the three x-ratelimit-* assertions on both routes
8 Content-Length removed from the skip list, so the delegated body's length is forwarded TestModelsCompat_RefusalCarriesTheRetryHeaders FAIL: content-length: the delegated body length must not describe the reshaped one, got "4096"
9 The Vary line deleted TestModelsCompat_DeclaresVary FAIL on both subtests: vary: want both request headers that select the representation, got ""
10 ensureStarted removed from Finish TestSSETranslator_EmptyStreamStillOpensTheMessage FAIL: first event: want message_start got message_delta

TestSSETranslator_FinishAfterAStartedStreamDoesNotRepeatMessageStart is a fifth non-regression guard: it stays green under all ten mutations and exists so that opening the message from Finish can never add a second message_start to a stream that already carried one.

Adversarial review of the round-two fixes

The round-two commit went back through the review pipeline before this was called done. Two findings, both fixed in f0e4c56.

The Vary declaration used Set where it should use Add. Nothing in edge-api declares a Vary on this route today (grep finds exactly one writer, the new line itself), so overwriting was harmless in the current tree and wrong the moment an outer middleware declares one of its own, a CORS layer setting Vary: Origin being the obvious case. The wrapper is now additive.

The 2xx branch of ModelsCompat dropped every header the delegated handler set. That path re-encodes the body rather than reshaping it, which is exactly why the carry-over the refusal path had just gained was easy to leave out of it. A header set alongside a success is as much part of that response as the body is, and this route is where a 2xx rate-limit budget would surface. The header copy is now its own method on the recorder, copyHeadersTo, and both branches call it.

# Mutation applied Test Result
11 Vary back to Set TestModelsCompat_VaryDoesNotClobberAnExistingDeclaration FAIL: vary: want "Origin" preserved, got "anthropic-version, x-api-key"
12 rec.copyHeadersTo(w) deleted from the success branch TestModelsCompat_SuccessCarriesTheDelegatedHeaders FAIL: x-ratelimit-remaining-requests: want 42 got ""

On display_name and the provider-blind edge

Review confirmed independently that the ModelsCompat translation is provider-blind (it reads id, created and name only, so owned_by and description cannot ride along) and flagged the remaining edge: display_name is model_aliases.display_name, free text, and one seeded row already reads Openrouter Auto (Task Aware). That row is visibility='internal' today, so the visibility IN ('public', 'preview') predicate keeps it off both list paths, but the migration that added it describes a later flip to public as a one-line follow-up.

Nothing here closes #1284, and this PR never claimed to: the OpenAI branch is unchanged and still ships the hive-stt and hive-tts descriptions that #1284 reports. The display_name edge is recorded as a comment on #1284 rather than a new issue, since it is the same route and the same family, and the guard it asks for (assert no listed alias's display_name or summary matches a known provider name) belongs where the catalog is listed, not in this translation.

Effect on #1278

This should flip all four xfail markers in packages/sdk-tests/python/tests/test_anthropic_messages.py to real assertions. Not touched here, since that branch is not mine to edit.

One caveat on the last one. client.models.list() will now authenticate and return the Anthropic list envelope, but the SDK's typed page model may expect fields this gateway has no source for. If that assertion still fails after this merges, it is a narrower follow-up about specific fields, not the auth and envelope defects #1259 reported.

Scope deliberately not taken

#1260 also observes that a zero-content streaming turn emits message_start, message_delta, message_stop with no content_block_start/stop pair. That is left alone: the streaming path already emits "content":[] in message_start, so an SDK folding the stream back into a message gets an empty array rather than null, and Anthropic's own specification does not require a content block on a turn that produced no content. The reported crash is in the non-streaming builder, which is what this changes.

The neighbouring case where message_start never fires at all is a different defect, was not what #1260 reported, and is now fixed here rather than deferred. See Review round two above.

Test plan

  • go test ./apps/edge-api/... -count=1 -short through the toolchain container, green, re-run after the review-round-two fixes
  • gofmt -l apps/edge-api clean for every touched file
  • Twelve-way mutation check, all three tables above
  • CI required checks green
  • packages/sdk-tests Anthropic conformance suite re-run against a live stack once test: add Anthropic Messages API conformance suite using the real SDK #1278 merges, to confirm the four xfails flip

Buglog entry

{"id":"bug-2026-08-28-anthropic-sdk-wire-conformance","date":"2026-08-28","title":"Anthropic surface broke the real SDK four ways: content_block_start dropped text, empty completions serialized content null, count_tokens 401d API keys, /v1/models rejected x-api-key and answered OpenAI-shaped","error_message":"TypeError: unsupported operand type(s) for +=: 'NoneType' and 'str' in anthropic/lib/streaming/_messages.py accumulate_event; TypeError: 'NoneType' object is not iterable on msg.content; anthropic.AuthenticationError 401 {\"type\":\"error\",\"error\":{\"type\":\"authentication_error\",\"message\":\"missing user\"}} on count_tokens; anthropic.AuthenticationError 401 invalid_api_key on models.list()","root_cause":"Two families. (1) Go zero-value JSON encoding dropped values the Anthropic wire contract requires to be explicitly present: omitempty on StreamContentBlock.Text dropped the required \"text\":\"\" from every text content_block_start, and a nil []ResponseBlock in FromOAIResponse marshaled to null instead of []. Neither is visible to a Go-side assertion, since len() cannot distinguish nil from empty and a \"\" field looks correct until encoding/json runs. (2) The Anthropic surface's auth model only ever covered POST /v1/messages: count_tokens is the only route that does not delegate to the chat chain so it never gained an API-key authority and recognised session principals only, and GET /v1/models never got the leaf APIKeyNormalizer /v1/messages carries, which matters because the global one in authSelectorMiddleware only exists when jwtMW is non-nil.","fix":"StreamContentBlock.Text became *string and gained Input json.RawMessage set to {} on tool_use starts, matching the live streaming spec. FromOAIResponse seeds Content with an empty non-nil slice at construction so both exits are covered. anthropic.Deps.AuthorizeAPIKey added and count_tokens accepts either principal, fail-closed when nil, refusals reshaped from the shared WriteAuthFailure mapping. GET /v1/models registered through modelsHandler applying APIKeyNormalizer at the leaf plus a ModelsCompat wrapper that answers Anthropic-shaped callers with the Anthropic list and error envelopes while leaving OpenAI callers byte-identical.","tags":["anthropic","sdk-conformance","edge-api","json-encoding","omitempty","auth","streaming","provider-blind"],"issues":[1274,1260,1261,1259],"pr":"fix/anthropic-stream-content-block-start"}

Four defects found by running the published anthropic 1.2.0 Python SDK
against a live edge-api, in two root-cause families.

Family one, Go zero-value JSON encoding dropping values Anthropic requires
to be present.

Issue #1274, the one that broke every streaming call. Anthropic emits
content_block_start for a text block as {"type":"text","text":""}, verified
on 2026-08-28 against the live streaming specification. The struct carried
`omitempty` on a plain Go string, which drops the key for the empty string
exactly as readily as for "unset", so the key never shipped. The SDK's own
stream accumulator seeds its snapshot from that field and then runs
content.text += delta.text on the first delta, which made a None += str
TypeError on every text response consumed through the documented
messages.stream() helper. Text is now a pointer so the empty value
serializes. The same specification shows a tool_use block start carrying
"input":{}, which was dropped by the same mechanism, so that is fixed in
the same struct; the Python accumulator survives that one because it
assigns input from its own partial-JSON buffer rather than reading it at
block start, but the wire shape was wrong regardless.

Issue #1260. FromOAIResponse left Content as a nil slice for a turn that
produced neither text nor a tool call, and a nil slice marshals to JSON
null rather than []. Anthropic's contract is that content is always an
array, so a typed client iterating it got a TypeError instead of an empty
turn. The empty slice is now seeded at construction, which covers both
exits from that function rather than only the one the report named.

Family two, an auth model that only ever covered POST /v1/messages.

Issue #1261. count_tokens is the one route on this surface that does not
delegate to the chat chain, so it recognised a JWT session principal and
nothing else. An hk_ request is routed past the JWT middleware by
auth.Selector and therefore carries no session user at all, which made
every API-key caller a 401 on that route while the sibling /v1/messages
accepted the identical key on the identical connection. The handler now
takes an optional API-key authority and accepts either principal. Nil
leaves it session-only and fail-closed, so no deployment silently loses
the guard. The refusal is the authorizer's own verdict reshaped into the
Anthropic envelope, reusing the shared status mapping rather than
duplicating it.

Issue #1259. GET /v1/models is now registered through a named
modelsHandler that applies APIKeyNormalizer at the leaf, as
/v1/messages already did, so an x-api-key credential is rewritten even on
a deployment where JWT auth is unwired and authSelectorMiddleware never
mounts. It also applies a compat wrapper that answers an Anthropic-shaped
caller with the Anthropic list envelope and the Anthropic error envelope,
verified against the live models-list specification. Detection is on
anthropic-version or x-api-key, so an OpenAI-shaped caller, Open WebUI
included, keeps the byte-identical body it always had. The translation
carries id, display name and created_at only, dropping owned_by, so it
strictly reduces the provider-blind leak surface rather than widening it.

Every added test was mutation-checked: each fix was reverted in turn, the
corresponding test observed failing for the right reason, then restored
and observed passing.
@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 Aug 28, 2026 •

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 40 minutes.

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: Pro Plus

Run ID: dc79c1af-c639-48a1-b6a4-7e2c5c758244

📥 Commits

Reviewing files that changed from the base of the PR and between db6bace and f0e4c56.

📒 Files selected for processing (12)
  • apps/edge-api/cmd/server/main.go
  • apps/edge-api/cmd/server/main_test.go
  • apps/edge-api/internal/anthropic/handler.go
  • apps/edge-api/internal/anthropic/handler_test.go
  • apps/edge-api/internal/anthropic/models.go
  • apps/edge-api/internal/anthropic/models_test.go
  • apps/edge-api/internal/anthropic/stream.go
  • apps/edge-api/internal/anthropic/stream_test.go
  • apps/edge-api/internal/anthropic/translate_response.go
  • apps/edge-api/internal/anthropic/translate_response_test.go
  • apps/edge-api/internal/anthropic/translate_writer.go
  • apps/edge-api/internal/anthropic/types.go

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.

@sakibsadmanshajib sakibsadmanshajib left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed this as the mandatory security pass, since it changes authentication on two routes. I verified the four fixes against the live specifications and the SDK source myself rather than re-deriving your work, and I traced the auth question end to end. Summary of where I landed, with detail in the inline comments.

Authority is not widened. For count_tokens I followed an hk_ request all the way through: auth.Selector sends it past the JWT middleware with no session user, the leaf APIKeyNormalizer on /v1/messages/ has already rewritten x-api-key into Authorization, and the new closure hands that header to the same authorizer.Authorize the rest of edge-api uses. CheckAccess still enforces key status, expiry and budget on that call; only the model allowlist is skipped, and only because aliasID is empty, which is exactly the precedent handleModels set for /v1/models. The limiter still runs. An invalid or revoked hk_ key gets a 401 carrying the authorizer's own message, which I confirmed is the authorizer's verdict and not a blanket string. What the route grants an API key is a len/4 estimate computed locally from the caller's own request body, with no catalog lookup, no tenant read and no dispatch, so there is no data an API key can reach here that it could not already reach. Nil AuthorizeAPIKey stays session only. For /v1/models the leaf normalizer changes the spelling of the credential, nothing else: same authorizer, same tenant filtered snapshot, and on a deployment with no JWT wiring the route is still fully authenticated because handleModels fails closed on an unresolvable tenant.

The client discriminator cannot be spoofed into revealing anything different. Both branches serve the same authorized snapshot.Models for the same principal, and the Anthropic branch carries a strict subset of the fields. Setting anthropic-version on a session request flips the shape but reveals strictly less. The OpenAI path is genuinely untouched: I grepped every in-repo consumer and nothing outside this PR's own tests sends anthropic-version or x-api-key at /v1/models, so Open WebUI, the console and the OpenAI SDK conformance suite all keep the byte-identical body. One caveat about intermediary caching in an inline comment.

Provider blindness holds, and I confirmed it independently. ModelsCompat reads only id, created and name. owned_by is dropped, and so is description, which is where the live leak in #1284 actually sits (hive-stt and hive-tts name Groq verbatim in model_aliases.summary). So the Anthropic branch is immune to that leak by construction. Please do not let this merge read as closing #1284 though: the OpenAI branch still ships those descriptions to every Open WebUI and OpenAI SDK caller, so that issue stays open. One forward looking risk on display_name inline.

The non-guard labelling is honest but the count is short by two. Both tests you named really are non-defect guards, and both really do go red under the inverse mutation, so they earn their place. Two more added tests are in the same category and are not named. Detail inline.

The declined scope is the right call. I checked it against the live specification rather than plausibility. The streaming page describes "a series of content blocks" with no stated minimum of one, emitMessageStart really does set Content: []string{} so message_start carries "content":[] on the wire, and the SDK accumulator appends blocks into that list and never indexes it eagerly, so a turn with zero blocks folds back into an empty array with no error. I did find one genuinely unhandled sequence next door to it, which is pre-existing rather than something you introduced. Inline, with a suggestion to file it rather than widen this PR.

On the review streams themselves, so the absences are not read as passes:

  • CodeRabbit: SKIPPED. Rate limited repo wide. The bot comment on this PR says "Review limit reached" and the check reports zero duration, and the CLI is limited on the same account. Not a clean pass.
  • Codex adversarial review: SKIPPED. Over quota. The connector posted "You have reached your Codex usage limits for code reviews" on this PR.
  • Everything else ran: the security pass, a Go read, the plain adversarial pass, and a downstream trace through authz, catalog, the alias seed migrations and the Anthropic SDK accumulator source.

Nothing here blocks the fixes. The one I would like addressed before merge is the dropped retry headers, since it is a small change and it quietly undoes a contract another PR added on purpose.

Comment thread apps/edge-api/internal/anthropic/handler.go
Comment thread apps/edge-api/internal/anthropic/models.go
Comment thread apps/edge-api/internal/anthropic/models.go
Comment thread apps/edge-api/internal/anthropic/models.go
Comment thread apps/edge-api/internal/anthropic/handler_test.go
Comment thread apps/edge-api/internal/anthropic/stream.go
…e finishing

Review findings on PR #1296, all three in the Anthropic surface.

Recording a refusal in headerlessRecorder and reshaping only its body threw
away the header half of that refusal. WriteAuthFailure is the shared source of
truth precisely so a retryable 429 is never collapsed into a non-retryable
refusal, and it delivers the retryable part through headers: retry-after plus
the x-ratelimit-* family on a real 429, and retry-after on both the degraded
limiter and the upstream_unavailable branches. count_tokens dropped all of
them, and GET /v1/models did the same one layer out, which also left the two
client shapes disagreeing about retry metadata for the same refusal on the same
route. headerlessRecorder.reshapeInto now carries the headers over and reshapes
in one call, so a call site cannot take one half without the other. Content-Type
and Content-Length are deliberately not carried: the reshaped body is a
different envelope of a different length, and a stale Content-Length would
truncate it on the wire. Nothing forwarded carries provider identity.

GET /v1/models now serves two representations for one URL, chosen by request
headers, so it declares Vary on both branches before either writes. Nothing on
the route sets Cache-Control and the route needs a credential, so no correct
cache stores it today; the declaration is what keeps a later edge cache, or an
intermediary keying on URL alone, from handing an Anthropic-shaped body to Open
WebUI and emptying its model picker.

SSETranslator.Finish emitted message_delta and message_stop without checking
whether message_start had ever fired, so an upstream stream that yielded no
parseable chunk produced a sequence the SDK accumulator rejects for unexpected
event order. Finish now calls the same ensureStarted the first chunk does,
which costs two events on an empty stream and emits message_start exactly once
on every other one.
… the models wrapper

Both findings from the adversarial review of the previous commit.

ModelsCompat set Vary rather than adding it. Nothing in edge-api declares a
Vary on this route today, so overwriting was harmless in the current tree and
wrong the moment an outer middleware declares one of its own, a CORS layer
setting Vary: Origin being the obvious case. Add keeps the wrapper additive.

The 200 branch of the same wrapper dropped every header the delegated handler
set. That path re-encodes the body rather than reshaping it, which is why the
carry-over the refusal path had just gained was easy to leave out of it, but a
header set alongside a success is as much part of that response as the body is,
and this route is exactly where a 2xx rate-limit budget would surface. The
header copy is now its own method on the recorder and both branches call it.
@sakibsadmanshajib
sakibsadmanshajib merged commit 0b4e5ff into main Aug 29, 2026
29 checks passed
@sakibsadmanshajib
sakibsadmanshajib deleted the fix/anthropic-stream-content-block-start branch August 29, 2026 00:57
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
## Summary

This is the batched buglog follow-up for the 21 pull requests merged
during the 2026-08-28 session. Its diff is `.wolf/buglog.jsonl` and
nothing else.

Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or
failed build must be logged, but the line may never be appended on a fix
branch: `merge=union` in `.gitattributes` resolves concurrent appends
locally and is ignored by GitHub's server side merge, so two branches
that both appended land in hard conflict there. An unmergeable pull
request gets no `refs/pull/N/merge`, no `pull_request` run and therefore
zero checks, and the required status gate then blocks the merge for a
reason the page never states (issue #873). Each fix accordingly carried
its entry in its own pull request body, and this pull request copies
them onto `main` in one batch, which the protocol explicitly prefers
over one pull request per entry.

## Source pull requests

1240, 1251, 1253, 1257, 1268, 1276, 1277, 1278, 1281, 1287, 1292, 1293,
1294, 1296, 1300, 1301, 1303, 1305, 1313, 1335, 1337. All merged.

Every entry came from a "Buglog entry" heading in one of those bodies.
Nothing was invented for a pull request that carried none.

## What landed

36 entries appended, one JSON object per line, append only. The 196
pre-existing lines are byte identical to `origin/main`.

| Source | Entries |
|---|---|
| #1240 | 1 |
| #1251 | 1 |
| #1253 | 1 |
| #1257 | 4 |
| #1268 | 5 |
| #1276 | 2 |
| #1277 | 1 (of 2 in the body) |
| #1278 | 0 (merged into #1296) |
| #1281 | 2 |
| #1287 | 2 |
| #1292 | 3 |
| #1293 | 1 |
| #1294 | 3 |
| #1296 | 1 |
| #1300 | 1 |
| #1301 | 1 |
| #1303 | 1 |
| #1305 | 3 |
| #1313 | 1 |
| #1335 | 1 |
| #1337 | 1 |

Note on #1268: its first "Buglog entry" heading says "None yet" in prose
and carries no JSON. Its two later headings, from the CI live lane and
from the intermittent tool call failure, carry the five entries taken
here.

## Deduplication

- **#1278 dropped, folded into #1296.** Both describe the same defect:
`omitempty` on `StreamContentBlock.Text` dropped the required
`"text":""` from every text `content_block_start`, crashing the real
Anthropic SDK's stream accumulator (issue #1274). #1278 is the
conformance suite that found it and shipped it marked xfail; #1296 is
the fix, and its entry carries the fuller root cause and the actual
remedy. One bug, one entry. #1296's entry gains a `discovered_by` field
naming #1278 so the discovery is not lost.
- **#1277's first entry dropped.** The same body carries a later "Buglog
entry (revised)" heading written after the review round found the page's
claims did not match what the code enforces. The revised entry is the
one taken.
- Checked and kept as distinct: #1313 and #1337 are two different hooks
(`decision-citation-check.js` and `secrets-scanner.js`) blind to the
same MultiEdit payload shape, fixed in two different pull requests, so
two entries. #1240 and #1335 are two different `account_not_provisioned`
defects, one an observability gap at the edge boundary and one a console
mint that should have refused, so two entries. #1305's three entries are
three separate rounds of defects in the same money path change, each
with its own root cause.

## Corrections against what actually merged

Each entry was checked against the merged tree at `origin/main`, not
against its own claim.

- **#1240.** The entry said the log line went in at
`AuthSnapshot.TenantUUID`. On `main` the check is the exported
`authz.ParseTenantID(TenantLookup)` that `TenantUUID` delegates to,
which the images and audio routing adapters (two further silent call
sites found in the same review) also call, and `key_id` is deliberately
not logged because CodeQL's clear text logging check flags any field
named `*Key*` (alert #31). The `fix` field now says so.
- **#1276, first entry.** The entry named
`app/console/analytics/page.tsx` as the home of the five fetch helpers
and their `Promise.all`. On `main` they live in
`apps/web-console/lib/analytics/overview-fetch.ts`, extracted during
review. Path corrected.
- **#1277.** Its `error_message` was the placeholder `n/a`.
Reconstructed from the pull request's own correction narrative: the page
as first written published a blanket no content stored claim false for
`/v1/batches`, `/v1/files` and `/v1/rag`, a product wide provider
blindness claim disproved by catalogue summaries that name vendors
(#1284), a metering claim anchored to the console side `UsageEventRow`
projection rather than the `usage_events` table, and a 1:1 alias to
route claim that is a property of seed data rather than of
`SelectRoute`.

**#1303 needed no correction.** Its original root cause asserted a live
mid stream provider leak on the session chat relay that measurement
disproved, and the author had already corrected the body before merge.
The corrected version is what was taken, including the sentence
recording that the session chat relay did not leak an error frame but
silently truncated instead.

Every other entry's central claim was verified present in the merged
tree, among them `metering.SupportsIncludeUsage`,
`sanitize.VariablePriceFrame` in the batch dispatcher, the revoke and
regrant in `20260828_01_service_role_public_schema_grant.sql` with the
`anon` assertions in `ci-throwaway-db.sh`, `normalizeReasoningUsage` now
called from `normalizeChatCompletion`,
`signup.SyncTenantMembershipRole`,
`TestKeyViewHidesALimitThatIsNotEnforced`,
`TestListEventsLatencyCrossesTheWire`, `mask-api-keys.mjs` and
`md-table.mjs`, `StreamContentBlock.Text` as `*string`,
`redactSnapshot`, `httpx.ReadBody`, `sanitize.ReplaceErrorFrame` with
the default deny tail in `provider_blind.go`, `pinCompletionCeiling` and
`captureInputTokens` with `applyReasoningHeadroom` gone,
`requireBillingTenant`, and `hooks.selfcheck.js` wired into the Repo
policy lints check.

## Verification

- `node .wolf/hooks/bugstore.selfcheck.js` reports `bugstore selfcheck
OK`.
- All 232 lines parse as a single JSON object each.
- Every appended entry carries `error_message`, `root_cause`, `fix` and
`tags`.
- Scanned for credentials: no API key, bearer token, JWT, password, AWS
key or Postgres DSN with a password appears in any entry. The `hk_`
occurrences are prefix descriptions in prose, not keys.
- `git diff origin/main...HEAD --name-only` prints `.wolf/buglog.jsonl`
and nothing else. No `.wolf/` telemetry was staged.

## Review

No adversarial review streams were run, deliberately. This change is
records only: it adds no code, no test, no configuration and no
behavior, and `.wolf/buglog.jsonl` is on the inert path allowlist in
`.github/workflows/ci.yml`, so the six required checks report green
without running their heavy steps. If a check does fail here, that is a
real signal about the file rather than about the pipeline.

https://claude.ai/code/session_01WyEwUxZCArdn1ZUDkTvuQ1

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
Same defect family as the three markers cleared in #1383: a marker that
outlived the bug it tracked and now renders as a red XPASS(strict) reading
"Expect test to fail".

Issue #1274 was content_block_start omitting an empty `text` field, because
`json:"text,omitempty"` drops a Go string's empty value as readily for the
required-but-empty case as for unset. The Anthropic SDK seeds its accumulator
snapshot from that field and then does `content.text += delta.text`, so a
missing key made that a `None += str` TypeError on every streamed text
response. PR #1296 (commit 0b4e5ff) fixed it by making Text a pointer so the
value reaches the wire, and the issue is closed.

Not cleared on a single green sample. The test reported XPASS(strict) on five
independent CI runs across five commits (33244935750, 33246016113,
33247050365, 33247571957, 33248088001), two of them after rebasing onto the
classifier fix in #1410, with no run in which it failed legitimately.
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
) (#1390)

Fixes #1324. Fixes #1380. Diagnoses #1370 and leaves it open with the
cause recorded.

## Was restoring this coverage worth it? It caught a real wire-format
bug on its first sighted run.

That is the short answer to the only question that matters here, and it
is evidence rather than principle.

A suite that had been blind for four months was given a real object
store to talk to. The first thing it found was that **every signed S3
request this product makes was going out in absolute form** (`PUT
http://host/s3/bucket/key HTTP/1.1`), which RFC 9112 reserves for
requests **to a proxy**. An origin server expects `PUT /s3/bucket/key`.
The cause was `URL.Opaque`, set for the SigV4 signer and never cleared,
which `net/url.RequestURI` returns verbatim with the scheme glued back
on.

It was invisible on the deployed box for one reason only: every S3
request there passes through Caddy, which normalizes the target before
proxying it on, so Supabase Storage never saw the malformed form.
Reached directly, Storage refuses it with a 500 that names neither the
path nor the credential. Nothing in four months of green CI could have
found this, because CI was pointed at a hostname that stopped resolving
in August.

Details, the wire capture, and the fix are in the pinned comment on this
pull request.

## What was actually wrong, and it is one thing rather than three

The `S3_ENDPOINT` repository secret was last written on
`2026-04-21T23:15:49Z`, four months before the August self-hosted
Supabase cutover, and still names the Supabase Cloud project this
repository left. That project is deleted, so the hostname does not
resolve. Confirmed with `gh secret list`, along with the rest of that
April-vintage family: `S3_ACCESS_KEY`, `S3_SECRET_KEY`, `S3_REGION`,
`S3_BUCKET_FILES`, `S3_BUCKET_IMAGES`, `SUPABASE_URL`,
`SUPABASE_DB_HOST`.

**#1324.** `live-integration` reads those six at job level
(`ci.yml:1221`), so every `POST /v1/files` died at the socket. The Files
and Batches conformance tests were carried as `it.fails` markers, which
means the suite reported green for four months while covering nothing.

**#1380 names the wrong job, and this is worth stating plainly rather
than quietly fixing.** `ci.yml:2026` and `ci.yml:2565` are `web-e2e` and
`interaction-coverage`, both Playwright. Neither runs `sdk-tests-js`;
those run in `live-integration` (`ci.yml:1612`), which was reading the
dead secret. So #1380's conclusion, that CI cannot fail on the storage
path, is correct, but its cause is #1324's secret and not the discard
port. I read the comment at `ci.yml:2016` before touching either line,
as asked, and its author's three reasons all survive scrutiny, so the
stub stays and the comment now names the job that does exercise storage.

**#1370 is a third victim of the same secret family.** The nightly does
not report `skipped` at run level: every scheduled run since 2026-08-09
reports `failure` (runs 33197858823, 33097409194, 32939092305,
32817812570, 32698750004, 32623212748, 32557183055, 32455134986,
32340347866). What is skipped is the work. Job log 98939498320 shows the
cause:

```
socket.gaierror: [Errno -2] Name or service not known
urllib.error.URLError: <urlopen error [Errno -2] Name or service not known>
  File "scripts/seed-owui-e2e-user.py", line 709, in main
```

The seeder resolves `secrets.SUPABASE_URL`. It dies at step 5, so steps
6 through 15 including the entire Playwright suite are skipped, and the
only red left is step 16 failing on `browserType.launch: Executable
doesn't exist`, because the step that installs browsers was one of the
skipped ones. A reader who opens the run sees a Playwright error and no
mention of a dead hostname. That is what made a week of failures read as
flake.

## The decision, and why it is not a new secret

**Can a GitHub-hosted runner reach the box's storage endpoint? No, and
it must not.** `deploy/docker/Caddyfile.supabase` puts `/storage/v1` on
the internal listener only. The public listener is a separate port
carrying `/auth/v1` minus its admin and self-service routes, and the
file states the constraint directly:

> Consequence for whoever does the cutover: publish or tunnel port 8080
ONLY. Ports 80 and 443 are the in-network surface and must never be
exposed.

So repointing the secret is blocked by a deliberate security posture,
not by an unfinished chore. Even with that posture relaxed, CI would
then be reading and writing the live demo box's buckets, which is the
arrangement the `web-e2e` comment already records as a defect that was
fixed.

**MinIO was considered and rejected, and not on the grounds you might
expect.** The standing no-MinIO line lives in
`docker-compose.enterprise.yml:312` and in CLAUDE.md as a statement
about what a deployment persists objects into. On a literal reading it
does not reach a container that lives for six minutes and holds nothing,
and I do not think it does. MinIO is rejected on a different ground: the
defect that actually took production down, #1282, was a
`supabase-storage` server-side credential defect. MinIO has no
`S3_PROTOCOL_*` concept at all, so a MinIO fixture is structurally
incapable of catching that or its next relative, and picking it would
rebuild a weaker version of the blindness this PR exists to remove.

**What this does instead:** `live-integration` stands up the real
`supabase/storage-api`, at the same immutable digest
`docker-compose.enterprise.yml` pins, on the throwaway Postgres the job
already boots. That database already has everything Storage needs:
`deploy/supabase/init/00-extensions.sql` creates the `storage` schema,
the Supabase roles, and the default privileges in `storage`, and its own
comments already name this consumer. Nothing external is dialed, no
secret is involved, and a Dependabot-triggered run behaves identically
to every other run.

Verified before writing a line of workflow YAML: the image boots against
a bare `pgvector/pgvector:pg17` with only `00-extensions.sql` applied,
runs its own migrations into `storage`, and serves a full signed round
trip on `/s3/hive-files/<key>` with no PostgREST and no Caddy.

**Deliberate gap, stated rather than buried.** Production reaches
Storage through the gateway, which strips `/storage/v1`, so Storage is
given `S3_PROTOCOL_PREFIX=/storage/v1` to rebuild the canonical URI. The
fixture has no gateway, so it runs with an empty prefix and the endpoint
ends in `/s3`. That prefix agreement is therefore not exercised live
here. It is not unchecked: `scripts/test_selfhost_supabase_seam.py`
asserts the prefix, the endpoint and the Caddy route all agree, and CI
runs it through `make test-scripts`. Adding Caddy to the fixture would
add a component and its failure modes for a seam that already has a
guard.

## No secret needs creating, rotating or repointing

Stated explicitly because the obvious reading of #1324 is "fix the
secret". It is not. The three workflows that read `secrets.S3_*` now
read none of them:

- `live-integration` takes its values from the fixture.
- `agent-visual-proof` takes the same secret-free stub the Playwright
jobs use, because it touches no object either. Its `github.actor !=
'dependabot[bot]'` guard is byte identical after the change; `git diff`
shows no line touching it.
- `owui-nightly` keeps its six, deliberately, and that needs the
explanation below.

Nothing else in this repository reads the six `S3_*` secrets after this
change.

### Why `owui-nightly` keeps them, and what the lint taught me

My first attempt at that file added a preflight step resolving
`secrets.SUPABASE_URL`, failing with a message naming the dead project.
`tools/lint-no-dead-supabase-secrets.mjs` rejected it, correctly, and it
is worth restating why rather than just complying. That guard pins the
exact per-secret reference counts for the file rather than exempting the
file, so a freshly copy-pasted `secrets.SUPABASE_*` line cannot ride in
on an exemption. My step took `SUPABASE_URL` from 3 to 4. Improving an
error message is not a reason to widen an exemption documented as one
that must only ever shrink.

The diagnostic therefore moved into the existing `Reconstruct
SUPABASE_DB_URL` step, which already reads `SUPABASE_DB_HOST`, and
resolves that instead. Same deleted project, same answer, zero new
references. **The `PENDING` map is unchanged and the lint passes on the
audited counts exactly as they stand:**

```
pending  owui-nightly.yml: 13 audited reference(s) to the retired project's secrets, tracked in #1055
ok: 16 workflow file(s) scanned, 1 pending, no unexempted reference to the retired project's secrets
```

I checked whether this PR could take that file to zero and retire the
entry, which is what #1055 actually wants. It can in principle, by the
route the linter itself names: `scripts/ci-throwaway-db.sh` plus
`scripts/ci-supabase-stack.sh` plus this PR's new
`scripts/ci-object-store.sh`, the arrangement `web-e2e` already has. I
am not doing it here, for reasons I would rather state than have
discovered:

- It replaces every credential path in a job whose suite has not passed
since 2026-08-18, so a configuration fix is unlikely to be the last
thing wrong with it. That is #1370's own fix, not a storage PR's.
- It is unverifiable from this branch in any way I would trust. The job
runs 45 minutes and builds Open WebUI from source, and I would be
asserting it works on one dispatch of a suite with unknown other
breakage. This repository has a standing lesson about exactly that shape
of sincere false green.
- Its S3 half specifically cannot take the stub the other jobs take.
`web-e2e`, `interaction-coverage` and `agent-visual-proof` can be
stubbed at the discard port because they touch no object. This one boots
Open WebUI, whose knowledge and document surfaces do write objects, so a
stub would convert a blocked suite into a suite that runs and fails on
storage. It needs the real fixture, which needs the throwaway Postgres,
which is the #1370 change.

The block now carries that reasoning inline, so the next person to open
it does not rediscover any of it.

## Proving the signal can actually fail

Every restored or new assertion was verified red before being kept.

**The fixture's own round-trip check.** Booted with
`S3_PROTOCOL_ACCESS_KEY_ID` and `S3_PROTOCOL_ACCESS_KEY_SECRET` removed,
it exits non-zero with, verbatim, the error PR #1368 read off the
production box:

```
::error::the throwaway object store answered its health check but could not complete a signed PUT and GET.
That is the shape of issue #1282: Storage reports healthy and refuses every signed request.

aws: [ERROR]: An error occurred (AccessDenied) when calling the PutObject operation:
Missing S3 Protocol Access Key ID or Secret Key Environment variables
```

With the credentials present, same database, same command: `round trip
OK` and seven `KEY=value` lines. The ordering guard was also verified
red, by running it against a Postgres with no `storage` schema.

**The workflow rot guard**,
`apps/web-console/tests/unit/ci-live-integration-storage.test.ts`, run
against the pre-change `ci.yml`:

```
× reads no S3 repository secret
× stands up a real object store fixture
× keeps the S3 values out of the job-level env block
Tests  3 failed | 1 passed (4)
```

and green against the post-change file. All four of its assertions
matter: a returning secret means the job dials a dead host again, a
missing fixture step means it dials nothing, and a job-level `S3_*`
entry silently overrides what the step writes to `$GITHUB_ENV`, which is
the exact trap that got `SUPABASE_*` and `HIVE_API_KEY` removed from
this job rather than reassigned.

**The digest drift guard**, added to
`scripts/test_selfhost_supabase_seam.py`, verified red by bumping the
fixture pin one patch version:

```
AssertionError: the CI object-store fixture and the deployed stack pin different Storage images.
  compose: supabase/storage-api:v1.11.13@sha256:1e85dad...
  fixture: supabase/storage-api:v1.11.12@sha256:1e85dad...
```

Without it, the claim that CI tests what the box runs is a comment
rather than a property.

**End to end in CI.** The `it.fails` markers came off, which is itself a
two-way check: `it.fails` reports an unexpected pass as a failure, so a
premature removal and a premature marker both go red. The deliberate
break and its revert are recorded below once both runs land.

The break was made in the code path rather than in the fixture, so what
is proven is that a real storage defect turns the check red:
`apps/edge-api/cmd/server/main.go` handed the files handler a bucket
that does not exist, which is the shape of the family #1282 belongs to,
where the request reaches a live server and is refused.

**Run 33243735217, commit `1e160189b`, the deliberate break. `Live
integration (SDK tests + smoke)` (job 99077797492): failure.**

The fixture itself came up clean on the hosted runner, which was the one
thing that could not be proven locally:

```
bucket hive-files: created
bucket hive-images: created
round trip OK
```

and then the restored coverage did exactly what it exists to do:

```
❯ tests/files/files.test.ts (2 tests | 1 failed)
  × Files > uploads, lists, retrieves metadata, downloads content, then deletes a file
    → 500 Failed to store file
  ✓ Files > rejects an invalid purpose with a structured 4xx error

❯ tests/batches/batches.test.ts (2 tests | 1 failed)
  × Batches > rejects an unsupported batch endpoint value with a structured 4xx error
    → 500 Failed to store file
```

Both failures name the storage path. Before this PR the same break would
have changed nothing, because the endpoint was already dead and both
assertions were carried as expected failures.

That run was later cancelled by the `ci-${{ github.ref }}` concurrency
group when the revert was pushed. Its `Live integration` job had already
concluded, so the evidence above is from a completed job and not from a
cancelled one. It also failed `Repo policy lints (tenant + audit)` on
the dead-secret linter, which is the finding described in the section
above and is fixed in `4411d7af9`.

**Run 33244166031, commit `b8b8ae75e`, break reverted plus the lint
fix.** Result recorded here once it lands.

**Run 33246016113, commit `9994dba07`.** The storage half is green:

```
== sdk-tests-js  (exit 0)
  ✓ tests/files/files.test.ts   (2 tests)
  ✓ tests/batches/batches.test.ts (2 tests)
  Test Files  22 passed (22)
       Tests  58 passed | 1 skipped (59)
```

| Run | Commit | Expectation | Result |
| --- | --- | --- | --- |
| 33243735217 | `1e160189b` | `Live integration` red, naming the storage
path | failure on `500 Failed to store file` |
| 33246016113 | `9994dba07` | Files and Batches green | both suites
pass, 58/58 in JS |

Between those two the restored coverage did its job and caught a real
defect, which is why the middle runs are not a clean green: see the
pinned comment on this pull request. `packages/storage` was emitting an
absolute-form request target on every S3 request, which Caddy normalizes
on the box and Supabase Storage refuses outright. That is fixed here,
verified red then green, then end to end against a real Storage
container.

`Live integration` is still red overall, on a strict `xfail` for issue
**#1274** in the Python suite that now passes. It is pre-existing,
present in run 33243735217 before any of this branch's storage work,
unrelated to storage, and deliberately left alone: clearing a strict
`xfail` on one green sample is how an intermittent defect gets recorded
as fixed.

## Required contexts: ran, or satisfied by a skip

Filled in from the PR's own checks once they resolve, per the standing
hazard that a `skipped` required check satisfies branch protection and a
non-required check's absence is invisible on the page.

## Also in this PR, at the coordinator's request

`.claude/skills/worktree-compose-stack.md` advertised "the current
dead-Supabase-host blocker" in its frontmatter description, which is the
line the skill router matches on, and named a specific Supabase Cloud
hostname in `.env` as the thing stopping a local stack, citing #1254.

Both halves are now false, and the way they are false is expensive:
#1254 is closed, and `.env` no longer contains that hostname. Verified
directly, values masked: `SUPABASE_URL`, `SUPABASE_DB_URL`,
`S3_ENDPOINT` and `NEXT_PUBLIC_SUPABASE_URL` are all present with a
zero-length value. An agent reading the old text greps for the hostname,
finds nothing, and concludes either that the skill is wrong or that a
local stack should now work. Neither is true. The blocker changed shape
rather than going away, and the two shapes produce different errors:

- Then: `control-plane` came up and warned `database unreachable after 6
attempt(s)`, naming no cause.
- Now: `control-plane` never reaches the database check.
`loadStorageConfigFromEnv` requires the S3 names to be non-empty and
`log.Fatalf`s, so it exits at boot with `storage unavailable: missing
S3_ENDPOINT, S3_ACCESS_KEY, S3_SECRET_KEY, S3_REGION`.

The section now leads with "do not grep `.env` for a Supabase hostname
to confirm this", states both shapes, and points at
`scripts/ci-supabase-stack.sh` plus the new `scripts/ci-object-store.sh`
as the sandbox-workable alternative. It belongs in this PR because it is
the same root cause at a different site: one deleted Supabase Cloud
project, still referenced by stale configuration in more than one place.
The CI-side instance is the secret this PR removes.

## Not in scope, deliberately

- **Fully fixing #1370.** See the section above on why, and what it
would take. What this PR adds there is a resolution check inside a step
that already reads the host, failing in seconds with a message naming
the stale secret family and the issue, instead of spending 45 minutes
producing a misleading missing-browser cascade. The issue stays open
with the cause recorded.
- **The accumulating `owui-e2e-shim` API keys** noted in #1370. Separate
hygiene finding.
- **`/v1/batches` success path.** Still not exercisable with the current
provider mix, CLAUDE.md Known Issues item 4, unchanged. The submit,
retrieve and cancel path is what the marker was hiding and is what comes
back.

## Effect on the demo

None. `deploy-demo-box.yml` is untouched, and no file this PR changes is
read by it.

## Plan

`ObsidianVault/hive/plan-2026-08-29-ci-object-storage-blindness.md`,
with the requirements and acceptance criteria folded in.

## Issue #1274's stale marker, cleared with two-sample evidence

`Live integration` was red on one thing that was not storage: a
`strict=True` `xfail` in
`packages/sdk-tests/python/tests/test_anthropic_messages.py` that now
passes, rendering as `XPASS(strict)`. That is the same defect family as
the three `it.fails` markers #1383 cleared this morning, and a marker
states what is true today.

**What it tracked and what fixed it,** so the closure is traceable
rather than "it passes now": #1274 was `content_block_start` omitting an
empty `text` field, because `json:"text,omitempty"` drops a Go string's
empty value as readily for the required-but-empty case as for unset. The
Anthropic SDK seeds its accumulator snapshot from that field and then
does `content.text += delta.text`, so a missing key made that a `None +=
str` TypeError on every streamed text response. **PR #1296, commit
`0b4e5ffed`,** fixed it by making `Text` a pointer so the
required-but-empty value reaches the wire. The comment on
`StreamContentBlock` in `apps/edge-api/internal/anthropic/types.go`
records that, and issue #1274 is closed.

**Not cleared on one sample.** It reported `XPASS(strict)` on five
independent runs across five commits, two of them after rebasing onto
#1410's classifier fix, with no run in which it failed legitimately:

| Run | Commit |
| --- | --- |
| 33244935750 | `f8dddd47e` |
| 33246016113 | `9994dba07` |
| 33247050365 | `3060119a5` |
| 33247571957 | `11d7e2eae` (post-rebase) |
| 33248088001 | `eac8729b4` (post-rebase) |

`gh run rerun --failed` refuses on this branch ("its workflow file may
be broken", the known behaviour when a run's workflow file has changed),
so the second sample comes from separate runs on separate commits rather
than a re-run of one. That is the stronger form of the evidence, not a
substitute for it.

The `run-live-integration` label stays on. Removing it would make a
required check skip, and a skipped required check satisfies branch
protection: that would trade a loud failure for a quiet absence on the
exact lane this pull request exists to restore.

## Correction posted to #1380

That issue attributes the blindness to the `http://127.0.0.1:9` stub.
The conclusion is right and the cause is not: `ci.yml:2026` and
`ci.yml:2565` are `web-e2e` and `interaction-coverage`, both Playwright,
and neither runs `sdk-tests-js`. The SDK suites run in
`live-integration`, which was reading the dead secret. Corrected on the
issue itself rather than only here, so the next reader does not inherit
the wrong model:
#1380 (comment)

## `ci.yml` regions this branch touches

Three, all narrow, listed so a rebase order against #1410 and #1417 can
be worked out:

1. The `live-integration` job env block, where six `secrets.S3_*`
entries are removed and replaced by a comment.
2. One new step in `live-integration` after the throwaway-database step,
one new teardown step, one new failure-dump step, and one sentence added
to the existing `Pre-verify secrets reached runner env` comment.
3. Comment-only additions inside the `web-e2e` and
`interaction-coverage` S3 stub blocks. No value changes in either.

It does not touch the `Repo policy lints` region that #1417 edits, and
it does not touch the upstream-refusal classifier that #1410 changed.
Rebased onto `0d089744a` cleanly with no conflicts.

## Buglog entry

```json
{"id":"bug-2026-08-29-ci-object-storage-dead-secret","date":"2026-08-29","title":"CI object storage endpoint pointed at a deleted Supabase Cloud project, so the file upload path could not fail","error_message":"file upload to object storage failed bucket=hive-files error=\"Put \\\"https://<project>.supabase.co/storage/v1/s3/hive-files/...\\\": dial tcp: lookup <project>.supabase.co: no such host\"","root_cause":"The S3_ENDPOINT repository secret was last written 2026-04-21, four months before the August self-hosted Supabase cutover, and still named the deleted Supabase Cloud project. The live-integration job read it at job level, so every POST /v1/files in CI died at DNS and the Files and Batches conformance assertions were carried as it.fails markers rather than as coverage. The same stale secret family also killed the OWUI nightly at its seeding step via SUPABASE_URL (#1370), which then skipped the entire Playwright suite and left a misleading missing-browser cascade as the only visible red. Issue #1380 attributed the blindness to the http://127.0.0.1:9 stub in ci.yml, but that stub is in two Playwright jobs that run no SDK suite.","fix":"Removed every secrets.S3_* reference from live-integration and agent-visual-proof. live-integration now boots its own throwaway supabase/storage-api at the digest docker-compose.enterprise.yml pins, on the throwaway Postgres it already stands up, whose 00-extensions.sql already creates the storage schema and roles. The fixture asserts a signed PUT and GET before printing its values. The it.fails markers came off. Guards added: a unit test asserting the job keeps the fixture and no S3 secret, and a seam-test assertion that the fixture and compose pin the same image.","tags":["ci","storage","s3","supabase","dark-detector","stale-secret","sigv4"],"issues":[1324,1380,1370,1282]}
```

```json
{"id":"bug-2026-08-29-s3-absolute-form-request-target","date":"2026-08-29","title":"S3 client sent absolute-form request targets, which Supabase Storage refuses with an unnamed 500","error_message":"s3 PUT /s3/hive-files/<key> failed with status 500: <Code>InternalError</Code><Message>Internal Server Error</Message>; storage side: {\"code\":\"ERR_INVALID_URL\",\"input\":\"http://localhost:8080http://172.17.0.1:5000/s3/hive-files/<key>\"} TypeError: Invalid URL at SignatureV4.constructCanonicalRequest","root_cause":"packages/storage/signing.go SignHTTP sets req.URL.Opaque to \"//host/path\" so the AWS SigV4 signer receives an un-normalized path, and never cleared it. net/url.RequestURI returns Opaque verbatim and re-attaches the scheme when it starts with \"//\", so every signed S3 request went on the wire in absolute form (PUT http://host/s3/bucket/key HTTP/1.1). RFC 9112 reserves absolute form for a request to a proxy. The deployed box never saw it because every S3 request passes through Caddy, which normalizes the target before proxying. Reached directly, Supabase Storage builds its canonical URI as new URL(\"http://localhost:8080\" + prefix + request.url) and throws on the doubled origin.","fix":"Clear req.URL.Opaque after signer.SignHTTP returns. The signature is unaffected: the signer derives its canonical URI from Opaque while set, stripping the leading //host, and that string is byte identical to the EscapedPath the wire form falls back to once Opaque is empty. presignHTTP still keeps Opaque, because url.URL.String renders a bare-path Opaque as http:/s3/... and the //host form is what makes a presigned URL absolute. Four tests added, each verified red first.","tags":["storage","s3","sigv4","http","rfc9112","proxy-masked","dark-detector"],"issues":[1324,1380,1282]}
```
sakibsadmanshajib added a commit that referenced this pull request Aug 29, 2026
## Summary

This is the batched buglog follow-up for the pull requests merged to
`main` on 2026-08-29. Its diff is `.wolf/buglog.jsonl` and nothing else.

Per `.claude/rules/openwolf.md`, every fixed bug, error, failed test or
failed build must be logged, but the line may never be appended on a fix
branch. `merge=union` in `.gitattributes` resolves concurrent appends
locally and is ignored by GitHub's server side merge, so two branches
that both appended land in hard conflict there. An unmergeable pull
request gets no `refs/pull/N/merge`, no `pull_request` run and therefore
zero checks, and the required status gate then blocks the merge for a
reason the page never states (issue #873). Each fix accordingly carried
its entry in its own pull request body, and this pull request copies
them onto `main` in one batch, which the protocol explicitly prefers
over one pull request per entry.

## Scope examined

Fifty nine pull requests merged to `main` on 2026-08-29. Forty eight of
them carried at least one entry, for eighty two entries in total. Thirty
two of those were already on `main` and are skipped, leaving fifty
appended here from thirty four pull requests.

The largest block of skips comes from #1342, the equivalent batch for
the 2026-08-28 merges, which merged earlier the same day and already
landed thirty six entries covering #1257, #1268, #1276, #1277, #1287,
#1292, #1293, #1294, #1296, #1301, #1303, #1305, #1313, #1335 and #1337.

## What landed

Fifty entries appended, one JSON object per line, append only. The 232
pre-existing lines are byte identical to `origin/main` (verified by
hashing the first 232 lines of the result against the base file). Every
line in the resulting file parses as JSON and carries `error_message`,
`root_cause`, `fix` and `tags`.

| Source | Entries |
|---|---|
| #1083 | 2 |
| #1277 | 1 |
| #1278 | 1 |
| #1298 | 1 |
| #1334 | 1 |
| #1336 | 3 |
| #1343 | 1 |
| #1346 | 1 |
| #1351 | 1 |
| #1365 | 2 |
| #1368 | 1 |
| #1369 | 1 |
| #1371 | 3 |
| #1375 | 3 |
| #1376 | 1 |
| #1378 | 1 |
| #1379 | 2 |
| #1388 | 5 |
| #1389 | 3 |
| #1390 | 2 |
| #1393 | 1 |
| #1394 | 1 |
| #1410 | 1 |
| #1417 | 1 |
| #1421 | 1 |
| #1423 | 1 |
| #1424 | 1 |
| #1426 | 1 |
| #1429 | 1 |
| #1431 | 1 |
| #1433 | 1 |
| #1434 | 1 |
| #1436 | 1 |
| #1439 | 1 |

Entries are copied verbatim from their source pull request bodies.
Nothing was rewritten, no field was invented, and no field was added. No
JSON needed repair: all eighty two extracted entries parsed on the first
attempt and all four required fields were present on every one.

## Merged pull requests that carried no entry

Eleven of the fifty nine. Recorded here because the gap is itself the
useful signal.

| Pull request | Title | Assessment |
|---|---|---|
| #1013 | chore(deps): bump the go-minor-patch group across 1 directory
with 4 updates | Dependabot bump, no defect fixed, no entry expected |
| #1015 | chore(deps): bump the go-minor-patch group across 1 directory
with 6 updates | Dependabot bump, no entry expected |
| #1016 | chore(deps): bump golang from 1.26-alpine to 1.27-alpine in
/deploy/docker | Dependabot bump, no entry expected |
| #1218 | chore(deps): bump postcss from 8.5.19 to 8.5.26 in
/apps/desktop | Dependabot bump, no entry expected |
| #1219 | chore(deps): bump golang.org/x/crypto from 0.41.0 to 0.52.0 in
/apps/control-plane | Dependabot bump, no entry expected |
| #1342 | chore: batch buglog entries for the 2026-08-28 merges | The
previous batch pull request itself, correctly carries no entry of its
own |
| #1364 | chore: remove four dead skills and record the patterns that
cost time | Protocol gap. The body records patterns that cost time,
which is the shape of a buglog entry, but none was written as one |
| #1383 | test: retire stale expected-failure markers, restore the ones
that are true (#1381, #1382, #1324) | Protocol gap. Stale `it.fails`
markers reading as red is a real defect that was fixed here and should
have carried an entry |
| #1384 | docs: correct D-047, hive-auto reverted to variable pricing
(D-059) | Decision ledger correction, arguably a documentation defect,
no entry written |
| #1387 | chore(deps): bump next from 15.5.23 to 16.3.3 in
/apps/agent-console | Dependabot bump, no entry expected |
| #1398 | docs: rescue the 2026-08-25 parity captures and add the
2026-08-29 QA matrix evidence | Documentation and evidence rescue, no
entry written |

Six of the eleven are Dependabot bumps and one is the previous batch, so
the genuine protocol gaps are #1364, #1383, #1384 and #1398. Of those,
#1383 is the one worth a follow-up: it fixed a real defect class (a
stale expected-failure marker reads as a red "Expect test to fail" and
gets dismissed as pre-existing) and left no record.

## Entries skipped as already present

Thirty two. Thirty of them matched an entry already on `main` on
`error_message`, `id` or `fix`. Two more from #1278 are semantic
duplicates that an exact match would have missed, and were skipped after
reading the landed entries they duplicate:

- #1278's `streaming content_block_start omits text field` entry is
covered by the consolidated
`bug-2026-08-28-anthropic-sdk-wire-conformance` entry landed from #1296,
whose root cause names the same `omitempty` on
`StreamContentBlock.Text`.
- #1278's `GET /v1/models leaked an upstream provider name` entry is
covered by `BUG-1284`, landed from #1300, which names the same
`public.model_aliases.summary` publication path.

#1278's third entry, on `top_k` forwarding producing a 400, is not
covered anywhere on `main` and is appended here. #1342 recorded #1278 as
fully "merged into #1296", which was accurate for two of its three
entries.

## Note on entry quality

One appended entry is thin: #1277's parity re-score record carries
`error_message` of `n/a` and a root cause of "console had no
privacy/data-policy surface at all". It is a parity gap record rather
than a defect record. It is included exactly as written rather than
embellished, per the protocol's preference for the author's own words.

## Test plan

- [x] Branch cut fresh from `origin/main`, diff is `.wolf/buglog.jsonl`
and nothing else
- [x] First 232 lines byte identical to the base file (md5 match)
- [x] All 282 resulting lines parse as JSON and carry `error_message`,
`root_cause`, `fix` and `tags`
- [x] No `.wolf/` telemetry (`anatomy.md`, `memory.md`,
`token-ledger.json`, `hooks/_session.json`, `buglog.json`) in the commit
- [ ] The six required checks report green via the inert path allowlist
in `.github/workflows/ci.yml`

---------

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