You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Translating a taxonomy silently resets the fields that describe it. Creating the Spanish translation exactly the way the API documents it — POST /_emdash/api/taxonomies with translationOf and a translated label — leaves the es def declaring collections: [] and hierarchical: false, because handleTaxonomyCreate defaults both (handlers/taxonomies.ts:322, :370) and never inherits them from the source. getCollectionTaxonomyNames then filters defs by collections on the locale-resolved row, so the taxonomy applies to posts in en and not in es, and a hierarchical taxonomy comes out flat in translation.
No unusual input is required. Omitting fields that describe the taxonomy rather than one locale's presentation of it is the happy path — there is nothing to send, and the defaults win.
Separately, one call to the MCP menu_set_items tool NULLs translation_group on every item in the menu.MenuRepository.setItems is the only insert path that never writes the column (menu.ts:601-615); createItem, the menu-clone path and seed/apply.ts:1137 all set it. Nothing reads it at runtime, so nothing looks wrong — until emdash export-seed (export-seed.ts:594 reads it to emit translationOf), at which point export → re-import mints a fresh group per item and the en/es correspondence is gone.
Both are the same root cause, and it is not confined to these two call sites: the row-per-locale + translation_group model was copied from content entries (019) to six system tables, and on those six nothing owns the fields that identify the thing. Details in Root cause below.
Verified
Against main @ 14017995 (packages/core 0.33.0) with an integration test driving the real handlers on SQLite:
A — en def: collections = ["post"], hierarchical = 1. Its es translation, same translation_group: collections = "[]", hierarchical = 0.
B — after setItems with two items, both rows have translation_group = NULL.
C — with locales: ["ar", "en"], translating a hierarchical en taxonomy to ar leaves ar at hierarchical = 0. requireTaxonomyDef (handlers/taxonomies.ts:146) resolves without a locale using orderBy("locale", "asc") + executeTakeFirst, so the ar row wins and handleTermList returns the tree flattened — a parent and its child both come back top-level — for a request at locale=en. Control run without the ar translation returns the correct nesting.
The model is dialect-independent; D1/Workers behaves the same.
Steps to reproduce
A — structural drift on the happy path (no divergent input required)
Create the Spanish translation the way the API intends: POST /_emdash/api/taxonomies → { "name": "topic", "label": "Temas", "locale": "es", "translationOf": "<id from step 2>" }
SELECT locale, collections, hierarchical FROM _emdash_taxonomy_defs WHERE name = 'topic' → en keeps ["posts"]/1, es gets []/0, both on the same translation_group.
getEmDashCollection("posts", { locale: "es" }) → entries carry no topic terms. The same call at en does.
Expected: collections and hierarchical describe the taxonomy, not one locale's presentation of it, so they should be inherited (or shared) rather than silently reset. The same applies to PUT /_emdash/api/taxonomies/:name?locale=xx, which can set either field on one locale only.
B — menu item translation linkage destroyed
Two locales configured; menu primary with items in en, translated to es via POST /_emdash/api/menus with translationOf (items are cloned with their groups intact).
Call the MCP menu_set_items tool on primary.
SELECT id, translation_group FROM _emdash_menu_items WHERE menu_id = '<primary en>' → every translation_group is NULL.
emdash export-seed → no translationOf on any item; re-importing mints a fresh group per item, so the en/es correspondence is gone.
Expected: setItems writes translation_group like every other insert path.
Translation-create resets collections/hierarchical; update writes them to one locale's row. The handleTaxonomyUpdate docblock claims "every field here belongs to a single locale's definition", which isn't true for those two. taxonomies/index.ts:390-393 already documents the drift ("per-locale rows of the same def can drift in their declared collections") and works around it by folding the collection scope into a cache key.
1b
Term tree collapses — handlers/taxonomies.ts:146, :608
handleTermList reads hierarchical from requireTaxonomyDef(name) with no locale, which takes the alphabetically first locale's row. Once a drifted translation exists at a locale code sorting before the source's (ar before en), every GET /_emdash/api/taxonomies/{name}/terms returns the tree flat, at every locale — including the source. Admin-UI-reachable: TaxonomySidebar term picker (fetchTerms, TaxonomySidebar.tsx:86). Verified as C above.
Both accept input.name freely alongside translationOf, with no equality check against the source. Terms are keyed on taxonomies.name, so an es def named categoria sharing a group with en's category orphans every es term. Menus are fetched by findByName(name, locale), so a divergent name makes the translation unreachable.
3
Menu item identity — menu.ts:601-615, export-seed.ts:594
setItems inserts with translation_group unset (nullable, no default). Invisible at runtime; destroys cross-locale item identity on the next export → import round-trip.
4
Junction tables disagree on what they key — content_taxonomies.entry_id, _emdash_content_references.parent_group/child_group vs _emdash_content_bylines.content_id
The first two store the entry's translation_group, so one assignment covers every locale. The third stores a row id and is duplicated per translation by copyContentBylines. On the same post, editing categories affects every locale while editing credits affects one.
Pivots are locale-agnostic; hydration is strict per locale by design. getTermsForEntry drops terms "whose translation_group lacks a row in the requested locale", and 040 states the same for bylines ("a credit at locale X renders iff a byline row exists at locale X… There is no read-time fallback"). An untranslated term simply vanishes from the es site, with nothing in the data distinguishing that from "deliberately uncategorised". #2508 (open) narrows this: public hydration falls back preferred → default locale inside the folded query, and the admin terms route returns availableLocales with an inline create-translation action. The vanish case shrinks to terms present in neither locale, and the underlying question — what a locale-neutral assignment means at render time — is answered by policy rather than by the model.
Reachability, for triage: 1 and 2 are REST/MCP/SDK surfaces (the admin only sends translationOf for content); 3 is MCP-only; 1b, 4 and 5 are reachable from the admin UI with no unusual steps.
Root cause: no owner for identity-level fields
The model was copied from content entries (019) to six system tables: taxonomies, _emdash_taxonomy_defs, _emdash_menus, _emdash_menu_items (036), _emdash_bylines (040), and _emdash_relations (043).
For content it holds: every column of an ec_* row is per-locale, and translation_group is pure linkage. For these six it doesn't. Most of their columns describe the identity — _emdash_taxonomy_defs.hierarchical/collections, _emdash_menus.name, _emdash_menu_items.type/reference_id, _emdash_bylines.user_id — and only one or two are genuinely translatable (label, bio). Those identity columns are stored once per locale row with nothing keeping the copies in agreement.
Nothing enforces the invariant.UNIQUE(name, locale), UNIQUE(slug, locale) and (translation_group, locale) all constrain the row, never the group. There is no check, no reconciliation pass, and no admin surface that shows two locales disagreeing.
Three different mitigations already exist, each invented once:
fan-out on write — TaxonomyRepository.update (parent_id, sort_order) and applyPositions. The only group-scoped UPDATEs in the codebase.
immutable structural fields — _emdash_relations; relation.ts:39-40 records the rest as "a cross-group operation deferred to a later slice".
a group-keyed side table — _emdash_byline_field_group_values plus a per-field translatable flag (042).
Everywhere else, writes are row-scoped and drift silently.
The decision this needs
Fallouts 1, 4 and 5 can't be fixed at the call site — patching handleTaxonomyCreate to inherit collections picks an answer to "is the entity the row or the group?" without stating it, and the next feature re-litigates it. That has already happened three times: 056 invented group fan-out for sort_order, 043 declared structural fields immutable and deferred the question, 042 built a second value table rather than answer it for the base columns. Every new column on these six tables is a fresh judgment call with no stated rule and no test that can catch getting it wrong.
Option 1 — the row is the entity (status quo, made explicit). Document that each locale owns its own structural fields; fix create to inherit from the source so the defaults stop winning; add a reconciliation view. Cheapest, no migration, but drift stays reachable and fallout 5 keeps its ambiguity.
Option 2 — the group is the entity, enforced by fan-out on write. Extend the existing TaxonomyRepository.update pattern to every identity column on all six tables. No schema change, but correctness depends on every writer remembering, and no constraint catches a missed one — which is how fallout 3 happened.
Option 3 — the group is the entity, enforced by storage. Per-field translatable flag plus a group-keyed table, as 042 already does for byline fields, extended to the base columns. Structurally prevents drift and collapses the per-locale read fallback (below), but it's the largest migration.
Two things any option has to answer:
What happens to data that has already drifted? Migrations are forward-only, and divergence is unrepairable after the fact: translation_group = id is a backfill convention, not a constraint, and the anchor row is freely deletable (TaxonomyRepository.delete:468, BylineRepository.delete:866, MenuRepository.delete:403 each drop one row and only cascade when it is the last sibling). Once it's gone there's no principled way to decide which surviving locale held the intended value.
Does it fix the logged-out read cost? Today, finding "the right row" is a search — one sequential query per locale down the fallback chain (menus/index.ts:71, taxonomies/index.ts:238, :258, :453, bylines/index.ts:102), and the common case (locale requested, translation absent) walks the whole chain before finding anything. Caching multiplies rather than amortises: getMenu keys on ${name}:${locale}, getTaxonomyDefs on defs:${localeKey}, so N locales hold N entries and pay N cold misses for structural data meant to be identical. Per CLAUDE.md the logged-out hot path only ratchets down, so option 3 is the only one that improves it; options 1 and 2 leave it as-is. fix: preserve taxonomy assignments across locale fallbacks #2508 (open) folds the entry-terms fallback into the existing query with logged-out counts unchanged, which settles this for that one path; the menu and byline chains and the per-locale cache keys are untouched.
Options 2 and 3 are breaking for anyone relying on per-locale structural fields today, so they'd need a package bump and a changeset calling the break out.
Fixable now, independent of that decision
Fallouts 2 and 3 are small, self-contained and unambiguous — nothing about the row-vs-group question changes what they should do:
2 — reject name mismatching the source when translationOf is present (or ignore it and copy the source's), in handleTaxonomyCreate and handleMenuCreate.
3 — setItems writes translation_group like every other insert path.
Environment
emdash version: 0.33.0 (packages/core, main @ 14017995)
Node.js version: v26.1.0
Runtime: both (SQLite/Node and D1/Workers — the model is dialect-independent)
Description
Translating a taxonomy silently resets the fields that describe it. Creating the Spanish translation exactly the way the API documents it —
POST /_emdash/api/taxonomieswithtranslationOfand a translatedlabel— leaves theesdef declaringcollections: []andhierarchical: false, becausehandleTaxonomyCreatedefaults both (handlers/taxonomies.ts:322,:370) and never inherits them from the source.getCollectionTaxonomyNamesthen filters defs bycollectionson the locale-resolved row, so the taxonomy applies topostsinenand not ines, and a hierarchical taxonomy comes out flat in translation.No unusual input is required. Omitting fields that describe the taxonomy rather than one locale's presentation of it is the happy path — there is nothing to send, and the defaults win.
Separately, one call to the MCP
menu_set_itemstool NULLstranslation_groupon every item in the menu.MenuRepository.setItemsis the only insert path that never writes the column (menu.ts:601-615);createItem, the menu-clone path andseed/apply.ts:1137all set it. Nothing reads it at runtime, so nothing looks wrong — untilemdash export-seed(export-seed.ts:594reads it to emittranslationOf), at which point export → re-import mints a fresh group per item and theen/escorrespondence is gone.Both are the same root cause, and it is not confined to these two call sites: the row-per-locale +
translation_groupmodel was copied from content entries (019) to six system tables, and on those six nothing owns the fields that identify the thing. Details in Root cause below.Verified
Against
main@14017995(packages/core0.33.0) with an integration test driving the real handlers on SQLite:endef:collections = ["post"],hierarchical = 1. Itsestranslation, sametranslation_group:collections = "[]",hierarchical = 0.setItemswith two items, both rows havetranslation_group = NULL.locales: ["ar", "en"], translating a hierarchicalentaxonomy toarleavesarathierarchical = 0.requireTaxonomyDef(handlers/taxonomies.ts:146) resolves without a locale usingorderBy("locale", "asc")+executeTakeFirst, so thearrow wins andhandleTermListreturns the tree flattened — a parent and its child both come back top-level — for a request atlocale=en. Control run without theartranslation returns the correct nesting.The model is dialect-independent; D1/Workers behaves the same.
Steps to reproduce
A — structural drift on the happy path (no divergent input required)
locales: ["en", "es"].POST /_emdash/api/taxonomies→{ "name": "topic", "label": "Topics", "hierarchical": true, "collections": ["posts"] }POST /_emdash/api/taxonomies→{ "name": "topic", "label": "Temas", "locale": "es", "translationOf": "<id from step 2>" }SELECT locale, collections, hierarchical FROM _emdash_taxonomy_defs WHERE name = 'topic'→enkeeps["posts"]/1,esgets[]/0, both on the sametranslation_group.getEmDashCollection("posts", { locale: "es" })→ entries carry notopicterms. The same call atendoes.Expected:
collectionsandhierarchicaldescribe the taxonomy, not one locale's presentation of it, so they should be inherited (or shared) rather than silently reset. The same applies toPUT /_emdash/api/taxonomies/:name?locale=xx, which can set either field on one locale only.B — menu item translation linkage destroyed
primarywith items inen, translated toesviaPOST /_emdash/api/menuswithtranslationOf(items are cloned with their groups intact).menu_set_itemstool onprimary.SELECT id, translation_group FROM _emdash_menu_items WHERE menu_id = '<primary en>'→ everytranslation_groupis NULL.emdash export-seed→ notranslationOfon any item; re-importing mints a fresh group per item, so theen/escorrespondence is gone.Expected:
setItemswritestranslation_grouplike every other insert path.Fallouts
handlers/taxonomies.ts:322,:370,:444collections/hierarchical; update writes them to one locale's row. ThehandleTaxonomyUpdatedocblock claims "every field here belongs to a single locale's definition", which isn't true for those two.taxonomies/index.ts:390-393already documents the drift ("per-locale rows of the same def can drift in their declared collections") and works around it by folding the collection scope into a cache key.handlers/taxonomies.ts:146,:608handleTermListreadshierarchicalfromrequireTaxonomyDef(name)with no locale, which takes the alphabetically first locale's row. Once a drifted translation exists at a locale code sorting before the source's (arbeforeen), everyGET /_emdash/api/taxonomies/{name}/termsreturns the tree flat, at every locale — including the source. Admin-UI-reachable:TaxonomySidebarterm picker (fetchTerms,TaxonomySidebar.tsx:86). Verified as C above.handleTaxonomyCreate,handleMenuCreateinput.namefreely alongsidetranslationOf, with no equality check against the source. Terms are keyed ontaxonomies.name, so anesdef namedcategoriasharing a group withen'scategoryorphans everyesterm. Menus are fetched byfindByName(name, locale), so a divergent name makes the translation unreachable.menu.ts:601-615,export-seed.ts:594setItemsinserts withtranslation_groupunset (nullable, no default). Invisible at runtime; destroys cross-locale item identity on the next export → import round-trip.content_taxonomies.entry_id,_emdash_content_references.parent_group/child_groupvs_emdash_content_bylines.content_idtranslation_group, so one assignment covers every locale. The third stores a row id and is duplicated per translation bycopyContentBylines. On the same post, editing categories affects every locale while editing credits affects one.taxonomy.ts:550, migration 040getTermsForEntrydrops terms "whosetranslation_grouplacks a row in the requested locale", and 040 states the same for bylines ("a credit at locale X renders iff a byline row exists at locale X… There is no read-time fallback"). An untranslated term simply vanishes from theessite, with nothing in the data distinguishing that from "deliberately uncategorised". #2508 (open) narrows this: public hydration falls back preferred → default locale inside the folded query, and the admin terms route returnsavailableLocaleswith an inline create-translation action. The vanish case shrinks to terms present in neither locale, and the underlying question — what a locale-neutral assignment means at render time — is answered by policy rather than by the model.Reachability, for triage: 1 and 2 are REST/MCP/SDK surfaces (the admin only sends
translationOffor content); 3 is MCP-only; 1b, 4 and 5 are reachable from the admin UI with no unusual steps.Root cause: no owner for identity-level fields
The model was copied from content entries (019) to six system tables:
taxonomies,_emdash_taxonomy_defs,_emdash_menus,_emdash_menu_items(036),_emdash_bylines(040), and_emdash_relations(043).For content it holds: every column of an
ec_*row is per-locale, andtranslation_groupis pure linkage. For these six it doesn't. Most of their columns describe the identity —_emdash_taxonomy_defs.hierarchical/collections,_emdash_menus.name,_emdash_menu_items.type/reference_id,_emdash_bylines.user_id— and only one or two are genuinely translatable (label,bio). Those identity columns are stored once per locale row with nothing keeping the copies in agreement.Nothing enforces the invariant.
UNIQUE(name, locale),UNIQUE(slug, locale)and(translation_group, locale)all constrain the row, never the group. There is no check, no reconciliation pass, and no admin surface that shows two locales disagreeing.Three different mitigations already exist, each invented once:
TaxonomyRepository.update(parent_id,sort_order) andapplyPositions. The only group-scopedUPDATEs in the codebase._emdash_relations;relation.ts:39-40records the rest as "a cross-group operation deferred to a later slice"._emdash_byline_field_group_valuesplus a per-fieldtranslatableflag (042).Everywhere else, writes are row-scoped and drift silently.
The decision this needs
Fallouts 1, 4 and 5 can't be fixed at the call site — patching
handleTaxonomyCreateto inheritcollectionspicks an answer to "is the entity the row or the group?" without stating it, and the next feature re-litigates it. That has already happened three times: 056 invented group fan-out forsort_order, 043 declared structural fields immutable and deferred the question, 042 built a second value table rather than answer it for the base columns. Every new column on these six tables is a fresh judgment call with no stated rule and no test that can catch getting it wrong.Option 1 — the row is the entity (status quo, made explicit). Document that each locale owns its own structural fields; fix create to inherit from the source so the defaults stop winning; add a reconciliation view. Cheapest, no migration, but drift stays reachable and fallout 5 keeps its ambiguity.
Option 2 — the group is the entity, enforced by fan-out on write. Extend the existing
TaxonomyRepository.updatepattern to every identity column on all six tables. No schema change, but correctness depends on every writer remembering, and no constraint catches a missed one — which is how fallout 3 happened.Option 3 — the group is the entity, enforced by storage. Per-field
translatableflag plus a group-keyed table, as 042 already does for byline fields, extended to the base columns. Structurally prevents drift and collapses the per-locale read fallback (below), but it's the largest migration.Two things any option has to answer:
translation_group = idis a backfill convention, not a constraint, and the anchor row is freely deletable (TaxonomyRepository.delete:468,BylineRepository.delete:866,MenuRepository.delete:403each drop one row and only cascade when it is the last sibling). Once it's gone there's no principled way to decide which surviving locale held the intended value.menus/index.ts:71,taxonomies/index.ts:238,:258,:453,bylines/index.ts:102), and the common case (locale requested, translation absent) walks the whole chain before finding anything. Caching multiplies rather than amortises:getMenukeys on${name}:${locale},getTaxonomyDefsondefs:${localeKey}, so N locales hold N entries and pay N cold misses for structural data meant to be identical. PerCLAUDE.mdthe logged-out hot path only ratchets down, so option 3 is the only one that improves it; options 1 and 2 leave it as-is. fix: preserve taxonomy assignments across locale fallbacks #2508 (open) folds the entry-terms fallback into the existing query with logged-out counts unchanged, which settles this for that one path; the menu and byline chains and the per-locale cache keys are untouched.Options 2 and 3 are breaking for anyone relying on per-locale structural fields today, so they'd need a package bump and a changeset calling the break out.
Fixable now, independent of that decision
Fallouts 2 and 3 are small, self-contained and unambiguous — nothing about the row-vs-group question changes what they should do:
namemismatching the source whentranslationOfis present (or ignore it and copy the source's), inhandleTaxonomyCreateandhandleMenuCreate.setItemswritestranslation_grouplike every other insert path.Environment
packages/core,main@14017995)