Contribution Date
Contribution Project
Contribution Details
A vocabulary you can close, and the fifth guard rail with no road
taxonomies.ai_taggable has existed since migration 0001 and nothing has ever read
it. So the zero-shot vocabulary query offered a model every non-deprecated term in
the tenant, an administrator who marked a vocabulary off-limits to machine tagging
changed nothing, and category trees — filing structure, not a label set — went
into the prompt alongside the vocabularies. §8.2 says a closed vocabulary is what
keeps AI tags governable; that is only true if something closes it.
It was also unadministrable. dam_db::taxonomy's move, merge and deprecate have
been written since 2.2 and unreachable since 2.2, and there was no way to create a
vocabulary outside SQL — which is why nobody had ever set the flag: there was no
surface on which to set it.
The sixth &PgPool module, and this one had a reason. The docs said the pool was
deliberate: a caller cannot accidentally run a merge outside a transaction. The
intent was right and the mechanism made the module uncallable, because a handler
reaches tenant tables through a TenantConn whose transaction carries the
search_path, and a pool has none. TenantConn *is* a Transaction, so taking a
connection keeps the guarantee and makes it structural — and a caller can now
merge three terms in one transaction instead of three, closing the windows where
the vocabulary had two live terms for one concept.
Migration 0034 backfills, and my first backfill was wrong. Honouring a
false-default column without one would hand every existing tenant an empty
vocabulary and silently stop AI tagging that works. So it sets the flag true — and
setting it on every taxonomy, which looked like the conservative choice, opened the
dev tenant's category tree to the model. Running it is what caught that. The
backfill is now scoped to kind = 'vocabulary', and the query requires the kind as
well as the flag, so a tree can never be offered even if somebody sets the column
by hand. That case is now a test.
Three more guards fired, all correctly. The embedded migration count and the
tenant index count are both asserted against the repository, so 0034 and its two
partial indexes had to be accounted for. And the MCP get_brand_guidelines tool
shares this query — its fixture's taxonomy had never been opened, so it correctly
returned nothing. That sharing is intentional and the comment already said so: an
agent must be told the same closed set the zero-shot pass scores against, or the
two would disagree about the library's own language.
What the surface deliberately lacks. No delete, at either level: asset_tags
cascades, so deleting a term untags every asset that carried it — years of work,
gone quietly, noticed when a search returns empty. Retire keeps the assets and
keeps the id resolving; merge moves the assets and leaves a pointer. No slug field
on the edit form either, because it is what a model answers with and what an
import resolves.
Smaller decisions. Opening a vocabulary to a model is its own endpoint, not a
field on an update body, so it cannot be changed while editing a label. Synonyms
are trimmed, emptied and de-duplicated case-insensitively before they cost prompt
bytes on every call — dam_ai::enrich already matches without regard to case. The
threshold is clamped and read back, because a screen showing the 1.5 somebody
typed would show a setting that is not in force. A term id in a URL is checked
against the vocabulary id beside it, or the path segment would be decoration and a
guessed id would confirm a term exists.
10 db cases, 9 API cases, 9 browser cases including axe. Verified against the
running stack through a real browser: created, terms added with "cloudy, grey ,,
Cloudy" arriving as "cloudy, grey", opened and closed with the count in the
sentence, merged with the survivor named, a 1.5 threshold reported back as 1, and
retired — with two copy fixes that only showed up on real data.
Contribution Issue Link
Files count
0
Patches count
1