diff --git a/internal/handlers/handlers.go b/internal/handlers/handlers.go index 3b63e39..66eb1d2 100644 --- a/internal/handlers/handlers.go +++ b/internal/handlers/handlers.go @@ -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) diff --git a/internal/handlers/paliadin_suggest.go b/internal/handlers/paliadin_suggest.go new file mode 100644 index 0000000..10bf5f6 --- /dev/null +++ b/internal/handlers/paliadin_suggest.go @@ -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": "", "project_id":"", "title":"...", +// "due_date": "YYYY-MM-DD", "notes":"..." (optional), +// "rule_code": "..." (optional) } +// +// Body shape (appointment): +// { "turn_id": "", "project_id":"", "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)}) + } +} diff --git a/internal/models/models.go b/internal/models/models.go index 3793abf..a74a4b0 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -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"` } diff --git a/internal/services/appointment_service.go b/internal/services/appointment_service.go index ec52d71..a0a835e 100644 --- a/internal/services/appointment_service.go +++ b/internal/services/appointment_service.go @@ -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 + } } } } diff --git a/internal/services/approval_service.go b/internal/services/approval_service.go index b7384ed..e76aa84 100644 --- a/internal/services/approval_service.go +++ b/internal/services/approval_service.go @@ -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=, 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 diff --git a/internal/services/deadline_service.go b/internal/services/deadline_service.go index 09a5a81..1337567 100644 --- a/internal/services/deadline_service.go +++ b/internal/services/deadline_service.go @@ -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 + } } } diff --git a/scripts/skills/paliadin/SKILL.md b/scripts/skills/paliadin/SKILL.md index 74508c6..0cdeb1a 100644 --- a/scripts/skills/paliadin/SKILL.md +++ b/scripts/skills/paliadin/SKILL.md @@ -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": "", + "project_id": "", + "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": "", + "project_id": "", + "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.