{(['open', 'in_progress', 'answered', 'not_relevant'] as const).map(s => (
@@ -302,18 +397,53 @@ function AnswerModal({
value={reasoning}
onChange={e => setReasoning(e.target.value)}
rows={4}
- className="w-full border rounded p-2 text-sm mb-3"
+ className="w-full border rounded p-2 text-sm mb-4"
placeholder="z.B. Pruefprotokoll vom 12.03.2024 vom Anlagenbauer FANUC vorgelegt; DCS-Konfig liegt bei."
/>
-
-
setAnsweredBy(e.target.value)}
- className="w-full border rounded p-2 text-sm mb-4"
- placeholder="Name oder Kürzel"
- />
+ {/* Comment Thread */}
+
+
Diskussion ({comments.length})
+
+ {comments.map(c => (
+
+
{c.author || 'anonym'} · {c.created_at.slice(0, 16).replace('T', ' ')}
+
{c.body}
+
+ ))}
+ {comments.length === 0 &&
Noch keine Kommentare.
}
+
+
+ setNewComment(e.target.value)}
+ placeholder="Kommentar hinzufügen..."
+ className="flex-1 border rounded px-2 py-1.5 text-xs"
+ onKeyDown={e => { if (e.key === 'Enter') postComment() }}
+ />
+
+
+
+
+ {history.length > 0 && (
+
+ Verlauf ({history.length})
+
+ {history.map((h, i) => (
+
+ {h.created_at.slice(0, 16).replace('T', ' ')} ·
+ {h.actor || 'unbekannt'}: {h.from_status} → {h.to_status}
+ {h.from_answer !== h.to_answer && ` (Antwort ${h.from_answer || '—'} → ${h.to_answer || '—'})`}
+
+ ))}
+
+
+ )}
{error &&
Fehler: {error}
}
diff --git a/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go b/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go
index 2dccba63..1adef823 100644
--- a/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go
+++ b/ai-compliance-sdk/internal/api/handlers/iace_handler_clarifications.go
@@ -110,6 +110,12 @@ func appendUnique(slice []string, s string) []string {
// ListClarifications handles GET /projects/:id/clarifications.
// Returns the aggregated clarification list with affected-hazard cross-refs
// and the persisted answer state.
+//
+// Phase 3 storage model: answers live in the iace_clarifications table
+// when migration 028 has been applied. The JSONB fallback in
+// project.metadata.clarification_answers is still read so projects that
+// were answered before the migration keep their state until the one-shot
+// upcopy runs.
func (h *IACEHandler) ListClarifications(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -124,7 +130,32 @@ func (h *IACEHandler) ListClarifications(c *gin.Context) {
}
hazards, _ := h.store.ListHazards(ctx, projectID)
- answers, _ := readClarificationAnswers(project.Metadata)
+ // Primary: relational answers
+ answers := map[string]iace.ClarificationAnswer{}
+ if rows, rerr := h.store.ListClarificationsForProject(ctx, projectID); rerr == nil {
+ for _, r := range rows {
+ answeredAt := ""
+ if r.AnsweredAt != nil {
+ answeredAt = r.AnsweredAt.UTC().Format(time.RFC3339)
+ }
+ answers[r.ClarificationKey] = iace.ClarificationAnswer{
+ Status: r.Status,
+ Answer: r.Answer,
+ Reasoning: r.Reasoning,
+ AnsweredBy: r.AnsweredBy,
+ AnsweredAt: answeredAt,
+ AssignedTo: r.AssignedTo,
+ }
+ }
+ }
+ // Fallback: JSONB legacy answers (keep until one-shot upcopy is done)
+ if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
+ for k, v := range legacy {
+ if _, ok := answers[k]; !ok {
+ answers[k] = v
+ }
+ }
+ }
narrative := extractNarrativeFromMetadata(project.Metadata)
hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
manufHits := iace.LookupManufacturerFeaturesInText(narrative)
@@ -164,11 +195,18 @@ type AnswerClarificationRequest struct {
Answer string `json:"answer"` // ja | nein | teilweise
Reasoning string `json:"reasoning"`
AnsweredBy string `json:"answered_by"`
+ AssignedTo string `json:"assigned_to"`
+ // Snapshot fields written into the new table on first contact so the
+ // audit trail does not break if the pattern library changes later.
+ Question string `json:"question,omitempty"`
+ Source string `json:"source,omitempty"`
+ Category string `json:"category,omitempty"`
+ NormReferences []string `json:"norm_references,omitempty"`
}
// AnswerClarification handles POST /projects/:id/clarifications/:cid/answer.
-// Stores the answer in project.metadata.clarification_answers — no schema
-// change required.
+// Upserts the answer in iace_clarifications (Phase 3). Old JSONB answers
+// remain readable but are no longer written.
func (h *IACEHandler) AnswerClarification(c *gin.Context) {
projectID, err := uuid.Parse(c.Param("id"))
if err != nil {
@@ -199,27 +237,123 @@ func (h *IACEHandler) AnswerClarification(c *gin.Context) {
c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
return
}
- answers, root := readClarificationAnswers(project.Metadata)
- answers[cid] = iace.ClarificationAnswer{
- Status: req.Status,
- Answer: req.Answer,
- Reasoning: req.Reasoning,
- AnsweredBy: req.AnsweredBy,
- AnsweredAt: time.Now().UTC().Format(time.RFC3339),
+ tenantID, terr := getTenantID(c)
+ if terr != nil {
+ tenantID = project.TenantID
}
- answersJSON, _ := json.Marshal(answers)
- root[clarificationAnswersKey] = answersJSON
- merged, _ := json.Marshal(root)
- if err := h.store.UpdateProjectMetadata(ctx, projectID, merged); err != nil {
+
+ // If the client didn't supply snapshot fields, fall back to whatever
+ // the engine currently produces for this clarification id.
+ if req.Question == "" || req.Source == "" {
+ if prev, _ := h.store.GetClarificationByKey(ctx, projectID, cid); prev != nil {
+ if req.Question == "" {
+ req.Question = prev.Question
+ }
+ if req.Source == "" {
+ req.Source = prev.Source
+ }
+ if req.Category == "" {
+ req.Category = prev.Category
+ }
+ if len(req.NormReferences) == 0 {
+ req.NormReferences = prev.NormReferences
+ }
+ }
+ }
+
+ now := time.Now().UTC()
+ answeredAt := &now
+ if req.Status != "answered" && req.Status != "not_relevant" {
+ answeredAt = nil
+ }
+ in := iace.ClarificationRow{
+ TenantID: tenantID,
+ ProjectID: projectID,
+ ClarificationKey: cid,
+ Question: req.Question,
+ Source: req.Source,
+ Category: req.Category,
+ NormReferences: req.NormReferences,
+ Status: req.Status,
+ Answer: req.Answer,
+ Reasoning: req.Reasoning,
+ AssignedTo: req.AssignedTo,
+ AnsweredBy: req.AnsweredBy,
+ AnsweredAt: answeredAt,
+ }
+ row, err := h.store.UpsertClarification(ctx, in)
+ if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"clarification_id": cid,
- "answer": answers[cid],
+ "row": row,
})
}
+// CommentRequest is the body for POST .../comment.
+type CommentRequest struct {
+ Author string `json:"author"`
+ Body string `json:"body"`
+}
+
+// PostClarificationComment handles POST /projects/:id/clarifications/:cid/comment.
+func (h *IACEHandler) PostClarificationComment(c *gin.Context) {
+ projectID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
+ return
+ }
+ cid := c.Param("cid")
+ var req CommentRequest
+ if err := c.ShouldBindJSON(&req); err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
+ return
+ }
+ if req.Body == "" {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "body required"})
+ return
+ }
+ ctx := c.Request.Context()
+ row, err := h.store.GetClarificationByKey(ctx, projectID, cid)
+ if err != nil || row == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "clarification not found — answer/assign it first to create the row"})
+ return
+ }
+ comment, err := h.store.AddClarificationComment(ctx, row.ID, req.Author, req.Body)
+ if err != nil {
+ c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
+ return
+ }
+ c.JSON(http.StatusOK, gin.H{"comment": comment})
+}
+
+// ListClarificationDetail handles GET /projects/:id/clarifications/:cid/detail
+// and returns comments + history for one clarification.
+func (h *IACEHandler) ListClarificationDetail(c *gin.Context) {
+ projectID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
+ return
+ }
+ cid := c.Param("cid")
+ ctx := c.Request.Context()
+ row, _ := h.store.GetClarificationByKey(ctx, projectID, cid)
+ if row == nil {
+ c.JSON(http.StatusOK, gin.H{"row": nil, "comments": []any{}, "history": []any{}})
+ return
+ }
+ comments, _ := h.store.ListClarificationComments(ctx, row.ID)
+ history, _ := h.store.ListClarificationHistory(ctx, row.ID)
+ c.JSON(http.StatusOK, gin.H{
+ "row": row,
+ "comments": comments,
+ "history": history,
+ })
+ _ = json.RawMessage{} // keep encoding/json import in case of future fields
+}
+
// ExportClarificationsCSV handles GET /projects/:id/clarifications.csv.
// Returns the aggregated clarifications as a CSV for handover to the
// Anlagenbauer — one row per question with all referenced hazards and
@@ -280,3 +414,153 @@ func (h *IACEHandler) ExportClarificationsCSV(c *gin.Context) {
}
w.Flush()
}
+
+// ExportClarificationsHTML handles GET /projects/:id/clarifications.html
+// and returns a print-friendly standalone HTML document that the browser
+// can render to PDF (no server-side PDF dependency needed). The Bediener
+// opens the link, hits Cmd-P / Strg-P and saves as PDF.
+func (h *IACEHandler) ExportClarificationsHTML(c *gin.Context) {
+ projectID, err := uuid.Parse(c.Param("id"))
+ if err != nil {
+ c.JSON(http.StatusBadRequest, gin.H{"error": "invalid project ID"})
+ return
+ }
+ ctx := c.Request.Context()
+ project, err := h.store.GetProject(ctx, projectID)
+ if err != nil || project == nil {
+ c.JSON(http.StatusNotFound, gin.H{"error": "project not found"})
+ return
+ }
+ hazards, _ := h.store.ListHazards(ctx, projectID)
+ answers := map[string]iace.ClarificationAnswer{}
+ if rows, _ := h.store.ListClarificationsForProject(ctx, projectID); rows != nil {
+ for _, r := range rows {
+ at := ""
+ if r.AnsweredAt != nil {
+ at = r.AnsweredAt.UTC().Format(time.RFC3339)
+ }
+ answers[r.ClarificationKey] = iace.ClarificationAnswer{
+ Status: r.Status, Answer: r.Answer, Reasoning: r.Reasoning,
+ AnsweredBy: r.AnsweredBy, AnsweredAt: at,
+ }
+ }
+ }
+ if legacy, _ := readClarificationAnswers(project.Metadata); len(legacy) > 0 {
+ for k, v := range legacy {
+ if _, ok := answers[k]; !ok {
+ answers[k] = v
+ }
+ }
+ }
+ narrative := extractNarrativeFromMetadata(project.Metadata)
+ hazardToPatterns := h.reconstructHazardPatterns(narrative, project.MachineType, hazards)
+ manufHits := iace.LookupManufacturerFeaturesInText(narrative)
+ cls := iace.BuildProjectClarifications(hazards, hazardToPatterns, manufHits, answers)
+ sort.Slice(cls, func(i, j int) bool {
+ if cls[i].Status != cls[j].Status {
+ return cls[i].Status == "open"
+ }
+ return cls[i].Source < cls[j].Source
+ })
+
+ c.Header("Content-Type", "text/html; charset=utf-8")
+ w := c.Writer
+ fmt.Fprintf(w, `
+
Klaerungen — %s
+
+
Tipp: Mit Strg+P / Cmd+P als PDF speichern.
+
Klaerungsliste — %s
+
Projekt-ID %s · Stand %s
+
+ %d offen
+ %d beantwortet
+ %d gesamt
+
+`,
+ htmlEscape(project.MachineName),
+ htmlEscape(project.MachineName),
+ project.ID.String(),
+ time.Now().Format("2006-01-02 15:04"),
+ countByStatus(cls, false), countByStatus(cls, true), len(cls),
+ )
+ for _, cl := range cls {
+ statusCls := "open"
+ statusLabel := "Offen"
+ if cl.Status == "answered" {
+ statusCls, statusLabel = "done", "Beantwortet"
+ } else if cl.Status == "not_relevant" {
+ statusCls, statusLabel = "gray", "Nicht relevant"
+ } else if cl.Status == "in_progress" {
+ statusCls, statusLabel = "open", "In Klaerung"
+ }
+ fmt.Fprintf(w, `
%s · %s
+%s
+`,
+ htmlEscape(cl.Source), statusCls, statusLabel,
+ htmlEscape(cl.Question),
+ )
+ if len(cl.NormReferences) > 0 {
+ fmt.Fprintf(w, `Normen: %s
`, htmlEscape(strings.Join(cl.NormReferences, " | ")))
+ }
+ if len(cl.AffectedHazardNames) > 0 {
+ fmt.Fprintf(w, `Betrifft %d Gefaehrdung(en): %s
`,
+ len(cl.AffectedHazardIDs),
+ htmlEscape(strings.Join(cl.AffectedHazardNames, "; ")),
+ )
+ }
+ if cl.Status == "answered" || cl.Status == "not_relevant" {
+ fmt.Fprintf(w, `Antwort (%s): %s`,
+ htmlEscape(cl.Answer),
+ htmlEscape(cl.Reasoning),
+ )
+ if cl.AnsweredBy != "" {
+ ts := cl.AnsweredAt
+ if len(ts) > 10 {
+ ts = ts[:10]
+ }
+ fmt.Fprintf(w, ` — %s, %s`, htmlEscape(cl.AnsweredBy), htmlEscape(ts))
+ }
+ fmt.Fprintf(w, `
`)
+ }
+ fmt.Fprintf(w, ``)
+ }
+ fmt.Fprintf(w, `
Anlagenbauer · Datum · Unterschrift
Bediener · Datum · Unterschrift
`)
+ fmt.Fprintf(w, ``)
+}
+
+func htmlEscape(s string) string {
+ r := strings.NewReplacer("&", "&", "<", "<", ">", ">", `"`, """, `'`, "'")
+ return r.Replace(s)
+}
+
+func countByStatus(cls []iace.Clarification, answered bool) int {
+ n := 0
+ for _, c := range cls {
+ isDone := c.Status == "answered" || c.Status == "not_relevant"
+ if isDone == answered {
+ n++
+ }
+ }
+ return n
+}
diff --git a/ai-compliance-sdk/internal/app/routes.go b/ai-compliance-sdk/internal/app/routes.go
index 75d57244..af532563 100644
--- a/ai-compliance-sdk/internal/app/routes.go
+++ b/ai-compliance-sdk/internal/app/routes.go
@@ -455,7 +455,10 @@ func registerIACERoutes(v1 *gin.RouterGroup, h *handlers.IACEHandler) {
// Clarifications — aggregated open questions per project
iaceRoutes.GET("/projects/:id/clarifications", h.ListClarifications)
iaceRoutes.GET("/projects/:id/clarifications.csv", h.ExportClarificationsCSV)
+ iaceRoutes.GET("/projects/:id/clarifications.html", h.ExportClarificationsHTML)
+ iaceRoutes.GET("/projects/:id/clarifications/:cid/detail", h.ListClarificationDetail)
iaceRoutes.POST("/projects/:id/clarifications/:cid/answer", h.AnswerClarification)
+ iaceRoutes.POST("/projects/:id/clarifications/:cid/comment", h.PostClarificationComment)
}
}
diff --git a/ai-compliance-sdk/internal/iace/clarifications.go b/ai-compliance-sdk/internal/iace/clarifications.go
index b31a97a7..7b216b64 100644
--- a/ai-compliance-sdk/internal/iace/clarifications.go
+++ b/ai-compliance-sdk/internal/iace/clarifications.go
@@ -33,6 +33,7 @@ type Clarification struct {
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
+ AssignedTo string `json:"assigned_to,omitempty"`
}
// ClarificationAnswer is the persisted shape (one entry in
@@ -43,6 +44,7 @@ type ClarificationAnswer struct {
Reasoning string `json:"reasoning,omitempty"`
AnsweredBy string `json:"answered_by,omitempty"`
AnsweredAt string `json:"answered_at,omitempty"`
+ AssignedTo string `json:"assigned_to,omitempty"`
}
// BuildProjectClarifications walks the project's current hazards and returns
@@ -154,6 +156,7 @@ func BuildProjectClarifications(
b.Reasoning = ans.Reasoning
b.AnsweredBy = ans.AnsweredBy
b.AnsweredAt = ans.AnsweredAt
+ b.AssignedTo = ans.AssignedTo
}
// dedup hazard IDs (multiple patterns can target the same hazard)
b.AffectedHazardIDs = dedupUUIDs(b.AffectedHazardIDs)
diff --git a/ai-compliance-sdk/internal/iace/store_clarifications.go b/ai-compliance-sdk/internal/iace/store_clarifications.go
new file mode 100644
index 00000000..34597cd9
--- /dev/null
+++ b/ai-compliance-sdk/internal/iace/store_clarifications.go
@@ -0,0 +1,241 @@
+package iace
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+
+ "github.com/google/uuid"
+ "github.com/jackc/pgx/v5"
+)
+
+// ClarificationRow is the persisted shape (one row per [project, clarification_key]).
+type ClarificationRow struct {
+ ID uuid.UUID
+ TenantID uuid.UUID
+ ProjectID uuid.UUID
+ ClarificationKey string
+ Question string
+ Source string
+ Category string
+ NormReferences []string
+ Status string
+ Answer string
+ Reasoning string
+ AssignedTo string
+ AnsweredBy string
+ AnsweredAt *time.Time
+ CreatedAt time.Time
+ UpdatedAt time.Time
+}
+
+// ClarificationComment is a single comment on a clarification.
+type ClarificationComment struct {
+ ID uuid.UUID
+ ClarificationID uuid.UUID
+ Author string
+ Body string
+ CreatedAt time.Time
+}
+
+// ClarificationHistoryEntry logs status/answer transitions.
+type ClarificationHistoryEntry struct {
+ ID uuid.UUID
+ ClarificationID uuid.UUID
+ Actor string
+ FromStatus string
+ ToStatus string
+ FromAnswer string
+ ToAnswer string
+ Note string
+ CreatedAt time.Time
+}
+
+// UpsertClarification creates or updates a clarification row by
+// (project_id, clarification_key) and logs the status/answer transition
+// in iace_clarification_history when something changes.
+func (s *Store) UpsertClarification(ctx context.Context, in ClarificationRow) (*ClarificationRow, error) {
+ tx, err := s.pool.Begin(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("begin upsert clarification: %w", err)
+ }
+ defer func() { _ = tx.Rollback(ctx) }()
+
+ // Fetch existing for history diff
+ var prev ClarificationRow
+ prevErr := tx.QueryRow(ctx, `
+ SELECT id, status, answer
+ FROM iace_clarifications
+ WHERE project_id = $1 AND clarification_key = $2
+ `, in.ProjectID, in.ClarificationKey).Scan(&prev.ID, &prev.Status, &prev.Answer)
+ hadPrev := prevErr == nil
+ if prevErr != nil && !errors.Is(prevErr, pgx.ErrNoRows) && !errors.Is(prevErr, sql.ErrNoRows) {
+ return nil, fmt.Errorf("lookup existing clarification: %w", prevErr)
+ }
+
+ var row ClarificationRow
+ err = tx.QueryRow(ctx, `
+ INSERT INTO iace_clarifications (
+ tenant_id, project_id, clarification_key, question, source, category,
+ norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at
+ ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
+ ON CONFLICT (project_id, clarification_key) DO UPDATE SET
+ question = EXCLUDED.question,
+ source = EXCLUDED.source,
+ category = EXCLUDED.category,
+ norm_references = EXCLUDED.norm_references,
+ status = EXCLUDED.status,
+ answer = EXCLUDED.answer,
+ reasoning = EXCLUDED.reasoning,
+ assigned_to = EXCLUDED.assigned_to,
+ answered_by = EXCLUDED.answered_by,
+ answered_at = EXCLUDED.answered_at
+ RETURNING id, tenant_id, project_id, clarification_key, question, source, category,
+ norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
+ created_at, updated_at
+ `,
+ in.TenantID, in.ProjectID, in.ClarificationKey, in.Question, in.Source, in.Category,
+ in.NormReferences, in.Status, in.Answer, in.Reasoning, in.AssignedTo, in.AnsweredBy, in.AnsweredAt,
+ ).Scan(
+ &row.ID, &row.TenantID, &row.ProjectID, &row.ClarificationKey, &row.Question, &row.Source, &row.Category,
+ &row.NormReferences, &row.Status, &row.Answer, &row.Reasoning, &row.AssignedTo, &row.AnsweredBy, &row.AnsweredAt,
+ &row.CreatedAt, &row.UpdatedAt,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("upsert clarification: %w", err)
+ }
+
+ // Log transition iff something changed
+ if hadPrev && (prev.Status != row.Status || prev.Answer != row.Answer) {
+ _, err = tx.Exec(ctx, `
+ INSERT INTO iace_clarification_history (clarification_id, actor, from_status, to_status, from_answer, to_answer, note)
+ VALUES ($1, $2, $3, $4, $5, $6, $7)
+ `, row.ID, in.AnsweredBy, prev.Status, row.Status, prev.Answer, row.Answer, "")
+ if err != nil {
+ return nil, fmt.Errorf("write history: %w", err)
+ }
+ }
+ if err := tx.Commit(ctx); err != nil {
+ return nil, fmt.Errorf("commit upsert clarification: %w", err)
+ }
+ return &row, nil
+}
+
+// ListClarificationsForProject returns all clarification rows for a project.
+func (s *Store) ListClarificationsForProject(ctx context.Context, projectID uuid.UUID) ([]ClarificationRow, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, tenant_id, project_id, clarification_key, question, source, category,
+ norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
+ created_at, updated_at
+ FROM iace_clarifications
+ WHERE project_id = $1
+ ORDER BY status, source, created_at
+ `, projectID)
+ if err != nil {
+ return nil, fmt.Errorf("list clarifications: %w", err)
+ }
+ defer rows.Close()
+ out := []ClarificationRow{}
+ for rows.Next() {
+ var r ClarificationRow
+ if err := rows.Scan(
+ &r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category,
+ &r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt,
+ &r.CreatedAt, &r.UpdatedAt,
+ ); err != nil {
+ return nil, err
+ }
+ out = append(out, r)
+ }
+ return out, rows.Err()
+}
+
+// GetClarificationByKey fetches a clarification by project + key.
+func (s *Store) GetClarificationByKey(ctx context.Context, projectID uuid.UUID, key string) (*ClarificationRow, error) {
+ var r ClarificationRow
+ err := s.pool.QueryRow(ctx, `
+ SELECT id, tenant_id, project_id, clarification_key, question, source, category,
+ norm_references, status, answer, reasoning, assigned_to, answered_by, answered_at,
+ created_at, updated_at
+ FROM iace_clarifications
+ WHERE project_id = $1 AND clarification_key = $2
+ `, projectID, key).Scan(
+ &r.ID, &r.TenantID, &r.ProjectID, &r.ClarificationKey, &r.Question, &r.Source, &r.Category,
+ &r.NormReferences, &r.Status, &r.Answer, &r.Reasoning, &r.AssignedTo, &r.AnsweredBy, &r.AnsweredAt,
+ &r.CreatedAt, &r.UpdatedAt,
+ )
+ if errors.Is(err, pgx.ErrNoRows) {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, fmt.Errorf("get clarification: %w", err)
+ }
+ return &r, nil
+}
+
+// AddClarificationComment appends a comment to the thread.
+func (s *Store) AddClarificationComment(ctx context.Context, clarID uuid.UUID, author, body string) (*ClarificationComment, error) {
+ var c ClarificationComment
+ err := s.pool.QueryRow(ctx, `
+ INSERT INTO iace_clarification_comments (clarification_id, author, body)
+ VALUES ($1, $2, $3)
+ RETURNING id, clarification_id, author, body, created_at
+ `, clarID, author, body).Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt)
+ if err != nil {
+ return nil, fmt.Errorf("add clarification comment: %w", err)
+ }
+ return &c, nil
+}
+
+// ListClarificationComments returns the comment thread, oldest first.
+func (s *Store) ListClarificationComments(ctx context.Context, clarID uuid.UUID) ([]ClarificationComment, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, clarification_id, author, body, created_at
+ FROM iace_clarification_comments
+ WHERE clarification_id = $1
+ ORDER BY created_at
+ `, clarID)
+ if err != nil {
+ return nil, fmt.Errorf("list clarification comments: %w", err)
+ }
+ defer rows.Close()
+ out := []ClarificationComment{}
+ for rows.Next() {
+ var c ClarificationComment
+ if err := rows.Scan(&c.ID, &c.ClarificationID, &c.Author, &c.Body, &c.CreatedAt); err != nil {
+ return nil, err
+ }
+ out = append(out, c)
+ }
+ return out, rows.Err()
+}
+
+// ListClarificationHistory returns the audit trail entries for a clarification.
+func (s *Store) ListClarificationHistory(ctx context.Context, clarID uuid.UUID) ([]ClarificationHistoryEntry, error) {
+ rows, err := s.pool.Query(ctx, `
+ SELECT id, clarification_id, actor, from_status, to_status, from_answer, to_answer, note, created_at
+ FROM iace_clarification_history
+ WHERE clarification_id = $1
+ ORDER BY created_at DESC
+ `, clarID)
+ if err != nil {
+ return nil, fmt.Errorf("list clarification history: %w", err)
+ }
+ defer rows.Close()
+ out := []ClarificationHistoryEntry{}
+ for rows.Next() {
+ var h ClarificationHistoryEntry
+ var fromStatus, toStatus, fromAnswer, toAnswer sql.NullString
+ if err := rows.Scan(&h.ID, &h.ClarificationID, &h.Actor, &fromStatus, &toStatus, &fromAnswer, &toAnswer, &h.Note, &h.CreatedAt); err != nil {
+ return nil, err
+ }
+ h.FromStatus = fromStatus.String
+ h.ToStatus = toStatus.String
+ h.FromAnswer = fromAnswer.String
+ h.ToAnswer = toAnswer.String
+ out = append(out, h)
+ }
+ return out, rows.Err()
+}
diff --git a/ai-compliance-sdk/migrations/028_iace_clarifications.sql b/ai-compliance-sdk/migrations/028_iace_clarifications.sql
new file mode 100644
index 00000000..2e53ef2f
--- /dev/null
+++ b/ai-compliance-sdk/migrations/028_iace_clarifications.sql
@@ -0,0 +1,88 @@
+-- Migration 028: IACE Clarifications — Multi-User-Workflow
+-- ==========================================================================
+-- Up to Phase 2 the Klaerungen feature persisted answers in
+-- iace_projects.metadata.clarification_answers (JSONB). That works for a
+-- single user but cannot model assigned_to, comment threads, status
+-- history or audit trail. Phase 3 introduces a proper relational table.
+--
+-- The previous JSONB blob remains read by the engine as fallback for any
+-- project whose clarifications have not yet been migrated, so this is a
+-- non-breaking add-on. A separate one-shot upcopy script migrates the
+-- existing JSONB answers into rows.
+-- ==========================================================================
+
+-- 1. Main table: one row per (project, clarification_id)
+CREATE TABLE IF NOT EXISTS iace_clarifications (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ tenant_id UUID NOT NULL,
+ project_id UUID NOT NULL REFERENCES iace_projects(id) ON DELETE CASCADE,
+ -- Deterministic clarification ID generated by the engine
+ -- (e.g. "pattern:HP1640:0", "manuf:fanuc:dual-check-safety-dcs:1").
+ -- Stable across re-inits so persisted answers survive.
+ clarification_key TEXT NOT NULL,
+ -- The verbatim question + source as known at last engine run.
+ -- Kept in this table so the audit trail does not break if the
+ -- pattern library is later updated.
+ question TEXT NOT NULL,
+ source TEXT NOT NULL,
+ category TEXT NOT NULL,
+ norm_references TEXT[] DEFAULT '{}',
+ -- Lifecycle state
+ status TEXT NOT NULL DEFAULT 'open'
+ CHECK (status IN ('open', 'in_progress', 'answered', 'not_relevant')),
+ answer TEXT DEFAULT '' CHECK (answer IN ('', 'ja', 'nein', 'teilweise')),
+ reasoning TEXT DEFAULT '',
+ -- Multi-User workflow
+ assigned_to TEXT DEFAULT '', -- user id / kuerzel — free text for now
+ answered_by TEXT DEFAULT '',
+ answered_at TIMESTAMPTZ,
+ -- Common audit
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ UNIQUE (project_id, clarification_key)
+);
+
+CREATE INDEX IF NOT EXISTS idx_iace_clar_project ON iace_clarifications(project_id);
+CREATE INDEX IF NOT EXISTS idx_iace_clar_tenant ON iace_clarifications(tenant_id);
+CREATE INDEX IF NOT EXISTS idx_iace_clar_status ON iace_clarifications(project_id, status);
+CREATE INDEX IF NOT EXISTS idx_iace_clar_assignee ON iace_clarifications(assigned_to) WHERE assigned_to <> '';
+
+-- 2. Comment thread: one row per comment, ordered by created_at
+CREATE TABLE IF NOT EXISTS iace_clarification_comments (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE,
+ author TEXT NOT NULL DEFAULT '',
+ body TEXT NOT NULL,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_iace_clar_comments_clar ON iace_clarification_comments(clarification_id, created_at);
+
+-- 3. Status history — every status / answer change is logged.
+CREATE TABLE IF NOT EXISTS iace_clarification_history (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ clarification_id UUID NOT NULL REFERENCES iace_clarifications(id) ON DELETE CASCADE,
+ actor TEXT NOT NULL DEFAULT '',
+ from_status TEXT,
+ to_status TEXT,
+ from_answer TEXT,
+ to_answer TEXT,
+ note TEXT DEFAULT '',
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+CREATE INDEX IF NOT EXISTS idx_iace_clar_history_clar ON iace_clarification_history(clarification_id, created_at);
+
+-- 4. Trigger to bump updated_at on the main table
+CREATE OR REPLACE FUNCTION iace_clarifications_touch_updated_at()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ LANGUAGE plpgsql;
+
+DROP TRIGGER IF EXISTS trg_iace_clarifications_updated_at ON iace_clarifications;
+CREATE TRIGGER trg_iace_clarifications_updated_at
+ BEFORE UPDATE ON iace_clarifications
+ FOR EACH ROW EXECUTE FUNCTION iace_clarifications_touch_updated_at();