One-gesture correction on /chat (V-628) #184

Merged
claude merged 7 commits from task/630-one-gesture-correction-on-chat-v-628 into master 2026-08-06 17:51:05 +02:00
3 changed files with 31 additions and 19 deletions
Showing only changes of commit a4b4733767 - Show all commits
+9 -5
View File
@@ -131,11 +131,12 @@ func handleChatAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, ses
// optionally the intent it should have been. An unstated target is accepted,
// because a turn marked wrong with no target is still a usable negative.
//
// Not step-up gated, unlike POST /api/chat. Writing a label reaches no router,
// no model and no act path; it writes one row nothing executes from. Gating it
// would price the gesture out of being used, which is the one thing that makes
// it worthless.
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI) {
// Step-up gated like POST /api/chat, and that costs the gesture nothing: he
// tapped to send the turn he is now correcting, so the session is already up.
// It is gated because trace ids are sequential integers and this writes the one
// table the routing heads (V-546) will be fitted on. A caller who can guess an
// id could otherwise mislabel turns he never corrected.
func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI, session *webauthn.PasskeySession, requireStepUp bool) {
if r.Method != http.MethodPost {
http.Error(w, "POST only", http.StatusMethodNotAllowed)
return
@@ -143,6 +144,9 @@ func handleCorrectAPI(w http.ResponseWriter, r *http.Request, core ipc.CoreAPI)
if !requireCore(w, core, "correct") {
return
}
if !stepUpGate(w, session, requireStepUp) {
return
}
id, err := strconv.ParseInt(strings.TrimSpace(r.FormValue("trace_id")), 10, 64)
if err != nil || id <= 0 {
http.Error(w, "trace_id required", http.StatusBadRequest)
+21 -7
View File
@@ -38,7 +38,7 @@ func TestCorrectAPIWithTarget(t *testing.T) {
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{
"trace_id": {"42"}, "should_be": {"fact"}, "q": {"поужинал"}, "rep": {"поняла"},
}), core)
}), core, stepUpSession(), false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status %d, want 303; body=%s", rr.Code, rr.Body.String())
@@ -58,7 +58,7 @@ func TestCorrectAPIWithTarget(t *testing.T) {
func TestCorrectAPIWithNoTarget(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core)
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}}), core, stepUpSession(), false)
if rr.Code != http.StatusSeeOther {
t.Fatalf("status %d, want 303", rr.Code)
@@ -76,7 +76,7 @@ func TestCorrectAPIWithNoTarget(t *testing.T) {
func TestCorrectAPIRejectsUnknownTarget(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core)
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"7"}, "should_be": {"погода"}}), core, stepUpSession(), false)
if rr.Code != http.StatusBadRequest {
t.Fatalf("status %d, want 400", rr.Code)
@@ -90,7 +90,7 @@ func TestCorrectAPINeedsTraceID(t *testing.T) {
for _, form := range []url.Values{{}, {"trace_id": {"0"}}, {"trace_id": {"nope"}}} {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(form), core)
handleCorrectAPI(rr, postCorrect(form), core, stepUpSession(), false)
if rr.Code != http.StatusBadRequest {
t.Errorf("form %v: status %d, want 400", form, rr.Code)
}
@@ -105,7 +105,7 @@ func TestCorrectAPINeedsTraceID(t *testing.T) {
func TestCorrectAPIReportsFailure(t *testing.T) {
core := &correctCore{err: errors.New("disk is full")}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core)
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
if rr.Code != http.StatusBadGateway {
t.Fatalf("status %d, want 502", rr.Code)
}
@@ -115,7 +115,7 @@ func TestCorrectAPIReportsFailure(t *testing.T) {
func TestCorrectAPIExpiredTurn(t *testing.T) {
core := &correctCore{err: ipc.ErrNoSuchTrace}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core)
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, stepUpSession(), false)
if rr.Code != http.StatusNotFound {
t.Fatalf("status %d, want 404", rr.Code)
}
@@ -123,7 +123,7 @@ func TestCorrectAPIExpiredTurn(t *testing.T) {
func TestCorrectAPIPostOnly(t *testing.T) {
rr := httptest.NewRecorder()
handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{})
handleCorrectAPI(rr, httptest.NewRequest(http.MethodGet, "/api/correct", nil), &correctCore{}, stepUpSession(), false)
if rr.Code != http.StatusMethodNotAllowed {
t.Fatalf("status %d, want 405", rr.Code)
}
@@ -144,3 +144,17 @@ func TestCorrectionTargetsAreTheSeven(t *testing.T) {
t.Error("empty is not a target: it is the absence of one, handled separately")
}
}
// Trace ids are sequential, so a caller who cannot assert step-up must not be
// able to label a turn the owner never corrected.
func TestCorrectAPINeedsStepUp(t *testing.T) {
core := &correctCore{}
rr := httptest.NewRecorder()
handleCorrectAPI(rr, postCorrect(url.Values{"trace_id": {"9"}, "should_be": {"note"}}), core, nil, true)
if rr.Code != http.StatusForbidden {
t.Fatalf("status %d, want 403", rr.Code)
}
if core.called {
t.Error("wrote a label with no step-up")
}
}
+1 -7
View File
@@ -210,13 +210,7 @@ func main() {
mux.HandleFunc("/routines", gatedPage(handleRoutines))
mux.HandleFunc("/api/chat", gatedPage(handleChatAPI))
mux.HandleFunc("/api/revert", gatedPage(handleRevert))
// POST /api/correct is deliberately NOT on the step-up list (V-630). It
// reaches no router, no model and no act path: it writes one label row that
// nothing executes from. A correction that costs a passkey tap is a
// correction the owner does not make, and then the table stays empty.
mux.HandleFunc("/api/correct", func(w http.ResponseWriter, r *http.Request) {
handleCorrectAPI(w, r, core)
})
mux.HandleFunc("/api/correct", gatedPage(handleCorrectAPI))
mux.HandleFunc("/models", func(w http.ResponseWriter, r *http.Request) {
handleModels(w, r, core, swapConn, stepUpSession, *requireStepUp)
})