Skip to content

A refused project update still rewrites settings.json #46

Closed

Description

@barelyworkingcode

Problem

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.

Activity

  1. added
    bugSomething isn't working
    needs-reviewAwaiting owner review before work starts
    and removed
    needs-reviewAwaiting owner review before work starts
    on Sep 25, 2026
  2. barelyworkingcode commented on Sep 26, 2026

    @barelyworkingcode
    OwnerAuthor

    Contract

    Approved as part of Group 4.

    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.

    1. 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.
    2. TestProjectUpdateOfAMissingProjectWritesNothing: an unknown id returns found == false with no error, and the file is untouched.
    3. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions