A vocabulary you can close, and the fifth guard rail with no road

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 Author
Bassam Ismail
Files count
0
Patches count
1