Files
paliad/internal/handlers/paliadin_suggest.go
m a3052eb085 feat(paliadin/suggest): t-paliad-161 Slice D — agent-suggested write path
Paliadin can now draft deadlines + appointments through two new
owner-gated HTTP endpoints. Drafted entities land in the existing
approval pipeline as approval_status='pending' with
requester_kind='agent' + agent_turn_id linking back to the chat turn
that produced the suggestion. The user reviews via the same eye-pill
👀 surface (with  added in Slice E).

  POST /api/paliadin/suggest/deadline
  POST /api/paliadin/suggest/appointment

Wiring:

- ApprovalService.SubmitAgentCreate — agent variant of SubmitCreate;
  always creates an approval_request (bypassing policy lookup) and
  stamps requester_kind='agent' + agent_turn_id. Required-role defaults
  to 'associate' so the deadlock check has a non-NULL threshold; m's
  lock-in for Q11 (every agent suggestion needs the user's eye) means
  bypassing the policy gate is correct here, not a regression.

- The shared `submit` kernel takes an optional agent_turn_id pointer.
  All four lifecycle entry points (SubmitCreate / SubmitUpdate /
  SubmitComplete / SubmitDelete) pass nil; SubmitAgentCreate passes
  the turn id. INSERT to approval_requests now writes both
  requester_kind + agent_turn_id atomically (xor-check on the schema
  enforces consistency).

- models.ApprovalRequest grows the two columns + their JSON tags so
  the inbox view + Verlauf renderer can read provenance without an
  extra fetch.

- approvalRequestViewColumns adds ar.requester_kind + ar.agent_turn_id
  to the SQL projection; both surfaces (ListPendingForApprover,
  ListSubmittedByUser, GetRequest) inherit the new fields free.

- CreateDeadlineInput + CreateAppointmentInput each get an optional
  AgentTurnID *uuid.UUID. When non-nil, the create-tx routes through
  SubmitAgentCreate instead of the regular SubmitCreate. Default-zero
  behaviour is unchanged for every existing caller.

- handlers/paliadin_suggest.go is the new HTTP layer. Owner-gated via
  requirePaliadinOwner (same gate /paliadin uses), JSON-bodied,
  RFC3339 + ISO-date validation, 409 + a useful message on
  ErrNoQualifiedApprover.

- Project-event audit metadata gains requester_kind + agent_turn_id so
  the project's Verlauf can render "Paliadin hat eine Frist
  vorgeschlagen " without joining approval_requests (Slice E reads
  this).

SKILL.md (~/.claude/skills/paliadin/SKILL.md) gains an "Agent-suggested
writes" section with the tool catalog, behaviour rules ("never write
directly", confirmation in the response file, project_id lookup
discipline, RFC3339 dates, no chained tool calls per turn), and the
409 error contract.

go build + go vet + go test all clean. No frontend changes in this
slice — Slice E lights up the  on existing eye-pill surfaces.

Refs: docs/design-paliadin-inline-2026-05-08.md §7.
2026-05-08 19:59:44 +02:00

193 lines
6.7 KiB
Go

package handlers
// paliadin_suggest.go — agent-suggested write path (t-paliad-161 Slice D).
//
// HTTP endpoints invoked by Paliadin (via tools the SKILL.md describes)
// to draft an entity on the user's behalf. The drafted entity goes
// through the existing approval pipeline as approval_status='pending'
// with requester_kind='agent' + agent_turn_id, surfaced to the user via
// the eye-pill 👀 + sparkle ✨.
//
// Routes (gated to PaliadinOwnerEmail — the same gate /paliadin uses):
// POST /api/paliadin/suggest/deadline
// POST /api/paliadin/suggest/appointment
//
// Body shape (deadline):
// { "turn_id": "<uuid>", "project_id":"<uuid>", "title":"...",
// "due_date": "YYYY-MM-DD", "notes":"..." (optional),
// "rule_code": "..." (optional) }
//
// Body shape (appointment):
// { "turn_id": "<uuid>", "project_id":"<uuid>", "title":"...",
// "start_at": "RFC3339", "end_at":"RFC3339" (optional),
// "location": "..." (optional), "appointment_type":"..." (optional) }
//
// On success returns 201 + the created entity. On policy / permission
// failure, returns the same error codes the regular create paths use.
import (
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"mgit.msbls.de/m/paliad/internal/services"
)
// suggestDeadlineRequest is the body POST /api/paliadin/suggest/deadline
// accepts. turn_id MUST refer to a row Paliadin actually wrote — the
// approval_requests.agent_turn_id FK rejects forged uuids.
type suggestDeadlineRequest struct {
TurnID string `json:"turn_id"`
ProjectID string `json:"project_id"`
Title string `json:"title"`
DueDate string `json:"due_date"` // YYYY-MM-DD
Notes *string `json:"notes,omitempty"`
RuleCode *string `json:"rule_code,omitempty"`
}
type suggestAppointmentRequest struct {
TurnID string `json:"turn_id"`
ProjectID string `json:"project_id"`
Title string `json:"title"`
StartAt string `json:"start_at"` // RFC3339
EndAt *string `json:"end_at,omitempty"`
Location *string `json:"location,omitempty"`
AppointmentType *string `json:"appointment_type,omitempty"`
}
// handlePaliadinSuggestDeadline drafts a deadline through the approval
// gate. Returns the created deadline (with approval_status='pending').
func handlePaliadinSuggestDeadline(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
if dbSvc == nil || dbSvc.deadline == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "deadline service unavailable"})
return
}
uid, _ := requireUser(w, r)
var req suggestDeadlineRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
turnID, err := uuid.Parse(strings.TrimSpace(req.TurnID))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid turn_id"})
return
}
projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
return
}
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.DueDate) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and due_date required"})
return
}
input := services.CreateDeadlineInput{
Title: req.Title,
DueDate: req.DueDate,
Notes: req.Notes,
RuleCode: req.RuleCode,
Source: "paliadin",
AgentTurnID: &turnID,
}
d, err := dbSvc.deadline.Create(r.Context(), uid, projectID, input)
if err != nil {
mapSuggestError(w, err)
return
}
writeJSON(w, http.StatusCreated, d)
}
// handlePaliadinSuggestAppointment drafts an appointment through the
// approval gate. Returns the created appointment (with
// approval_status='pending').
func handlePaliadinSuggestAppointment(w http.ResponseWriter, r *http.Request) {
if !requirePaliadinOwner(w, r) {
return
}
if dbSvc == nil || dbSvc.appointment == nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]string{"error": "appointment service unavailable"})
return
}
uid, _ := requireUser(w, r)
var req suggestAppointmentRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON"})
return
}
turnID, err := uuid.Parse(strings.TrimSpace(req.TurnID))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid turn_id"})
return
}
projectID, err := uuid.Parse(strings.TrimSpace(req.ProjectID))
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid project_id"})
return
}
if strings.TrimSpace(req.Title) == "" || strings.TrimSpace(req.StartAt) == "" {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "title and start_at required"})
return
}
startAt, err := time.Parse(time.RFC3339, req.StartAt)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid start_at: " + err.Error()})
return
}
var endAt *time.Time
if req.EndAt != nil && strings.TrimSpace(*req.EndAt) != "" {
t, err := time.Parse(time.RFC3339, *req.EndAt)
if err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid end_at: " + err.Error()})
return
}
endAt = &t
}
input := services.CreateAppointmentInput{
ProjectID: &projectID,
Title: req.Title,
StartAt: startAt,
EndAt: endAt,
Location: req.Location,
AppointmentType: req.AppointmentType,
AgentTurnID: &turnID,
}
a, err := dbSvc.appointment.Create(r.Context(), uid, input)
if err != nil {
mapSuggestError(w, err)
return
}
writeJSON(w, http.StatusCreated, a)
}
// mapSuggestError translates ApprovalService / DeadlineService errors
// into HTTP responses. Mirrors the existing approval-error mapping in
// handlers/approvals.go (mapApprovalError) but with a payload shape the
// Paliadin shim can read back into a useful chip in the response.
func mapSuggestError(w http.ResponseWriter, err error) {
switch {
case errors.Is(err, services.ErrNotVisible):
writeJSON(w, http.StatusForbidden, map[string]string{"error": "no visibility on project"})
case errors.Is(err, services.ErrInvalidInput):
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
case errors.Is(err, services.ErrNoQualifiedApprover):
writeJSON(w, http.StatusConflict, map[string]string{
"error": "no qualified approver on this project — invite an associate or higher before suggesting",
})
default:
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": fmt.Sprintf("suggest failed: %v", err)})
}
}