---
title: 'TEXTIQ-375: Add DELETE tag endpoint on Airflow plugin'
type: 'feature'
created: '2026-04-16'
status: 'done'
baseline_commit: 'e22547d'
context: []
---

<frozen-after-approval reason="human-owned intent — do not modify unless human renegotiates">

## Intent

**Problem:** APA calls `DELETE /api/v1/blocks/{chunk_id}/tags/{tag_id}` via HMAC but the endpoint does not exist on the Airflow plugin — returns 404/405.

**Approach:** Add a DELETE endpoint to the existing `tags_app` in the Airflow plugin. Hard-delete the tag by removing it from the JSONB tags array in PG, then cascade to Milvus by upserting with the tag removed. Consistent with document and domain term delete patterns.

## Boundaries & Constraints

**Always:** HMAC auth enforced (via existing middleware). Return 204 on success. Use existing helpers (`_read_chunk_for_update`, `_get_tags`, `_write_tags`, `_EarlyReturn`). Follow the same PG-first-then-Milvus transaction pattern as sibling endpoints.

**Ask First:** Whether to return 404 or 204 if tag was already removed by a prior call.

**Never:** Import from `src.*` (Airflow plugin constraint). Modify existing endpoint behavior. Add new dependencies.

## I/O & Edge-Case Matrix

| Scenario | Input / State | Expected Output / Behavior | Error Handling |
|----------|--------------|---------------------------|----------------|
| Happy path | Valid chunk_id + tag_id, tag exists | Tag removed from JSONB array in PG, tag removed from Milvus, 204 returned | N/A |
| Chunk not found | Non-existent chunk_id | 404 with NOT_FOUND code | N/A |
| Tag not found | Valid chunk but tag_id not in tags array | 404 with NOT_FOUND code | N/A |
| Chunk not in Milvus | Chunk exists in PG but not in vector index | PG updated, Milvus upsert silently skipped (existing `upsert_chunk_field` behavior) | N/A |

</frozen-after-approval>

## Code Map

- `airflow/plugins/api/tags.py` -- Add DELETE endpoint handler; all helpers already exist
- `airflow/plugins/api/milvus.py` -- `upsert_chunk_field` used for Milvus cascade (no changes needed)
- `tests/airflow/test_plugins/test_tags.py` -- Add tests following existing pattern

## Tasks & Acceptance

**Execution:**
- [x] `airflow/plugins/api/tags.py` -- Add `delete_tag` endpoint (`DELETE /{chunk_id}/tags/{tag_id}`). Remove the tag from the JSONB array, write to PG via `_write_tags`, upsert remaining tags to Milvus via `upsert_chunk_field`, return 204.
- [x] `tests/airflow/test_plugins/test_tags.py` -- Add `TestDeleteTag` class with tests: happy path (204 + tag removed + Milvus called), chunk not found (404), tag not found (404).

**Acceptance Criteria:**
- Given a valid chunk with an existing tag, when DELETE is called, then the tag is removed from the JSONB array in PG and from Milvus metadata, returning 204.
- Given a non-existent chunk_id, when DELETE is called, then 404 is returned.
- Given a valid chunk but non-existent tag_id, when DELETE is called, then 404 is returned.
- Given a valid request without HMAC headers, when DELETE is called, then 401 is returned.

## Verification

**Commands:**
- `uv run pytest tests/airflow/test_plugins/test_tags.py -v` -- expected: all tests pass including new TestDeleteTag class

## Suggested Review Order

- Entry point: DELETE handler — filter-based hard delete, PG write, Milvus cascade
  [`tags.py:269`](../../airflow/plugins/api/tags.py#L269)

- Existing helpers reused unchanged — `_read_chunk_for_update`, `_get_tags`, `_write_tags`
  [`tags.py:79`](../../airflow/plugins/api/tags.py#L79)

- Milvus partial upsert — `upsert_chunk_field` skips chunks not in index
  [`milvus.py:43`](../../airflow/plugins/api/milvus.py#L43)

- Happy path test — verifies 204, tag removed, Milvus receives filtered list
  [`test_tags.py:315`](../../tests/airflow/test_plugins/test_tags.py#L315)

- Edge case — deleting the last tag on a chunk yields empty tags list
  [`test_tags.py:358`](../../tests/airflow/test_plugins/test_tags.py#L358)

- Error paths — chunk not found (404), tag not found (404)
  [`test_tags.py:341`](../../tests/airflow/test_plugins/test_tags.py#L341)
