Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 95 additions & 0 deletions migrations/sql/0061_feedback_transitions_view.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
-- Replayable, minimized feedback transitions for app-scoped agent patrols.
--
-- audit_logs remains the single append-only source of truth. This view strips
-- actor and arbitrary payload data, expands a combined status+assignee audit
-- into a fixed status-then-assignee order, and exposes no reporter identity,
-- comment body, contact, device, webhook data, or secret-bearing field.

CREATE TRIGGER audit_logs_no_update BEFORE UPDATE ON audit_logs BEGIN SELECT RAISE(ABORT, 'audit logs are immutable'); END;

CREATE TRIGGER audit_logs_no_delete BEFORE DELETE ON audit_logs WHEN EXISTS (SELECT 1 FROM apps WHERE id = OLD.app_id) BEGIN SELECT RAISE(ABORT, 'audit logs are durable while app exists'); END;

CREATE INDEX idx_audit_app_action ON audit_logs(app_id, action);
CREATE INDEX idx_audit_action_created ON audit_logs(action, created_at);

CREATE VIEW feedback_transitions AS
WITH safe_logs AS (
SELECT rowid AS audit_rowid, app_id, action, created_at,
CASE WHEN json_valid(payload) THEN payload ELSE '{}' END AS payload
FROM audit_logs
), expanded AS (
SELECT l.audit_rowid,
1 AS event_order,
l.app_id,
json_extract(l.payload, '$.ticket_id') AS ticket_id,
'status_changed' AS transition_type,
json_extract(l.payload, '$.previous_status') AS previous_value,
json_extract(l.payload, '$.status') AS value,
l.created_at AS occurred_at
FROM safe_logs l
WHERE l.action = 'feedback.update'
AND json_type(l.payload, '$.ticket_id') = 'text'
AND json_type(l.payload, '$.previous_status') = 'text'
AND json_type(l.payload, '$.status') = 'text'
AND json_extract(l.payload, '$.previous_status') IS NOT json_extract(l.payload, '$.status')

UNION ALL

SELECT l.audit_rowid,
2,
l.app_id,
json_extract(l.payload, '$.ticket_id'),
'assignee_changed',
json_extract(l.payload, '$.previous_assignee'),
json_extract(l.payload, '$.assignee'),
l.created_at
FROM safe_logs l
WHERE l.action = 'feedback.update'
AND json_type(l.payload, '$.ticket_id') = 'text'
AND json_type(l.payload, '$.previous_assignee') IN ('text', 'null')
AND json_type(l.payload, '$.assignee') IN ('text', 'null')
AND json_extract(l.payload, '$.previous_assignee') IS NOT json_extract(l.payload, '$.assignee')

UNION ALL

SELECT l.audit_rowid,
1,
l.app_id,
json_extract(l.payload, '$.ticket_id'),
'comment_visibility',
NULL,
CASE WHEN json_extract(l.payload, '$.internal') = 1 THEN 'internal' ELSE 'reporter' END,
l.created_at
FROM safe_logs l
WHERE l.action = 'feedback.comment'
AND json_type(l.payload, '$.ticket_id') = 'text'
AND json_type(l.payload, '$.internal') IN ('true', 'false')

UNION ALL

SELECT l.audit_rowid,
1,
l.app_id,
json_extract(l.payload, '$.ticket_id'),
'comment_visibility',
NULL,
'reporter',
l.created_at
FROM safe_logs l
WHERE l.action = 'feedback.reporter_comment'
AND json_type(l.payload, '$.ticket_id') = 'text'
), numbered AS (
SELECT app_id,
ROW_NUMBER() OVER (
PARTITION BY app_id ORDER BY audit_rowid, event_order
) AS sequence,
ticket_id,
transition_type,
previous_value,
value,
occurred_at
FROM expanded
)
SELECT app_id, sequence, ticket_id, transition_type,
previous_value, value, occurred_at
FROM numbered;
61 changes: 61 additions & 0 deletions migrations/validation/0061_feedback_transitions_view.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
-- Post-migration / restore validator for the feedback transition projection.
-- A valid database returns zero rows.
WITH malformed_source AS (
SELECT l.app_id,
'malformed_' || l.action AS violation
FROM audit_logs l
WHERE (
l.action = 'feedback.update'
AND (
NOT json_valid(l.payload)
OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text'
OR NOT (
(
json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.previous_status') = 'text'
AND json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.status') = 'text'
)
OR
(
json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.previous_assignee') IN ('text', 'null')
AND json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.assignee') IN ('text', 'null')
)
)
)
)
OR (
l.action = 'feedback.comment'
AND (
NOT json_valid(l.payload)
OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text'
OR COALESCE(json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.internal'), 'missing') NOT IN ('true', 'false')
)
)
OR (
l.action = 'feedback.reporter_comment'
AND (
NOT json_valid(l.payload)
OR json_type(CASE WHEN json_valid(l.payload) THEN l.payload ELSE '{}' END, '$.ticket_id') IS NOT 'text'
)
)
), projection_stats AS (
SELECT app_id,
COUNT(*) AS event_count,
MIN(sequence) AS min_sequence,
MAX(sequence) AS max_sequence,
COUNT(*) - COUNT(DISTINCT sequence) AS duplicate_count
FROM feedback_transitions
GROUP BY app_id
), projection_violations AS (
SELECT app_id,
CASE
WHEN min_sequence != 1 THEN 'transition_sequence_not_one_based'
WHEN event_count != max_sequence THEN 'transition_sequence_gap'
WHEN duplicate_count != 0 THEN 'duplicate_transition_sequence'
ELSE NULL
END AS violation
FROM projection_stats
)
SELECT app_id, violation FROM malformed_source
UNION ALL
SELECT app_id, violation FROM projection_violations WHERE violation IS NOT NULL
ORDER BY app_id, violation;
6 changes: 6 additions & 0 deletions worker/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ import {
handlePublicMinidumpSubmit,
handleListFeedback,
handleListFeedbackMaterialDelta,
handleListFeedbackTransitions,
handleGetFeedback,
handleUpdateFeedback,
handleAddFeedbackComment,
Expand Down Expand Up @@ -761,6 +762,11 @@ admin.get(
requireAppRole("viewer"),
handleListFeedbackMaterialDelta,
);
admin.get(
"/api/apps/:appId/feedback/transitions",
requireAppRole("viewer"),
handleListFeedbackTransitions,
);
admin.get("/api/apps/:appId/feedback/:ticketId", requireAppRole("viewer"), handleGetFeedback);
admin.patch("/api/apps/:appId/feedback/:ticketId", requireFeedbackTriageRole(), handleUpdateFeedback);
admin.post("/api/apps/:appId/feedback/:ticketId/comments", requireFeedbackTriageRole(), handleAddFeedbackComment);
Expand Down
35 changes: 35 additions & 0 deletions worker/src/openapi/feedback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,41 @@ export function registerFeedbackRoutes(registry: OpenApiRegistry) {
},
});

register(registry, {
method: "get",
path: "/api/apps/{appId}/feedback/transitions",
tags: ["Feedback"],
summary: "Replay feedback status, assignee, and comment-visibility transitions",
description:
"Returns a minimized transition stream derived from append-only audit records. coverage_started_at is reported per transition type; null or a report window before that timestamp means the corresponding report section is incomplete. Process a whole page before persisting next_cursor.",
security: auth,
request: {
params: AppIdParam,
query: z.object({
cursor: z.string().optional(),
limit: z.coerce.number().int().min(1).max(200).optional(),
}),
},
responses: {
200: success(
"Complete app-visible feedback transitions within each declared coverage window.",
z.object({
transitions: z.array(GenericObject),
next_cursor: z.string(),
has_more: z.boolean(),
coverage_started_at: z.object({
status_changed: z.number().int().nonnegative().nullable(),
assignee_changed: z.number().int().nonnegative().nullable(),
comment_visibility: z.number().int().nonnegative().nullable(),
}),
}),
),
400: error("Invalid feedback transitions cursor or limit."),
403: error("Current principal cannot view feedback."),
500: error("Feedback transition projection is unavailable."),
},
});

register(registry, {
method: "get",
path: "/api/apps/{appId}/feedback/stats",
Expand Down
11 changes: 11 additions & 0 deletions worker/src/routes/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,17 @@ export async function handleAgentManifest(c: Context<{ Bindings: Env }>) {
limit: { type: "number", in: "query", required: false, description: "Page size from 1 to 200; defaults to 100." },
},
},
{
name: "list-feedback-transitions",
description:
"Replay minimized feedback status, assignee, and comment-visibility transitions after an opaque per-app cursor. Requires app viewer. coverage_started_at is per transition type; a null or later coverage timestamp makes that report section incomplete. Process the whole page before persisting next_cursor.",
endpoint: { method: "GET", path: "/api/apps/{app_id}/feedback/transitions" },
parameters: {
app_id: { type: "string", in: "path", required: true, description: "App UUID." },
cursor: { type: "string", in: "query", required: false, description: "Opaque feedback-transitions-v1 cursor from the previous page; omit for the coverage epoch." },
limit: { type: "number", in: "query", required: false, description: "Page size from 1 to 200; defaults to 100." },
},
},
{
name: "bind-reporter-webhook",
description:
Expand Down
Loading