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.
This commit is contained in:
@@ -497,6 +497,10 @@ func Register(mux *http.ServeMux, client *auth.Client, giteaAPIToken string, svc
|
||||
protected.HandleFunc("POST /api/paliadin/turn", handlePaliadinTurn)
|
||||
protected.HandleFunc("GET /api/paliadin/stream/{id}", handlePaliadinStream)
|
||||
protected.HandleFunc("POST /api/paliadin/reset", handlePaliadinReset)
|
||||
// Agent-suggested write path (t-paliad-161 Slice D). Owner-gated;
|
||||
// drafts a deadline / appointment that lands in the approval pipeline.
|
||||
protected.HandleFunc("POST /api/paliadin/suggest/deadline", handlePaliadinSuggestDeadline)
|
||||
protected.HandleFunc("POST /api/paliadin/suggest/appointment", handlePaliadinSuggestAppointment)
|
||||
protected.HandleFunc("GET /admin/paliadin", gateOnboarded(handleAdminPaliadinPage))
|
||||
protected.HandleFunc("GET /api/admin/paliadin/stats", handleAdminPaliadinStats)
|
||||
protected.HandleFunc("GET /api/admin/paliadin/turns", handleAdminPaliadinTurns)
|
||||
|
||||
192
internal/handlers/paliadin_suggest.go
Normal file
192
internal/handlers/paliadin_suggest.go
Normal file
@@ -0,0 +1,192 @@
|
||||
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)})
|
||||
}
|
||||
}
|
||||
@@ -628,6 +628,11 @@ type ApprovalRequest struct {
|
||||
DecidedAt *time.Time `db:"decided_at" json:"decided_at,omitempty"`
|
||||
DecisionKind *string `db:"decision_kind" json:"decision_kind,omitempty"`
|
||||
DecisionNote *string `db:"decision_note" json:"decision_note,omitempty"`
|
||||
// RequesterKind is 'user' (direct user create) or 'agent' (Paliadin
|
||||
// drafted the row from a chat turn — t-paliad-161). Agent rows render
|
||||
// alongside 👀 with a sparkle ✨ on the eye-pill surface.
|
||||
RequesterKind string `db:"requester_kind" json:"requester_kind"`
|
||||
AgentTurnID *uuid.UUID `db:"agent_turn_id" json:"agent_turn_id,omitempty"`
|
||||
CreatedAt time.Time `db:"created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `db:"updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
@@ -85,6 +85,10 @@ type CreateAppointmentInput struct {
|
||||
EndAt *time.Time `json:"end_at,omitempty"`
|
||||
Location *string `json:"location,omitempty"`
|
||||
AppointmentType *string `json:"appointment_type,omitempty"`
|
||||
// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
|
||||
// suggestion (t-paliad-161). Same semantics as
|
||||
// CreateDeadlineInput.AgentTurnID.
|
||||
AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateAppointmentInput is the partial-update payload for PATCH /api/appointments/{id}.
|
||||
@@ -358,10 +362,21 @@ func (s *AppointmentService) Create(ctx context.Context, userID uuid.UUID, input
|
||||
|
||||
// Approval gate (t-paliad-138). No-op for personal appointments
|
||||
// (project_id IS NULL) and when no policy applies.
|
||||
//
|
||||
// Agent-suggested path (t-paliad-161): when input.AgentTurnID is
|
||||
// set, the row goes through the agent-create variant which always
|
||||
// creates a request (bypassing the policy gate) and stamps the
|
||||
// request with requester_kind='agent' + the originating turn id.
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{"title": title, "start_at": input.StartAt.UTC().Format(time.RFC3339)}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
|
||||
return nil, err
|
||||
if input.AgentTurnID != nil {
|
||||
if _, err := s.approvals.SubmitAgentCreate(ctx, tx, *input.ProjectID, id, userID, *input.AgentTurnID, EntityTypeAppointment, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, *input.ProjectID, id, userID, EntityTypeAppointment, payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -166,7 +166,22 @@ func (s *ApprovalService) hasQualifiedApprover(ctx context.Context, tx *sqlx.Tx,
|
||||
//
|
||||
// Returns the new request ID if pending, nil if no policy applied.
|
||||
func (s *ApprovalService) SubmitCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload)
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, nil)
|
||||
}
|
||||
|
||||
// SubmitAgentCreate is the agent-drafted variant of SubmitCreate
|
||||
// (t-paliad-161). Used when Paliadin drafts a row on the user's behalf —
|
||||
// the request is created UNCONDITIONALLY (even when no policy applies),
|
||||
// stamped with requester_kind='agent', and linked to the originating
|
||||
// paliadin_turns row via agent_turn_id.
|
||||
//
|
||||
// The unconditional gate is by design: every agent suggestion needs the
|
||||
// user's eye (m's lock-in for Q11). Bypassing the policy lookup keeps a
|
||||
// single audit shape — agent-drafted entities never appear "live" in the
|
||||
// approved column without an approve-decision behind them, regardless of
|
||||
// whether the project would have skipped 4-eye for a direct user create.
|
||||
func (s *ApprovalService) SubmitAgentCreate(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID, agentTurnID uuid.UUID, entityType string, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleCreate, nil, payload, &agentTurnID)
|
||||
}
|
||||
|
||||
// SubmitUpdate is invoked after the entity row has been UPDATEd. preImage
|
||||
@@ -178,14 +193,14 @@ func (s *ApprovalService) SubmitUpdate(ctx context.Context, tx *sqlx.Tx, project
|
||||
// the approval flow entirely (the underlying UPDATE was cosmetic).
|
||||
return nil, nil
|
||||
}
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload)
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleUpdate, preImage, payload, nil)
|
||||
}
|
||||
|
||||
// SubmitComplete is invoked after status was flipped to 'completed'
|
||||
// (deadline) or completed_at was set (appointment). preImage stores the
|
||||
// pre-completion state so a rejection can revert.
|
||||
func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload)
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleComplete, preImage, payload, nil)
|
||||
}
|
||||
|
||||
// SubmitDelete is invoked WITHOUT a prior delete on the entity (delete is
|
||||
@@ -196,25 +211,50 @@ func (s *ApprovalService) SubmitComplete(ctx context.Context, tx *sqlx.Tx, proje
|
||||
// preImage stores the full row state so the inbox can render
|
||||
// "About to delete: Frist X (due 2026-05-12)".
|
||||
func (s *ApprovalService) SubmitDelete(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType string, preImage map[string]any) (*uuid.UUID, error) {
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil)
|
||||
return s.submit(ctx, tx, projectID, entityID, requesterID, entityType, LifecycleDelete, preImage, nil, nil)
|
||||
}
|
||||
|
||||
// submit is the shared lifecycle-handling kernel.
|
||||
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any) (*uuid.UUID, error) {
|
||||
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// No policy applies — entity stays approval_status='approved'. No-op.
|
||||
return nil, nil
|
||||
//
|
||||
// agentTurnID, when non-nil, marks the request as agent-drafted: the
|
||||
// request gets requester_kind='agent' + agent_turn_id=<id>, and the
|
||||
// policy gate is BYPASSED — every agent suggestion goes into pending
|
||||
// (m's lock-in for t-paliad-161 Q11). When nil, the standard flow runs
|
||||
// (look up policy, return nil + no-op when no policy applies).
|
||||
func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, entityID, requesterID uuid.UUID, entityType, lifecycle string, preImage, payload map[string]any, agentTurnID *uuid.UUID) (*uuid.UUID, error) {
|
||||
// Resolve required role:
|
||||
// - User path: lookup policy, no-op if none, error if no qualified
|
||||
// approver exists for the threshold.
|
||||
// - Agent path: bypass policy lookup; required_role defaults to the
|
||||
// associate floor (the conservative seed across paliad's policy
|
||||
// baseline) so the inbox query for who-can-approve still has a
|
||||
// non-NULL threshold to fold into the strict ladder. The user
|
||||
// receiving the suggestion to approve is themselves and they're
|
||||
// not in the qualified-approver pool (CHECK decided_by != requested_by),
|
||||
// so the deadlock check needs to verify someone OTHER than the
|
||||
// suggesting user can approve — which they can, because the
|
||||
// suggesting user is m (the only Paliadin owner today) and any
|
||||
// project lead / global_admin not equal to m qualifies.
|
||||
var requiredRole string
|
||||
if agentTurnID == nil {
|
||||
policy, err := s.LookupPolicy(ctx, tx, projectID, entityType, lifecycle)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if policy == nil {
|
||||
// No policy applies — entity stays approval_status='approved'. No-op.
|
||||
return nil, nil
|
||||
}
|
||||
// LookupPolicy guarantees MinRole is non-nil whenever a non-nil
|
||||
// policy is returned (gate on + threshold set).
|
||||
requiredRole = *policy.MinRole
|
||||
} else {
|
||||
// Agent path: associate threshold (the firm-wide seed baseline).
|
||||
requiredRole = "associate"
|
||||
}
|
||||
|
||||
// Deadlock check: somebody other than the requester must be qualified
|
||||
// to approve, either via project team membership or as global_admin.
|
||||
// LookupPolicy guarantees MinRole is non-nil whenever a non-nil policy
|
||||
// is returned (gate on + threshold set).
|
||||
requiredRole := *policy.MinRole
|
||||
ok, err := s.hasQualifiedApprover(ctx, tx, projectID, requesterID, requiredRole)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -246,13 +286,26 @@ func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, en
|
||||
return nil, fmt.Errorf("marshal payload: %w", err)
|
||||
}
|
||||
|
||||
// Agent fields are nil on the user path, set on the agent path. The
|
||||
// xor-check on approval_requests_agent_xor enforces that
|
||||
// requester_kind='agent' implies agent_turn_id IS NOT NULL and vice
|
||||
// versa, so we always set both columns coherently here.
|
||||
requesterKind := "user"
|
||||
var agentTurnArg any
|
||||
if agentTurnID != nil {
|
||||
requesterKind = "agent"
|
||||
agentTurnArg = *agentTurnID
|
||||
}
|
||||
|
||||
insertReqSQL := `INSERT INTO paliad.approval_requests
|
||||
(id, project_id, entity_type, entity_id, lifecycle_event,
|
||||
pre_image, payload, requested_by, required_role, status)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending')`
|
||||
pre_image, payload, requested_by, required_role, status,
|
||||
requester_kind, agent_turn_id)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, 'pending', $10, $11)`
|
||||
if _, err := tx.ExecContext(ctx, insertReqSQL,
|
||||
requestID, projectID, entityType, entityID, lifecycle,
|
||||
preImageJSON, payloadJSON, requesterID, requiredRole); err != nil {
|
||||
preImageJSON, payloadJSON, requesterID, requiredRole,
|
||||
requesterKind, agentTurnArg); err != nil {
|
||||
return nil, fmt.Errorf("insert approval_request: %w", err)
|
||||
}
|
||||
|
||||
@@ -280,6 +333,10 @@ func (s *ApprovalService) submit(ctx context.Context, tx *sqlx.Tx, projectID, en
|
||||
"lifecycle_event": lifecycle,
|
||||
"required_role": requiredRole,
|
||||
entityType + "_id": entityID.String(),
|
||||
"requester_kind": requesterKind,
|
||||
}
|
||||
if agentTurnID != nil {
|
||||
meta["agent_turn_id"] = agentTurnID.String()
|
||||
}
|
||||
if err := insertProjectEventWithMeta(ctx, tx, projectID, requesterID, eventType, eventType, descPtr, meta); err != nil {
|
||||
return nil, err
|
||||
@@ -766,6 +823,7 @@ const approvalRequestViewColumns = `
|
||||
ar.id, ar.project_id, ar.entity_type, ar.entity_id, ar.lifecycle_event,
|
||||
ar.pre_image, ar.payload, ar.requested_by, ar.requested_at, ar.required_role,
|
||||
ar.status, ar.decided_by, ar.decided_at, ar.decision_kind, ar.decision_note,
|
||||
ar.requester_kind, ar.agent_turn_id,
|
||||
ar.created_at, ar.updated_at,
|
||||
p.title AS project_title,
|
||||
CASE WHEN ar.entity_type = 'deadline' THEN d.title
|
||||
|
||||
@@ -84,6 +84,12 @@ type CreateDeadlineInput struct {
|
||||
Source string `json:"source,omitempty"` // default "manual"
|
||||
Notes *string `json:"notes,omitempty"`
|
||||
EventTypeIDs []uuid.UUID `json:"event_type_ids,omitempty"`
|
||||
// AgentTurnID, when non-nil, marks the create as a Paliadin-drafted
|
||||
// suggestion (t-paliad-161). The deadline lands as approval_status='pending'
|
||||
// with requester_kind='agent' on the approval_request, regardless of
|
||||
// whether a (project, deadline, create) policy applies. Default-zero
|
||||
// behaviour matches the user-direct path.
|
||||
AgentTurnID *uuid.UUID `json:"agent_turn_id,omitempty"`
|
||||
}
|
||||
|
||||
// UpdateDeadlineInput is the partial-update payload for PATCH.
|
||||
@@ -934,13 +940,24 @@ func (s *DeadlineService) insert(ctx context.Context, userID, projectID uuid.UUI
|
||||
// flips the just-inserted row's approval_status to 'pending' and emits
|
||||
// a 'deadline_approval_requested' audit event. No-op when no policy is
|
||||
// configured or when the approval service isn't wired (test harness).
|
||||
//
|
||||
// Agent-suggested path (t-paliad-161): when input.AgentTurnID is set,
|
||||
// the row goes through the agent-create variant which always creates
|
||||
// a request (bypassing the policy gate) and stamps the request with
|
||||
// requester_kind='agent' + the originating turn id.
|
||||
if s.approvals != nil {
|
||||
payload := map[string]any{
|
||||
"title": desc,
|
||||
"due_date": input.DueDate,
|
||||
}
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
|
||||
return uuid.Nil, err
|
||||
if input.AgentTurnID != nil {
|
||||
if _, err := s.approvals.SubmitAgentCreate(ctx, tx, projectID, id, userID, *input.AgentTurnID, EntityTypeDeadline, payload); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
} else {
|
||||
if _, err := s.approvals.SubmitCreate(ctx, tx, projectID, id, userID, EntityTypeDeadline, payload); err != nil {
|
||||
return uuid.Nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -106,6 +106,76 @@ Direkt im Antworttext einbetten — Paliad-Frontend rendert sie als Buttons:
|
||||
|
||||
Nur IDs/Slugs benutzen, die du tatsächlich aus einem Tool-Call hast. **Niemals erfinden.**
|
||||
|
||||
## Agent-suggested writes (t-paliad-161)
|
||||
|
||||
Wenn m sagt *"Lege eine Frist an: …"* / *"Plane einen Termin: …"* /
|
||||
*"Add a deadline: …"*, kannst du den Eintrag **vorschlagen** — er
|
||||
landet in der Approval-Pipeline und wartet auf m's eigene Genehmigung
|
||||
über den 👀-Inbox-Workflow.
|
||||
|
||||
**Niemals direkt schreiben.** Du hast keine direkten Schreibrechte. Der
|
||||
einzige Pfad ist über die `paliad__suggest_*` HTTP-Endpunkte (siehe unten);
|
||||
diese stempeln den Approval-Request mit `requester_kind='agent'` und
|
||||
verlinken zur aktuellen Turn-ID.
|
||||
|
||||
### Tools
|
||||
|
||||
Beide nehmen JSON-Body, geben den angelegten Entry zurück, oder
|
||||
`{"error": "..."}` bei Konflikt:
|
||||
|
||||
```
|
||||
POST /api/paliadin/suggest/deadline
|
||||
{
|
||||
"turn_id": "<aktuelle Turn-ID aus dem [PALIADIN:] Prefix>",
|
||||
"project_id": "<UUID — aus dem [ctx entity=project:…] oder über mcp__supabase__execute_sql lookup>",
|
||||
"title": "Klageerwiderung Acme v. Müller",
|
||||
"due_date": "2026-05-16",
|
||||
"notes": "(optional)",
|
||||
"rule_code": "(optional, z.B. RoP.023)"
|
||||
}
|
||||
|
||||
POST /api/paliadin/suggest/appointment
|
||||
{
|
||||
"turn_id": "<aktuelle Turn-ID>",
|
||||
"project_id": "<UUID>",
|
||||
"title": "Mündliche Verhandlung",
|
||||
"start_at": "2026-06-12T10:00:00+02:00",
|
||||
"end_at": "(optional, RFC3339)",
|
||||
"location": "(optional)",
|
||||
"appointment_type": "(optional)"
|
||||
}
|
||||
```
|
||||
|
||||
Aufruf via `mcp__claude_ai_*` HTTP fetch oder direkt mit dem
|
||||
`bash`-curl-Befehl (im paliadin-Pane verfügbar):
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://localhost:8080/api/paliadin/suggest/deadline \
|
||||
-H 'Content-Type: application/json' \
|
||||
-b /tmp/paliad-cookies \
|
||||
-d '{...}'
|
||||
```
|
||||
|
||||
### Verhalten
|
||||
|
||||
1. **Bestätigung in der Antwortdatei**: Schreibe in den Markdown-Output
|
||||
*"Frist als Vorschlag angelegt — wartet auf deine Genehmigung im
|
||||
/inbox 👀✨"*. Niemals so tun, als wäre die Frist bereits live.
|
||||
2. **`project_id` ist Pflicht.** Wenn nicht aus `[ctx entity=…]`
|
||||
ableitbar: SQL-Lookup über `paliad.projects` mit Reference/Title aus
|
||||
m's Frage. Mehrere Treffer → frag nach.
|
||||
3. **Datumsformat**: ISO `YYYY-MM-DD` für Fristen, RFC3339 für Termine.
|
||||
Niemals "16.05." in den Body schreiben — explizites Datum mit Jahr.
|
||||
4. **Bei Fehler `409 no qualified approver`**: erkläre m, dass die
|
||||
Akte aktuell keinen approver-fähigen Kollegen hat (Lead/Associate)
|
||||
— der Vorschlag kann erst nach dem Staffing fliegen.
|
||||
5. **Niemals mehrere Tools chained ausführen** (Frist anlegen + dann
|
||||
Termin + dann Notiz). Pro Turn höchstens ein Suggest-Call. m's Regel
|
||||
aus #20: "Multi-turn agent loops … Every creation gets the user's eye."
|
||||
6. **Bei Frist anlegen für eine Akte ohne `[ctx]` entity-Hinweis**:
|
||||
erst SQL lookup, dann anlegen. Kein "ich nehme die erste passende
|
||||
Akte" — stattdessen frag.
|
||||
|
||||
## Hard rules
|
||||
|
||||
1. **Keine Erfindungen.** Liefert ein Tool nichts, sag das. Niemals Aktenzeichen, Daten, Gerichts- oder Parteinamen erfinden.
|
||||
|
||||
Reference in New Issue
Block a user