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:
m
2026-05-08 19:59:44 +02:00
parent ba2408eb51
commit a3052eb085
7 changed files with 383 additions and 22 deletions

View File

@@ -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)

View 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)})
}
}

View File

@@ -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"`
}

View File

@@ -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
}
}
}
}

View File

@@ -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

View File

@@ -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
}
}
}

View File

@@ -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.