refactor(health-report): put summary where the other writer puts it
check_history has two producers. sysmon-go writes `summary` at the top level beside `status`; this module wrote it under `metrics`. So a reader had to know which producer wrote a row before it could find out what the row said, and a query written the obvious way found one and silently missed the other. That is the T-36 failure repeating. There, per-domain queries returned rows from August and looked like a system that had stopped reporting, because the data was nested under a composite row nobody had mentioned. Nothing was missing; the query was asking the wrong shape. verify.sh had already grown a coalesce over both spellings, which is the tell: a compatibility shim that hides a schema disagreement rather than resolving it. D-33 made this table a contract between producers, and a contract needs one spelling. Summary is now a required parameter with no default. sysmon-go enforces the same thing through Domain.Run's signature, and the reason is identical: a row whose substance is missing looks exactly like a row whose check found nothing to say. Both call sites pass it; the failure path passes the exception rather than leaving the field to the metrics blob. Old rows keep the nested spelling and verify.sh keeps reading both, because rewriting history to match a new convention is a worse trade than a fallback with a reason attached. Also drops "Three consequences" from the module docstring, which by then listed five. A hardcoded count beside the thing it counts is the same defect as install.sh printing "wrote 8 keys" while writing ten — this morning's bug, in prose instead of code. Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -148,3 +148,59 @@ Omitted rather than nulled when absent — report() is callable from a script, a
|
||||
|
||||
The one pre-existing critical row still carries no task, which now identifies it: it is the only backup row without the field, so it is legible as pre-attribution rather than ambiguous.', NULL, '2026-08-11 10:25:02', '2026-08-11 10:25:02.952', '2026-08-11 10:25:02.952', NULL, 'ede9b41ea9849e08202b7aae16e9a54c', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ0PNFBDB743QJQ9MF38MPJ8', 'status', 'backlog', 'done', NULL, '2026-08-11 10:25:03', '2026-08-11 10:25:03.085', '2026-08-11 10:25:03.085', NULL, 'b45fb340bf78c6c7e4287df001d01456', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ0FHPBBXXKBRWGR3FJZRK58', 'description', 'DELETE /tasks/{task_name} returns 500 whenever the task has at least one row in task_executions:
|
||||
|
||||
psycopg2.errors.ForeignKeyViolation: update or delete on table "scheduled_tasks"
|
||||
violates foreign key constraint "task_executions_task_id_fkey" on table "task_executions"
|
||||
DETAIL: Key (id)=(46) is still referenced from table "task_executions".
|
||||
|
||||
src/main.py:355. Since every task that has ever fired has execution history, the endpoint works only for tasks that have never run — which is close to none of them. Found on 2026-08-11 while cleaning up a temporary probe task created for T-74; it had to be removed with hand-written SQL against two tables, which is not something the API should require.
|
||||
|
||||
The caller gets a bare "Internal Server Error" with no indication that history is the obstacle, so it reads as the service being broken rather than the request being refusable.
|
||||
|
||||
Deciding what delete should MEAN is the actual work here, and it should not be guessed:
|
||||
- cascade — drop the execution history with the task. Simple, and silently destroys the audit trail for a task someone deletes by mistake.
|
||||
- soft delete — mark it deleted and keep the history. Keeps the audit trail, adds a state every query then has to filter on.
|
||||
- refuse with 409 and a real message ("task has N executions; pass ?purge=true"). Explicit, and makes the destructive variant a deliberate act.
|
||||
|
||||
The third is the smallest correct change and matches how the rest of this system treats destructive operations. Whichever is chosen, a 500 on a foreseeable, well-defined condition is the part that is simply wrong.', 'DELETE /tasks/{task_name} returns 500 whenever the task has at least one row in task_executions:
|
||||
|
||||
psycopg2.errors.ForeignKeyViolation: update or delete on table "scheduled_tasks"
|
||||
violates foreign key constraint "task_executions_task_id_fkey" on table "task_executions"
|
||||
DETAIL: Key (id)=(46) is still referenced from table "task_executions".
|
||||
|
||||
src/main.py:355. Since every task that has ever fired has execution history, the endpoint works only for tasks that have never run — which is close to none of them. Found on 2026-08-11 while cleaning up a temporary probe task created for T-74; it had to be removed with hand-written SQL against two tables, which is not something the API should require.
|
||||
|
||||
The caller gets a bare "Internal Server Error" with no indication that history is the obstacle, so it reads as the service being broken rather than the request being refusable.
|
||||
|
||||
Deciding what delete should MEAN is the actual work here, and it should not be guessed:
|
||||
- cascade — drop the execution history with the task. Simple, and silently destroys the audit trail for a task someone deletes by mistake.
|
||||
- soft delete — mark it deleted and keep the history. Keeps the audit trail, adds a state every query then has to filter on.
|
||||
- refuse with 409 and a real message ("task has N executions; pass ?purge=true"). Explicit, and makes the destructive variant a deliberate act.
|
||||
|
||||
The third is the smallest correct change and matches how the rest of this system treats destructive operations. Whichever is chosen, a 500 on a foreseeable, well-defined condition is the part that is simply wrong.
|
||||
|
||||
FIXED in v1.8.0 (c34db66). 409 with a message that can be acted on; ?purge=true proceeds.
|
||||
|
||||
Verified against the live service with a throwaway task that had one execution row:
|
||||
|
||||
DELETE /tasks/t1_delete_probe2 -> HTTP 409
|
||||
Task ''t1_delete_probe2'' has 1 execution record(s). Deleting it would discard
|
||||
that history. Re-send with ?purge=true to delete the task and its history
|
||||
together, or PUT enabled=false to stop it running while keeping the record.
|
||||
task still present afterwards: 1 (the refusal deleted nothing)
|
||||
|
||||
DELETE /tasks/t1_delete_probe2?purge=true -> HTTP 200
|
||||
{"message":"...deleted successfully","executions_purged":1}
|
||||
tasks: 24, probe execution rows: 0
|
||||
|
||||
REFUSE RATHER THAN CASCADE, chosen for asymmetry of recovery: a task definition can be recreated from the API in one call, its execution history cannot be recreated at all. Defaulting to the destructive reading of an ambiguous request is how audit trails disappear quietly.
|
||||
|
||||
The message carries the three things a caller needs — how much history is at stake, the flag that proceeds, and PUT enabled=false, which is usually what was actually wanted since it stops the task running and keeps the record. A bare "conflict" would be little better than the 500 it replaced.
|
||||
|
||||
History and task are deleted in ONE transaction. Split across two, a failure between them leaves the audit trail gone and the task alive: the worst of both outcomes.
|
||||
|
||||
Mutation-checked: removing the guard fails the refusal tests. Separate tests pin that a refused delete issues no DELETE at all, and that ?purge=true against a missing task is still 404 rather than a success.
|
||||
|
||||
NOTE: the commit for this work is missing its Co-Authored-By trailer — I wrote the message file without it. Already pushed, and fixing it would require rewriting published history on main, so it stands as-is.', NULL, '2026-08-11 10:31:46', '2026-08-11 10:31:46.465', '2026-08-11 10:31:46.465', NULL, 'efc9df5b52ac92d63e5ae5a60c2bdef8', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
INSERT INTO ticket_history (ticket_record_id, field, old_value, new_value, changed_by, changed_at, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ0FHPBBXXKBRWGR3FJZRK58', 'status', 'backlog', 'done', NULL, '2026-08-11 10:31:46', '2026-08-11 10:31:46.598', '2026-08-11 10:31:46.598', NULL, '7bad13e92e65eb912519a498a882faee', 2) ON CONFLICT(hash) DO NOTHING;
|
||||
|
||||
@@ -191,3 +191,83 @@ TWO PROPERTIES VERIFIED IN THE DEPLOYED RUNTIME rather than assumed:
|
||||
Omitted rather than nulled when absent — report() is callable from a script, and a null would claim a task existed with no name.
|
||||
|
||||
The one pre-existing critical row still carries no task, which now identifies it: it is the only backup row without the field, so it is legible as pre-attribution rather than ambiguous.', 'done', 'medium', NULL, NULL, NULL, '2026-08-11 10:25:02.811', '2026-08-11 10:25:03.084', NULL, '63f6b48e753891a75d47dd0b3d7b5a4c', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ0FHPBBXXKBRWGR3FJZRK58', 'bug', NULL, 'DELETE /tasks/{name} 500s for any task that has ever run', 'DELETE /tasks/{task_name} returns 500 whenever the task has at least one row in task_executions:
|
||||
|
||||
psycopg2.errors.ForeignKeyViolation: update or delete on table "scheduled_tasks"
|
||||
violates foreign key constraint "task_executions_task_id_fkey" on table "task_executions"
|
||||
DETAIL: Key (id)=(46) is still referenced from table "task_executions".
|
||||
|
||||
src/main.py:355. Since every task that has ever fired has execution history, the endpoint works only for tasks that have never run — which is close to none of them. Found on 2026-08-11 while cleaning up a temporary probe task created for T-74; it had to be removed with hand-written SQL against two tables, which is not something the API should require.
|
||||
|
||||
The caller gets a bare "Internal Server Error" with no indication that history is the obstacle, so it reads as the service being broken rather than the request being refusable.
|
||||
|
||||
Deciding what delete should MEAN is the actual work here, and it should not be guessed:
|
||||
- cascade — drop the execution history with the task. Simple, and silently destroys the audit trail for a task someone deletes by mistake.
|
||||
- soft delete — mark it deleted and keep the history. Keeps the audit trail, adds a state every query then has to filter on.
|
||||
- refuse with 409 and a real message ("task has N executions; pass ?purge=true"). Explicit, and makes the destructive variant a deliberate act.
|
||||
|
||||
The third is the smallest correct change and matches how the rest of this system treats destructive operations. Whichever is chosen, a 500 on a foreseeable, well-defined condition is the part that is simply wrong.
|
||||
|
||||
FIXED in v1.8.0 (c34db66). 409 with a message that can be acted on; ?purge=true proceeds.
|
||||
|
||||
Verified against the live service with a throwaway task that had one execution row:
|
||||
|
||||
DELETE /tasks/t1_delete_probe2 -> HTTP 409
|
||||
Task ''t1_delete_probe2'' has 1 execution record(s). Deleting it would discard
|
||||
that history. Re-send with ?purge=true to delete the task and its history
|
||||
together, or PUT enabled=false to stop it running while keeping the record.
|
||||
task still present afterwards: 1 (the refusal deleted nothing)
|
||||
|
||||
DELETE /tasks/t1_delete_probe2?purge=true -> HTTP 200
|
||||
{"message":"...deleted successfully","executions_purged":1}
|
||||
tasks: 24, probe execution rows: 0
|
||||
|
||||
REFUSE RATHER THAN CASCADE, chosen for asymmetry of recovery: a task definition can be recreated from the API in one call, its execution history cannot be recreated at all. Defaulting to the destructive reading of an ambiguous request is how audit trails disappear quietly.
|
||||
|
||||
The message carries the three things a caller needs — how much history is at stake, the flag that proceeds, and PUT enabled=false, which is usually what was actually wanted since it stops the task running and keeps the record. A bare "conflict" would be little better than the 500 it replaced.
|
||||
|
||||
History and task are deleted in ONE transaction. Split across two, a failure between them leaves the audit trail gone and the task alive: the worst of both outcomes.
|
||||
|
||||
Mutation-checked: removing the guard fails the refusal tests. Separate tests pin that a refused delete issues no DELETE at all, and that ?purge=true against a missing task is still 404 rather than a success.
|
||||
|
||||
NOTE: the commit for this work is missing its Co-Authored-By trailer — I wrote the message file without it. Already pushed, and fixing it would require rewriting published history on main, so it stands as-is.', 'backlog', 'high', NULL, NULL, NULL, '2026-08-11 09:53:56.826', '2026-08-11 10:31:46.465', NULL, '9299b6bccd53cdc77e2551e2de94a10e', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
INSERT INTO tickets (record_id, type, parent_record_id, title, description, status, priority, assigned_to, team, decision_ref, created_at, updated_at, deleted_at, hash, canonical_version) VALUES ('06FZ0FHPBBXXKBRWGR3FJZRK58', 'bug', NULL, 'DELETE /tasks/{name} 500s for any task that has ever run', 'DELETE /tasks/{task_name} returns 500 whenever the task has at least one row in task_executions:
|
||||
|
||||
psycopg2.errors.ForeignKeyViolation: update or delete on table "scheduled_tasks"
|
||||
violates foreign key constraint "task_executions_task_id_fkey" on table "task_executions"
|
||||
DETAIL: Key (id)=(46) is still referenced from table "task_executions".
|
||||
|
||||
src/main.py:355. Since every task that has ever fired has execution history, the endpoint works only for tasks that have never run — which is close to none of them. Found on 2026-08-11 while cleaning up a temporary probe task created for T-74; it had to be removed with hand-written SQL against two tables, which is not something the API should require.
|
||||
|
||||
The caller gets a bare "Internal Server Error" with no indication that history is the obstacle, so it reads as the service being broken rather than the request being refusable.
|
||||
|
||||
Deciding what delete should MEAN is the actual work here, and it should not be guessed:
|
||||
- cascade — drop the execution history with the task. Simple, and silently destroys the audit trail for a task someone deletes by mistake.
|
||||
- soft delete — mark it deleted and keep the history. Keeps the audit trail, adds a state every query then has to filter on.
|
||||
- refuse with 409 and a real message ("task has N executions; pass ?purge=true"). Explicit, and makes the destructive variant a deliberate act.
|
||||
|
||||
The third is the smallest correct change and matches how the rest of this system treats destructive operations. Whichever is chosen, a 500 on a foreseeable, well-defined condition is the part that is simply wrong.
|
||||
|
||||
FIXED in v1.8.0 (c34db66). 409 with a message that can be acted on; ?purge=true proceeds.
|
||||
|
||||
Verified against the live service with a throwaway task that had one execution row:
|
||||
|
||||
DELETE /tasks/t1_delete_probe2 -> HTTP 409
|
||||
Task ''t1_delete_probe2'' has 1 execution record(s). Deleting it would discard
|
||||
that history. Re-send with ?purge=true to delete the task and its history
|
||||
together, or PUT enabled=false to stop it running while keeping the record.
|
||||
task still present afterwards: 1 (the refusal deleted nothing)
|
||||
|
||||
DELETE /tasks/t1_delete_probe2?purge=true -> HTTP 200
|
||||
{"message":"...deleted successfully","executions_purged":1}
|
||||
tasks: 24, probe execution rows: 0
|
||||
|
||||
REFUSE RATHER THAN CASCADE, chosen for asymmetry of recovery: a task definition can be recreated from the API in one call, its execution history cannot be recreated at all. Defaulting to the destructive reading of an ambiguous request is how audit trails disappear quietly.
|
||||
|
||||
The message carries the three things a caller needs — how much history is at stake, the flag that proceeds, and PUT enabled=false, which is usually what was actually wanted since it stops the task running and keeps the record. A bare "conflict" would be little better than the 500 it replaced.
|
||||
|
||||
History and task are deleted in ONE transaction. Split across two, a failure between them leaves the audit trail gone and the task alive: the worst of both outcomes.
|
||||
|
||||
Mutation-checked: removing the guard fails the refusal tests. Separate tests pin that a refused delete issues no DELETE at all, and that ?purge=true against a missing task is still 404 rather than a success.
|
||||
|
||||
NOTE: the commit for this work is missing its Co-Authored-By trailer — I wrote the message file without it. Already pushed, and fixing it would require rewriting published history on main, so it stands as-is.', 'done', 'high', NULL, NULL, NULL, '2026-08-11 09:53:56.826', '2026-08-11 10:31:46.597', NULL, 'e020f77c66ce6631f6d05ee63ec0c2b8', 2) ON CONFLICT(record_id) DO UPDATE SET type=excluded.type, parent_record_id=excluded.parent_record_id, title=excluded.title, description=excluded.description, status=excluded.status, priority=excluded.priority, assigned_to=excluded.assigned_to, team=excluded.team, decision_ref=excluded.decision_ref, updated_at=excluded.updated_at, deleted_at=excluded.deleted_at, hash=excluded.hash, canonical_version=excluded.canonical_version WHERE excluded.updated_at >= tickets.updated_at;
|
||||
|
||||
Reference in New Issue
Block a user