Refuse writes PATCH cannot perform; honour ?workspace=; reach sub-routes by full slug - #13
Merged
Merged
Conversation
Three defects found while pointing agent lifecycle hooks at tracker. All three
share a shape: the server answers 200 to a request it did not carry out, so a
caller learns nothing is wrong unless it reads back.
PATCH accepted a content field, ignored it, and returned 200 with a content_url
and content_hash for the *old* bytes:
PATCH {"content":"REPLACED","tags":["probe"]} -> 200, content_hash echoed
GET .../raw -> "ORIGINAL", still version 1
Content is written by PUT, which takes a lease and an If-Match version. PATCH
now decodes strictly, so content -- or any misspelt field -- is a 400 naming
PUT, rather than a silent no-op. This is the one that matters: it is
indistinguishable from success at the call site, and it cost a real edit that
reported itself as applied.
?workspace= was accepted and ignored on read paths. The MCP tools take a
per-call workspace argument and the /fleet handlers already read the query
param, but the auth middleware only ever consulted X-Workspace, so
GET /docs?workspace=scripture-gamma -> 200, documents from *default*
GET /docs -H 'X-Workspace: ...gamma' -> 200, the right 75
The header still wins; the query param is a fallback. Neither can widen a
confined token -- that check is unchanged and above this one.
/revisions was unreachable by a multi-segment slug. A {rest...} wildcard has to
end its pattern, so folio-style slugs never matched /docs/{id}/revisions:
/docs/fleet/alpha-testpane-codex/revisions -> 404
/docs/{uuid}/revisions -> 200
/docs/fleet/alpha-testpane-codex/raw -> 200 (control)
The raw control is what makes this a routing gap rather than a design choice.
getDoc now dispatches /revisions and /revisions/{n}/raw by suffix, the same way
it already handles /raw and /lock, and lockDocRest does for POST and DELETE.
Revision-raw is tested before plain raw because it also ends in "/raw".
Tests fail against each prior behaviour: reverting the query fallback fails
workspaceSelector/query_only, and reverting the split fails cutRevisionRaw on
every revision path. Verified on a copied tree. Full DB-backed suite passes.
Four defects the reviewer found in the previous commit.
The strictness was not strict. A decoder reads one JSON value and stops, so
`{"add_tags":["x"]} {"content":"new"}` still applied the tags, discarded the
content and answered 200 — the exact no-op the commit claimed to remove, still
reachable. Decoding now requires EOF after the object.
`web/usage.md` documented PATCH as taking `content` and `content_type`. That is
a client the change breaks, and searching mcp.go, skills/, scripts/ and
openapi.yaml missed it. The guide now points content writes at PUT. openapi.yaml
declares `additionalProperties: false` on the PATCH body and the 400 it can now
return.
The test file's header claimed it covered the PATCH regression. There was no
PATCH test — only the two pure helpers. The header now says what the tests
actually cover, and says that it previously overstated.
`silent_writes_db_test.go` adds the coverage that was claimed: PATCH refusing
`content`, `content_type` and a trailing second object while leaving version and
tags untouched and still allowing a legitimate relabel; `?workspace=` selecting
another workspace through the auth middleware; and a folio-style slug reaching
`/revisions` and `/revisions/1/raw`, asserting the *old* bytes so a plain "/raw"
fallback cannot pass by serving the current ones.
All three fail with the fixes reverted on a copied tree, and run rather than
skip: `scripts/test.sh -run ... -v` shows RUN and PASS for each.
Not addressed here, because they are contract decisions rather than defects in
this change: `/changes` reads ?workspace= itself and lets the query win, while
these endpoints let the header win, so an unconfined caller sending both can get
a response whose X-Workspace names one workspace and whose body came from
another. The commit message's "exact slug always wins" was also too broad —
/docs/a/raw serves a's bytes, not a document named "a/raw".
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Three defects, all the same shape: the server answers 200 to a request it did not carry out, so a caller learns nothing is wrong unless it reads back. Found while pointing agent lifecycle hooks at tracker.
1. PATCH accepted a
contentfield and discarded itThe response even carried a
content_urlandcontent_hash— for the old bytes — so it looked like a successful content write. This cost a real edit that reported itself as applied.Content is written by
PUT, which takes a lease andIf-Match. PATCH now decodes strictly and returns 400 naming PUT. It also requires EOF after the object, because a decoder reads one JSON value and stops —{"add_tags":["x"]} {"content":"new"}would otherwise still apply the tags, drop the content and answer 200.2.
?workspace=was accepted and ignored on readsThe MCP tools take a per-call
workspaceargument and the/fleethandlers already read the query param, but the auth middleware only ever consultedX-Workspace. The header still wins and the query is a fallback. Neither can widen a confined token — that check is unchanged and resolves before the selector.3.
/revisionswas unreachable by a multi-segment slugA
{rest...}wildcard must end its pattern, so folio-style slugs never matched/docs/{id}/revisions. Therawcontrol is what makes this a routing gap rather than a design choice.getDocnow dispatches/revisionsand/revisions/{n}/rawby suffix, the same way it already handles/rawand/lock, andlockDocRestdoes for POST and DELETE. Revision-raw is tested before plain raw because it also ends in/raw.Tests
silent_writes_db_test.gogoes through the real mux and the auth middleware:content,content_typeand a trailing second object; version and tags are asserted untouched afterwards, and a legitimate relabel still returns 200?workspace=selects another workspace end to end/revisionsand/revisions/1/raw, asserting the old bytes — so a plain/rawfallback cannot pass by serving the current onessilent_writes_test.gounit-tests the two pure helpers.All three DB-backed tests fail with the fixes reverted on a copied tree, and are confirmed to run rather than skip (
scripts/test.sh -run ... -vshows RUN and PASS for each). Full suite green.Docs
web/usage.mddocumented PATCH as takingcontentandcontent_type— a real client this breaks. It now points content writes at PUT.openapi.yamldeclaresadditionalProperties: falseon the PATCH body and the 400 it can return.Deliberately not addressed
/changesreads?workspace=itself and lets the query win, while these endpoints let the header win. An unconfined caller sending both can get a response whoseX-Workspacenames one workspace and whose body came from another. That is a contract decision, not a defect in this change./docs/a/rawservesa's bytes rather than a document literally nameda/raw, because the explicit single-segment route matches first. Pre-existing; noted so the new suffix dispatch is not read as a general "exact slug always wins" rule.