You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
A refused project update still rewrites settings.json #46
PUT /api/projects/{id} with an invalid field (e.g. an unknown mode) returns 400 but rewrites settings.json with identical content. The mtime changes and the file watcher and fan-out fire for no change.
Root cause
ProjectOps.Update's queued closure records updateErr and returns nil, so the write isn't declined.
Fix
Decline the write on a refusal (WithDeclinable), as SetDefaultProject does. Failing test first: a refused update leaves settings.json byte-identical, mtime included, and publishes no commit event.
Fix. In ProjectOps.Update (cmd/relay/project_ops.go), the queued WithDeclinable callback returns the refusal instead of nil, so the store declines the write:
If project.ApplyUpdate returns an error, the callback returns it. Update still returns (config.Project{}, true, updateErr). The HTTP 400 and the IPC error are unchanged, and the refusal is not wrapped as errProjectSaveFailed.
If the project is not found (found == false), the callback returns an unexported sentinel. Update maps it back to (config.Project{}, false, nil), so the 404 path is unchanged.
Nothing else changes: no signatures, the presence gate, the recheck, audit or wire shapes. The audit record still runs only after a write that succeeded.
Test (red first). New file cmd/relay/project_update_declined_test.go. It reuses the odw* helpers from ops_declined_write_test.go without editing them.
TestProjectUpdateThatIsRefusedWritesNothing: a stored local project; Update with an invalid mode, then with another field refusal (for example an invalid access value). Each returns an error with found == true, and settings.json is untouched: assertUntouched covers bytes and file identity, so no rename-over.
TestProjectUpdateOfAMissingProjectWritesNothing: an unknown id returns found == false with no error, and the file is untouched.
A guard row: a valid update still writes and returns the updated project.
Files. Dev: cmd/relay/project_ops.go. Test writer: cmd/relay/project_update_declined_test.go. No docs change: docs/tokens.md already states that a declined write is not a write.
Activity
barelyworkingcode commentedon Sep 26, 2026
Contract
Approved as part of Group 4.
Fix. In
ProjectOps.Update(cmd/relay/project_ops.go), the queuedWithDeclinablecallback returns the refusal instead of nil, so the store declines the write:project.ApplyUpdatereturns an error, the callback returns it.Updatestill returns(config.Project{}, true, updateErr). The HTTP 400 and the IPC error are unchanged, and the refusal is not wrapped aserrProjectSaveFailed.found == false), the callback returns an unexported sentinel.Updatemaps it back to(config.Project{}, false, nil), so the 404 path is unchanged.Test (red first). New file
cmd/relay/project_update_declined_test.go. It reuses theodw*helpers fromops_declined_write_test.gowithout editing them.TestProjectUpdateThatIsRefusedWritesNothing: a stored local project;Updatewith an invalidmode, then with another field refusal (for example an invalidaccessvalue). Each returns an error withfound == true, andsettings.jsonis untouched:assertUntouchedcovers bytes and file identity, so no rename-over.TestProjectUpdateOfAMissingProjectWritesNothing: an unknown id returnsfound == falsewith no error, and the file is untouched.Files. Dev:
cmd/relay/project_ops.go. Test writer:cmd/relay/project_update_declined_test.go. No docs change:docs/tokens.mdalready states that a declined write is not a write.