Devlog: The PUT That Bypassed Everything (A217)

We just closed a quiet but significant gap in Smeldr's state governance layer. The fix is small — seven lines — but the gap it closes was hiding behind a test that looked correct but wasn't: HTTP PUT requests could change a content item's status to anything, bypassing every transition and role check.

Amendment: A217 Date: 2026-07-15


We just closed a quiet but significant gap in Smeldr's state governance layer. The fix is small — seven lines — but the gap it closes was hiding behind a test that looked correct but wasn't.

What was wrong

Smeldr's state flow system enforces two things: (1) transitions must follow registered edges in the flow graph, and (2) certain transitions can require a specific role (RequiredRole). Both checks live in validateTransition.

The lifecycle methods — MCPPublish, MCPArchive, MCPSchedule — all call validateTransition. The MCP update path (MCPUpdate) sidesteps it by design: it restores the status from the existing record, making it impossible to change status through an MCP update at all.

But the HTTP update path (PUT /{prefix}/{slug}) did neither. It decoded the request body into a fresh item, preserved the ID and Slug from the existing record, and then saved whatever Status value the caller submitted. No call to validateTransition. No role check. Any authenticated caller with write access could PUT any content item to any status string — including states that don't exist in any flow, and transitions that require elevated roles.

The fix is a seven-line guard added after prevStatus and newStatus are resolved:

if prevStatus != newStatus {
    if err := validateTransition(ctx, m.db, m.roleStore, ctx.User().ID, m.contentTypeName, string(prevStatus), string(newStatus)); err != nil {
        WriteError(w, r, err)
        return
    }
}

Fail-open semantics (nil DB, no flow registered, non-SQLite) are preserved — matching every other call site.

The test that concealed the gap

TestModule_updateHandler_unpublish tested exactly this code path — a PUT that transitions a Published item to Draft. It asserted 200 OK and that the saved item had status Draft. The test passed.

But the test module had no database (m.db == nil). validateTransition checks the DB first; if it's nil, it returns nil immediately (fail-open). So the test was silently bypassing the exact validation path the fix adds. A published→draft transition would have been rejected in production by validateTransition — except it wouldn't, because of the second gap.

The second gap: published→draft was missing from the default flow

After writing the fix, I checked whether the test would actually pass against a real migrated database. The answer was: no, it wouldn't — because published → draft was not in the default flow's transition list.

The default flow had five transitions: draft→scheduled, draft→published, scheduled→published, published→archived, draft→archived. Unpublishing (published → draft) was never added.

This is an independent gap in the default flow itself, not a side effect of T150. The fix adds it as the sixth transition:

{"published", "draft"},

The ON CONFLICT DO NOTHING insert in migrateStateFlows makes this additive and safe for existing production instances.

The test was updated to use a real migrated SQLite database. Now it exercises the full path — validateTransition runs, finds the published → draft edge, and allows the transition. If either gap had remained open, the test would have failed.

What this means for developers

If you're using Smeldr with a state flow configured and a database wired in, HTTP PUT requests that attempt to change status now go through the same governance gate as the lifecycle methods. An invalid target state returns 409. A missing transition edge returns 409. A transition that requires a role you don't have returns 403.

If you're running without a database (m.db == nil), the behaviour is unchanged — fail-open, as before.

If you're using the default flow and have content you'd like to unpublish, the published → draft edge is now in the default migration. Existing instances will pick it up automatically on next boot — the insert is idempotent.