fix(artifacts): optimistic-concurrency token for content PATCH (409 on stale write) - #7818
fix(artifacts): optimistic-concurrency token for content PATCH (409 on stale write)#7818peterhieuvu wants to merge 1 commit into
Conversation
|
CI status note: the only red is Coverage Combine → "Upload combined coverage", which failed with a GitHub artifact-service error, not a test failure: All test lanes on the run are green (backend, frontend, lint, build); Coverage Gate then fails closed because Could a maintainer re-run the failed jobs on run 33600280562? I'm holding off pushing anything — the diff is unchanged and a push would only re-arm the fork approval gate for the whole suite, which is more maintainer work than a re-run click. |
37db268 to
02a001e
Compare
Design Review (Fable 5, fork) — 🟡 CONCERNSDesign-level review of I've reviewed the patch against the base tree (the Design-Verdict: CONCERNS Sound, well-layered guard on a real lost-update class; one mirror verdict ("too_large" on an already-oversize source) still forms the permanent-409 loop the PR itself outlawed. WatchAn oversize file-backed source makes a guarded save permanently unsavable with a false message. Cause: Suggestions
[DESIGN-REVIEWED] acf8cbd |
GPT 5.6 Review (fork) — 🔴 changes requested (blocking)Reviewed 2 of 2 blocking finding(s) are security-class and were withheld from adjudication, so the blocking verdict stands. BLOCKING -- src/kiro_crew/artifacts.py:1792 -- guarded replacement still clobbers a racing external save BLOCKING -- src/kiro_crew/artifacts.py:2076 -- failed rollback leaves rejected content persisted FINDING -- src/kiro_crew/dashboard/handlers/artifacts.py:1828 -- [BLOCK-MERGE] acf8cbd Adjudication (Opus 4.8) — is blocking on each finding proportionate?I've traced both fenced findings against the base code and the pre-fetched diff. Here is my adjudication. F1 — artifacts.py:1792 ( F2 — artifacts.py:2076 ( 🏷️ Fenced finding(s) machine-flagged as likely edge caseThe security fence keeps these findings blocking regardless of adjudication; the only clearance path is a human override recorded by a repository writer, who must independently verify a rationale before recording it — it is machine-authored, and a wrong override on a security-class finding ships exactly the class the fence exists to stop. (This lane's comment deliberately carries no override command.)
|
First Principles Review (Fable 5, fork) — ✅ PASSPremise-level review of All verification passes cleanly: the CAS primitive is reused (not duplicated) from First-Principles-Verdict: PASS Verify #7751 actually reports this clobber — the fix's provenance rests on that link plus the base-failing What this change shipsIntent: stop two concurrent writers (window/agent/external file edit) from silently overwriting each other's artifact content — a FIX. Inventory (10 items)
[FIRST-PRINCIPLES-REVIEWED] acf8cbd |
UX Review (Fable 5, fork) — 🟡 CONCERNSUX-level review of UX-Verdict: CONCERNS Solid conflict-recovery copy and flow, but fork-lane screenshots are unviewable and two recovery paths leave the user under-informed at the point of action. Watch
Evidence gaps
[UX-REVIEWED] acf8cbd |
Opus 4.8 Review (fork) — ✅ no blocking findingsReviewed Review detailsI've traced the candidate through the actual code paths. The candidate's mechanism is real at the code level: for a source file over But it fails the validation bar on two independent grounds:
No new grounded findings emerged from the surrounding code (the No findings. [OPUS-REVIEWED] acf8cbd |
02a001e to
638025f
Compare
The window is real and is now closed on both edges, in
On the prescribed fix ("revert the source-first write"): reverting would reopen the exact hole the CAS exists to close — with |
bolichen97
left a comment
There was a problem hiding this comment.
Description / code mismatch
One item does not line up: the checklist asserts no module doc covers this endpoint, but docs/system-specs/modules/artifacts.md documents PATCH /api/artifacts/{slug} and is not updated for the new request field, the new response field, or the new 409.
1. Documented PATCH contract changes (new request field, new response field, new 409) with no spec update, and the Description states the opposite
The Description says —
- Documentation updated (if applicable) — behavior documented in docstrings; no module doc covers this endpoint today
The code does — docs/system-specs/modules/artifacts.md:142 carries a PATCH /api/artifacts/{slug} row for this endpoint, so a module doc does cover it. The diff changes that endpoint's contract in three documented-surface ways and leaves the row untouched: the handler accepts a new optional expected_sha256 request field, rejects a non-string token with 400, and maps ArtifactConflictError to a 409 carrying {error, current_sha256, version} (src/kiro_crew/dashboard/handlers/artifacts.py); and to_dict adds content_sha256 to every content-carrying response (src/kiro_crew/artifacts.py). Docstrings are not a substitute here, because the spec row is the documented contract for this endpoint.
Risk — AGENTS.md:218 requires updating the spec in the SAME commit when an API, schema, or documented behavior changes. The artifacts spec is what the next contributor reads before touching a content-write path or writing a second client; leaving it silent means they will not know a token exists, that a 409 is now reachable, or that content_sha256 is part of the response contract. The checklist line makes this worse rather than neutral: it asserts there is no module doc to update, which removes the one prompt a reviewer would use to catch the omission. Docs Lint only validates index integrity, so no gate catches it either.
Required change — Extend the PATCH /api/artifacts/{slug} row at docs/system-specs/modules/artifacts.md:142 in this commit with: the optional expected_sha256 token, the 409 outcome and its {error, current_sha256, version} body, the 400 on a non-string token, and content_sha256 on content-carrying responses. Correct the checklist line "no module doc covers this endpoint today" to name the spec update. Store-side prose for ArtifactStore.update(expected_sha256=), ArtifactConflictError, and the file-backed compare-and-swap layer is welcome but optional, since that doc does not currently enumerate update()'s parameters or the other artifact exception classes.
638025f to
d772887
Compare
|
You're right on both counts — the spec row exists and my checklist line asserted the opposite, which is exactly the kind of claim that hides the omission. Fixed in
I kept the store-side prose ( |
d772887 to
ab9a547
Compare
33126da to
c0e5fd8
Compare
c0e5fd8 to
39226df
Compare
Fixed as prescribed, and — since this is the third round on this span — generalized to the invariant that makes all three rounds' failure modes unreachable rather than another point-patch: a guarded save either fully applies or changes nothing. On the guarded path every non- The trade this makes explicit: a guarded save of a genuinely unwritable source (read-only file) now refuses loudly instead of silently demoting and diverging — the honest behavior for an endpoint whose contract is "only if nothing changed." |
|
Round-3 findings addressed in Opus — encoding mismatch between the token and the CAS base (blocking): fixed. The guard now performs ONE raw read of the source and derives both representations from it: the decoded text (what clients see — the token compares against this) and the raw-byte SHA-256 (what the mirror's compare-and-swap verifies at the descriptor). A source with non-UTF-8-round-tripping bytes saves cleanly instead of 409ing forever; locked in by UX — half-informed overwrite (concerns): fixed. The 409 now renders under its own title ("Save refused — content changed" — protection, not a fault) and carries a "View the newer content" link that opens the artifact's popout window, which renders the live version — inspectable without cancelling the edit buffer the banner promised to keep. Screenshots in the PR body show both. First Principles — undeclared |
39226df to
578e8ab
Compare
Open PR relationship auditThis is a consolidated, point-in-time code-level audit note. It compares complete merge-base diffs and current/merged code; it does not treat a shared topic as duplication or partial coverage as completion. Relationship findings
No PR, Issue, label, branch, or review state was changed by the relationship-note portion of this audit. |
578e8ab to
4e99f39
Compare
|
Round-4 findings addressed in GPT
Tests: GPT GPT Verification: 437 backend artifact tests, mypy/flake8/isort clean, the repo's own baselined black gate passes, tsc, 166 frontend tests, eslint at the zero-warning cap, i18n catalog parity across all locales, production build. Evidence screenshots re-captured from a live instance at this head. |
4e99f39 to
acf8cbd
Compare
|
Rebased onto main Conflicts: none. Clean rebase, single commit reapplied as Gates run locally on the rebased head (changed files only): Please review the rebased head. A maintainer push makes the maintainer the last pusher, so a second approver is needed under the repo's last-push rule. Reply if anything looks wrong. |
Problem / Motivation
PATCH /api/artifacts/{slug}has no stale-write protection:api_artifact_updatepassesbody["content"]straight intoArtifactStore.update(), which writes unconditionally. Last write wins. Two dashboard windows open on the same artifact silently clobber each other today; a user's stale buffer silently overwrites an agent's iteration; and for file-backed artifacts, an external write tosource_pathis invisible to an editor holding a stale buffer. The frontendartifactEditGuardis page-local and does nothing across windows or surfaces.Neither existing field can detect a stale base:
versionis not bumped by silent saves (snapshot: false, the dashboard default), so two different live states can share a version number;updated_atis bumped by metadata-only renames/retags, so it false-positives on non-content changes.Why it matters
This is the same stale-snapshot-clobber class already recognized on the agent-config PUT surface (#7470, #7089) — the artifacts PATCH instance of it. The blast radius is user data: the overwritten content is someone's live edit. It gets worse as write frequency rises — any client implementing debounced autosave of live state multiplies the race windows. The store itself already treats this as a real problem for remote pushes (
PublicationMetadata.last_pushed_sha256, sent asexpectedCurrentSha256, is described in source as "the optimistic-concurrency guard"); local writes had no equivalent.What changed (motivation → approach → change)
Symptom → root cause: unconditional content writes with no way for a caller to say "only if nothing changed since I read." Approach: opt-in optimistic concurrency mirroring the store's own remote-push design, with the compare inside the store lock (a handler-level compare would be TOCTOU-racy against concurrent store writers).
ArtifactStore.update()gains optionalexpected_sha256. When provided withcontent, live content is read exactly the wayget()reads (source file for file-backed artifacts,current.htmlotherwise), hashed, and compared underself._lockbefore any mutation. Mismatch raises the newArtifactConflictError, which carriescurrent_sha256+version.source_pathbetween the compare and the mirror. For guarded writes the source mirror runs FIRST, as a descriptor-pinned compare-and-swap through the existingverified_replace_file_nolinkprimitive (base_hash=expected_sha256,max_bytes=MAX_CONTENT_BYTES);conflict/too_largeabort beforecurrent.htmlor metadata are touched._try_write_source_pathnow returns the verdict string instead of a bool; all call sites updated. Unguarded writes keep today's last-write-wins + demotion-to-source_copy_onlysemantics exactly.ArtifactConflictError→ 409 with{error, current_sha256, version}so the client can refetch and re-base; a non-string token is a 400 (caller bug), not a phantom conflict.to_dictincludescontent_sha256whenever content is included — computed on raw content before the HTTP serializer's redaction pass, because the guard hashes the raw bytes on disk. Only computed on detail/update responses; the list path serializes without content and pays nothing.ArtifactDetailPageadopts the token:handleSavesendsexpected_sha256from its last fetch, and a 409 renders as a "changed since you loaded it" notice that preserves the buffer (editedContentuntouched,editingstays true) and refetches so the next save carries the fresh token — a deliberate overwrite instead of a silent one. New i18n key in all 13 catalogs (en-XA regenerated).Token omitted = today's behavior; zero existing callers change (MCP, revert, pull/publish, blank-settlement all send no token).
Review-round hardening (rounds 2–3):
okmirror verdict aborts with 409 before any store write. The unguarded path keeps its demote-to-source_copy_onlyfallback unchanged.event_typevalidation moved to the top ofstore.update()(before any side effect), so an invalidevent_typeon a non-snapshot save is now a 400 instead of being silently accepted — previously it could also raise mid-write and leave a save partially applied.Round-4 hardening (guarded mirror ordering). The guarded save now writes the store's own copy first and mirrors to the source second (CAS). Consequences, both deliberate:
"conflict"/"too_large"mirror verdicts restore the store copy and answer 409 with nothing applied."refused"mirror (read-only file, foreign owner, path outside its authorizing root — no competing writer) no longer 409s: it falls through to the pre-existing demote-to-source_copy_onlypath, keeping the edit and visibly detaching the artifact from the unwritable source. The previous behavior was a permanent 409 loop, since the 409 body re-based the client onto the same unwritable file.Tests
Store (
test/test_artifacts.py,TestConflictToken): matching token writes; stale token raises with recovery fields and writes nothing; omitted token keeps last-write-wins; token ignored on metadata-only updates; a silent save trips a stale token despite an unchanged version number (the reason the token is a hash, not the version); an external source-file edit trips the guard; a source write racing in between the compare and the mirror answers conflict instead of clobbering (CAS layer); unguarded mirrors still last-write-wins;to_dictcarries/omits the token correctly. Existing_try_write_source_pathtests updated to the verdict-string contract.Handler (
test/test_artifacts_handlers.py): stale token → 409 withcurrent_sha256/versionand content unchanged; matching token saves and returns the fresh token; malformed token → 400 not 409; detail response includescontent_sha256.Component (
website/src/test/ArtifactDetailPage.saveConflict.test.tsx, new — with a controlled Pierre editor stub so a buffer re-seed regression fails visibly): Save sends the token; 409 shows the conflict notice, keeps the buffer, and refetches; non-409 errors keep the generic error path.Manual verification
Live two-writer race in an isolated pod: opened the artifact, dirtied the editor, issued a silent save from "another window" via the API, clicked Save → 409 banner with the buffer intact; clicked Save again → informed overwrite applied with the fresh token. Screenshots below are from that run.
Screenshots / video
Stale save refused — banner, buffer preserved, Save still armed:
"View the newer content" opens the live version in a popout without leaving the edit buffer
Related Issues
Fixes #7751
Pattern harvest
Rule candidate: review-prompt
Pattern: "content-write endpoint accepts no base token — concurrent writers silently clobber (compare against #7470/#7089 class); a compare outside the writer's own lock, or a mirror to an externally-writable file without CAS, reopens the same hole"
Checklist
feat|fix|docs|refactor|perf|test|chore|ci|build|revert: ...)PATCH /api/artifacts/{slug}row indocs/system-specs/modules/artifacts.mdupdated with the token, the 409 body, the 400 on a malformed token, andcontent_sha256on content-carrying responses