Skip to content

Refuse writes PATCH cannot perform; honour ?workspace=; reach sub-routes by full slug - #13

Merged
chicagobuss merged 2 commits into
mainfrom
fix/silent-writes-and-slug-subroutes
Aug 5, 2026
Merged

Refuse writes PATCH cannot perform; honour ?workspace=; reach sub-routes by full slug#13
chicagobuss merged 2 commits into
mainfrom
fix/silent-writes-and-slug-subroutes

Conversation

@chicagobuss

Copy link
Copy Markdown
Owner

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 content field and discarded it

PATCH {"content":"REPLACED","tags":["probe"]}  → 200, content_url + content_hash echoed
GET   .../raw                                  → "ORIGINAL", still version 1

The response even carried a content_url and content_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 and If-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 reads

GET /docs?workspace=scripture-gamma   → 200, documents from *default*
GET /docs -H 'X-Workspace: ...gamma'  → 200, the right 75

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. 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. /revisions was unreachable by a multi-segment slug

/docs/myfolio/file.md/revisions   → 404
/docs/{uuid}/revisions            → 200
/docs/myfolio/file.md/raw         → 200   ← control

A {rest...} wildcard must end its pattern, so folio-style slugs never matched /docs/{id}/revisions. 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

silent_writes_db_test.go goes through the real mux and the auth middleware:

  • PATCH refuses content, content_type and 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
  • a folio-style slug reaches /revisions and /revisions/1/raw, asserting the old bytes — so a plain /raw fallback cannot pass by serving the current ones

silent_writes_test.go unit-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 ... -v shows RUN and PASS for each). Full suite green.

Docs

web/usage.md documented PATCH as taking content and content_type — a real client this breaks. It now points content writes at PUT. openapi.yaml declares additionalProperties: false on the PATCH body and the 400 it can return.

Deliberately not addressed

  • /changes reads ?workspace= itself and lets the query win, while these endpoints let the header win. An unconfined caller sending both can get a response whose X-Workspace names one workspace and whose body came from another. That is a contract decision, not a defect in this change.
  • /docs/a/raw serves a's bytes rather than a document literally named a/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.

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".
@chicagobuss
chicagobuss merged commit a0d3dd7 into main Aug 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant