-
Notifications
You must be signed in to change notification settings - Fork 26
docs: add ADR for taxonomy_type field, amend ADR 0002 #662
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
mgwozdz-unicon
wants to merge
1
commit into
openedx:main
Choose a base branch
from
mgwozdz-unicon:cbe-taxonomy-type-field-adr
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
132 changes: 132 additions & 0 deletions
132
docs/openedx_tagging/decisions/0013-taxonomy-type-field.rst
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,132 @@ | ||
| .. _openedx-tagging-adr-0013: | ||
|
|
||
| 13. Base-model ``taxonomy_type`` field for Competency Taxonomies | ||
| ================================================================== | ||
|
|
||
| Status | ||
| ------ | ||
|
|
||
| Proposed | ||
|
|
||
| Context | ||
| ------- | ||
|
|
||
| The taxonomy Get endpoints (``TaxonomySerializer``) need to report whether a taxonomy is | ||
| a Competency Taxonomy, so that Studio can badge Competency Taxonomies and gate access to | ||
| the Competency Management page (#618), symmetrically with the Create/Import endpoint's | ||
| ``taxonomy_type`` field (#614). ``CompetencyTaxonomy`` is a Django multi-table-inheritance | ||
| subclass of ``Taxonomy`` (``CompetencyTaxonomy(Taxonomy)``), owned by the CBE applet and | ||
| defined in `ADR 0002 <../../openedx_learning/decisions/0002-competency-criteria-model.rst>`_. | ||
|
|
||
| ``openedx_tagging`` is a generic tagging library with no knowledge of any specific taxonomy | ||
| flavor built on top of it, CBE or otherwise, and must not gain any: a specific downstream | ||
| applet's model or relation name has no business appearing in this library's source, since | ||
| that reverses the intended dependency direction (CBE depends on ``openedx_tagging``, not | ||
| the other way around) and would break for any Open edX install running ``openedx_tagging`` | ||
| without CBE installed. | ||
|
|
||
| Two approaches were tried on #618 and found unsound; see Rejected Alternatives. | ||
|
|
||
| This decision does not reopen `ADR 0002 <../../openedx_learning/decisions/0002-competency-criteria-model.rst>`_'s | ||
| rejection of a flat ``taxonomy_type`` column as an alternative to multi-table inheritance. | ||
| That rejection's deciding reason was that a flat column can't give other CBE tables a | ||
| real, database-enforced foreign key to specifically a competency-enabled taxonomy; | ||
| ``CompetencyRuleProfile.competency_taxonomy_id`` relies on that guarantee. That reasoning | ||
| is independent of whether ``CompetencyTaxonomy`` currently defines any columns of its own | ||
| beyond the parent link, and is unaffected by this decision. This decision addresses a | ||
| narrower, later problem ADR 0002 does not cover: a lightweight type label for | ||
| ``openedx_tagging``'s own generic REST API. | ||
|
|
||
| Decision | ||
| -------- | ||
|
|
||
| Add a ``taxonomy_type`` field directly to ``Taxonomy``: | ||
|
|
||
| - A ``TaxonomyType(models.TextChoices)`` enum with values ``TAGS`` (default) and | ||
| ``COMPETENCY``, shared between this field and #614's write-side ``ChoiceField`` so the | ||
| two can never drift apart. | ||
| - Set explicitly by whichever code creates the row. The code that creates a | ||
| ``CompetencyTaxonomy`` row (#614) also sets ``taxonomy_type=TaxonomyType.COMPETENCY`` on | ||
| the same ``Taxonomy`` row, in the same transaction that creates both rows (the existing | ||
| lifecycle rule in ADR 0002 Decision 1). ``openedx_tagging`` never inspects, imports, or | ||
| names ``CompetencyTaxonomy`` or any relation to it anywhere in its own source; the field's | ||
| value is opaque to it and owned entirely by the caller. | ||
| - Immutable after creation, mirroring the scope-immutability precedent for | ||
| ``CompetencyRuleProfile`` in ADR 0002 Decision 3: a taxonomy's type does not change over | ||
| its lifetime. | ||
| - Existing taxonomies are backfilled to ``TaxonomyType.TAGS`` via an additive migration; | ||
| none has a ``CompetencyTaxonomy`` row today, since CBE has no production data yet. | ||
| - Exposed read-only on ``TaxonomySerializer`` (#618), returning the field's value directly. | ||
|
|
||
| **Known tradeoff.** This creates two facts that must stay in sync: the field's value, and | ||
| whether a ``CompetencyTaxonomy`` row actually exists for that taxonomy. This is accepted | ||
| because it is enforced at a single transactional chokepoint (the creation path for a | ||
| ``CompetencyTaxonomy``), not as an ongoing invariant that application code must maintain | ||
| across multiple call sites. | ||
|
|
||
| Future considerations | ||
| ~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| If a third taxonomy flavor is introduced later, it extends this same field and enum with a | ||
| third value; no schema redesign is implied. This field is not a general-purpose | ||
| polymorphism mechanism and does not preclude one being added separately if a future need | ||
| requires it. | ||
|
|
||
| Rejected Alternatives | ||
| ---------------------- | ||
|
|
||
| Check for a related ``CompetencyTaxonomy`` row directly inside ``openedx_tagging`` | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| The original #618 proposal: ``hasattr(instance, "competencytaxonomy")`` inside | ||
| ``openedx_tagging``'s serializer. Rejected because it hardcodes a specific downstream | ||
| applet's multi-table-inheritance relation name into a standalone, generic library, which | ||
| is exactly the reverse-dependency problem this decision exists to avoid. | ||
|
|
||
| An overridable ``Taxonomy.get_type()`` method | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| The follow-up #618 proposal: a base ``Taxonomy.get_type()`` method returning ``"tags"``, | ||
| overridden by ``CompetencyTaxonomy`` to return ``"competency"``, mirroring the existing | ||
| ``Taxonomy.system_defined`` / ``SystemDefinedTaxonomy`` base/override shape. Rejected for | ||
| two independent reasons: | ||
|
|
||
| - That base/override shape is implemented via ``Taxonomy._taxonomy_class`` and | ||
| ``Taxonomy.cast()``/``Taxonomy.copy()``, which #634 (Remove Taxonomy Subclasses) removes. | ||
| - Independent of #634: querying taxonomies the normal way (``Taxonomy.objects.all()``) | ||
| returns plain ``Taxonomy`` instances, so a subclass method override is never reached | ||
| without an explicit cast step first. The existing ``.cast()``/``.copy()`` implementation | ||
| would not correctly perform that cast for a true multi-table-inheritance subclass in any | ||
| case: it copies a hardcoded list of base ``Taxonomy`` field values in Python and never | ||
| queries the subclass's own table, so it would silently produce a ``CompetencyTaxonomy`` | ||
| instance with unset or wrong subclass-specific fields. | ||
|
|
||
| Frontend-only: no backend field | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| Studio determines type by combining ``openedx_tagging``'s generic taxonomy list with a | ||
| separate, CBE-owned endpoint client-side. Rejected because it gives no answer to any | ||
| in-process backend consumer of this published library (other apps, plugins, management | ||
| commands), only to whichever frontend chooses to make two calls and join them, and it | ||
| pushes the integration cost of that join onto every future consumer rather than paying it | ||
| once. | ||
|
|
||
| A settings-configured resolver function | ||
| ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ | ||
|
|
||
| A Django setting pointing at a dotted import path to a resolver function, owned by CBE, | ||
| that ``Taxonomy.get_type()`` calls if configured. This would have kept the type-detection | ||
| logic entirely out of ``openedx_tagging``'s own source. Rejected because it is a new, | ||
| bespoke, ad-hoc hook into ``openedx_tagging``, when this project's established pattern for | ||
| a cross-app extension point, if one is genuinely needed, is ``openedx-events``/ | ||
| ``openedx-filters`` rather than a one-off settings hook. Reaching for that established | ||
| pattern instead would mean depending on and wiring up a plugin/event framework across | ||
| repos for a field with exactly two known values today -- more machinery than either this | ||
| alternative or the field approach chosen here, not less. | ||
|
|
||
| Changelog | ||
| --------- | ||
|
|
||
| 2026-07-16: | ||
|
|
||
| * Proposed. | ||
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'm not sure about this.
So we're still hard-coding a reference to
COMPETENCYin thetaggingapp, and we now have to ensure that we keep the field's value in sync with whether or not aCompetencyTaxonomyactually exists.Plus, per the ADR 614, we have to update a whole bunch of other tagging REST APIs to support the
taxonomy_typefield.If we're going to hard-code some reference to Competencies in the tagging app, I think what I'm leaning toward is something radically simpler:
Put this in the taxonomy list/get serializer, and that's it - no other changes needed to the taxonomy app.
REST APIs: I don't think there's any need to change the tagging REST APIs. To create a competency taxonomy from the frontend, you could either (A) call the "create taxonomy" REST API and on succes, call the "create competency metadata for taxonomy" REST API (from the
cbeapp), or (B) call a competency REST API that creates theTaxonomyand theCompetencyTaxonomyobject together in a single request.Export: You could add the
taxonomy_typefield to the JSON export data if that seems useful. It's not possible to include on CSVs.Import: If the user chooses to import something as a Competency taxonomy (in the frontend UI), it's the same thing: import the taxonomy then call a second API to convert it to Competency.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Going through in order:
On the sync-drift point: yes, I agree that this tradeoff is the risk, but hopefully it can be resolved at implementation time.
On the REST API surface per #614: I don't think ADR 13 is adding work there. Those endpoints need to accept
taxonomy_typeon create/import regardless of how the Get side reports it, so that's #614's scope either way, not something this ADR introduces on top.On the
hasattrsuggestion: I hear the appeal, it's less code today, and I get that you're framing it as a deliberate, called-out layering violation rather than an accidental one. Thinking it through more, I don't think either approach avoids touchingopenedx_taggingwhen a new type shows up eventually, the field approach still means adding a value to the shared enum. Where I think they differ is what that touch requires: with the field, adding a type is adding a string to a catalog, the read path (TaxonomySerializer) doesn't change, it already just returns whatever's stored. Withhasattr, adding a type means adding another branch to the serializer's logic that names a specific downstream app's MTI relation, so the read path itself keeps growing a fact about another app's model internals with every flavor. That feels like the thing the ADR is trying to keep out of a generic library, holding the type as a piece of data the caller owns, versus holding it as code that inspects other apps' structure. Also worth flagging: this looks close to option 2 from your comment on #618 (Adding a tiny bit of Competency awareness to the base tagging app), where you said you leaned toward 1+3 over it. Did something change your thinking there, or is this meant as a variant of option 1 rather than 2?On separate Competency REST APIs: Since the Taxonomies landing page is meant to serve both flows, I don't think it makes sense to make the frontend juggle two calls, especially when this could become even more calls than that if the number of taxonomy types increases.
On export/import: agreed, that's already settled in #614 and not really in scope here.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yeah, sorry, I'm still not happy with any of the approaches here so I'm just throwing out ideas to see if there's anything that you like. I'm not so unhappy that I'm going to block anything though. I'm reluctantly fine with any of these approaches, but I want to see if we can come up with something clean that doesn't have any layering violations.
Thinking about this more, what if we kept the taxonomy code in
openedx-core/oel_taggingcompletely free of competency awareness, but use the platform's override of the REST API atopenedx/core/djangoapps/content_tagging/rest_api/v1/to inject the competency pieces you need in the REST API? Then you could addtaxonomy_type = "competency" if hasattr(instance, "competencytaxonomy") else "tags"to the serializer and also wire through the "competency creation" changes to the REST API specified in #614 while still keeping some semblance of layer separation, because the core tagging API is unchanged.So the layers would be:
oel_taggingpython API & REST APIs in this repo - pure tagging/taxonomy only ✅content_taggingpython API in openedx-platform - pure tagging/taxonomy only ✅ (as specified on [BE] Update taxonomy Create & Import endpoints to accept Taxonomy Type #614 , "Layering constraint. openedx_tagging/api.py::create_taxonomy() creates only a plain Taxonomy. It must not import or call anything from openedx_learning. The CompetencyTaxonomy creation belongs exclusively in the CBE applet.")content_taggingREST API in openedx-platform (openedx/core/djangoapps/content_tagging/rest_api/v1/) - injects and handles thetaxonomy_typefield (not persisted in the database), which gets routed to the competency python API as needed.At least in that case there are clean boundaries drawn in several places that completely respect the layering.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I had Claude create the following comparison table and diagrams to help me think through our options more clearly, with your most recent proposal added as option 4.
I think Option 1 (currently in this ADR) and your option 4 (the router in openedx-platform) are the two strongest contenders at this point. I'm not opposed to going with option 4 if you feel it fits Open edX's existing patterns better and is worth it to avoid an explicit
COMPETENCYvalue in the tagging app's enum.I think there are two bigger-picture questions underneath this:
If someone runs Open edX without using any CBE features (as many people probably will), is this okay? Jenna and I have already been marketing this CBE initiative as "built into the core, not an add-on," so I think it's completely fair to accept a small trade-off where every Open edX instance carries a tiny bit of CBE awareness even if that instance never uses the feature. On this question, I'd put options 1 and 4 on relatively equal ground, both are small, harmless trade-offs for non-CBE users, though option 1's is arguably a bit lighter since it's inert stored data, while option 4 runs a small check on every request.
If someone wants to add another taxonomy type, is this okay? This situation might actually be pretty unlikely, especially given our recent conversation about getting rid of system-level taxonomies, but given how pluggable Open edX is meant to be, I still think it's a fair question to want a good answer for. Here the two options aren't equal: option 1 is a one-line addition to the model, a new enum value, and the serializer never changes. Option 4 is a new branch added directly into openedx-platform's shared serializer, plus a matching
select_relatedaddition to that view's queryset.Given how close options 1 and 4 are, I think I'd like to bring this to Tuesday's architecture meeting rather than trying to settle it here in the thread. Let me know if that works for you, or if you'd rather wait for Dave, or if one option is starting to stand out to you as more of a clear winner. Also let me know if you have different thinking around the comparison table and what's been proposed than what is outlined below.
Here's the comparison, for reference:
Comparison
taxonomy_typeenum fieldTaxonomygets new columnWHERE- Serializer just returns stored data, no cross-app knowledge
- Already drafted, one migration
- Extensible: a future third taxonomy type is just a new enum value; the read path never changes again
- Needs to be set correctly at creation time
hasattrcheck in serializer- Always accurate, can't drift out of sync
- Needs a join/exists-subquery to filter by type, no plain indexed
WHERE- N+1 risk: one extra query per row when listing taxonomies unless
select_related('competencytaxonomy')is added, and since the check is on the shared base serializer,oel_tagging's own REST API (used by consumers other than openedx-platform) inherits the risk with no equivalent fix- Not extensible: a future third taxonomy type needs another hardcoded branch added directly to
oel_tagging's shared serializer- Already deprioritized once on #618 (1+3 was favored then); revived without stated reasoning
- Partial-failure handling (taxonomy created, competency conversion fails)
- Already rejected for UX reasons
- Frontend still needs both the plain "create taxonomy" call and this combined call, and has to pick between them since nothing tells it upfront whether a Tags or Competency taxonomy is being created
- That branching arguably relocates the type-awareness into the frontend rather than removing it, though it wouldn't formally be called a layering violation there
oel_taggingand CBE app both stay fully pure- One existing endpoint, no new REST surface
- Matches this repo's real rule: openedx-core must never depend on openedx-platform, not the reverse
- Verified against the actual code:
TaxonomyOrgViewalready fully overridesget_queryset()andTaxonomyOrgSerializeralready extends the base fields, so both the check and itsselect_relatedfix stay entirely in openedx-platform, zero footprint onoel_taggingor its other consumers- A deployment that never installs the CBE app is completely unaffected, since
oel_taggingitself never mentions competencies- Same N+1 risk, but confined to openedx-platform's own endpoint, and fixable there without touching
oel_tagging- Every openedx-platform deployment carries this hardcoded check permanently in its shared
content_tagginglayer, whether or not that operator ever enables CBE, harmless at runtime, but not absent- Not extensible: same as option 2, a future third taxonomy type needs another hardcoded branch, just added to openedx-platform's shared serializer instead of
oel_tagging'sLegend: 🟩 pure/unchanged · 🟧 competency-specific · 🟦 integration layer · 🟥 violation/hardcoding · 🟨 schema change
Option 1 — persisted enum field
flowchart TD FE[Studio MFE] subgraph PLAT[openedx-platform] P1[REST API] end subgraph CORE[openedx-core: oel_tagging] C1[Serializer] end subgraph CBEAPP[openedx-core: CBE app] B1[CBE API] end subgraph DB[Database] T[("Taxonomy<br/>+ taxonomy_type")] CT[(CompetencyTaxonomy)] end FE --> P1 --> C1 C1 -->|reads column| T B1 -->|writes on create| CTOnly option where the
Taxonomytable's schema changes. Serializer just reads a plain column, no cross-app lookup at read time.Option 2 — hasattr in oel_tagging's serializer
flowchart TD FE[Studio MFE] subgraph PLAT[openedx-platform] P1[REST API] end subgraph CORE[openedx-core: oel_tagging] C1["Serializer<br/>hasattr check"] end subgraph CBEAPP[openedx-core: CBE app] B1[CBE API] end subgraph DB[Database] T[(Taxonomy<br/>unchanged)] CT[(CompetencyTaxonomy)] end FE --> P1 --> C1 C1 -->|reads| T C1 -.->|"exists? (per request)"| CT B1 --> CTTaxonomytable is untouched. The generic serializer instead reaches across to check forCompetencyTaxonomy's existence on every read, that cross-app relation name is the violation.Option 3a — two-call frontend create flow
flowchart TD FE[Studio MFE] subgraph PLAT[openedx-platform] P1[REST API] end subgraph CORE[openedx-core: oel_tagging] C1[create_taxonomy] end subgraph CBEAPP[openedx-core: CBE app] B0[existing conversion call] end subgraph DB[Database] T[(Taxonomy<br/>unchanged)] CT[(CompetencyTaxonomy)] end FE -->|"call 1: create taxonomy"| P1 --> C1 --> T FE -->|"call 2: convert to competency"| B0 --> CTSchema is unchanged. The frontend makes two sequential calls and has to handle the case where the first succeeds but the second fails.
Option 3b — new combined competency REST API
flowchart TD FE["Studio MFE<br/>picks a call based on taxonomy type"] subgraph PLAT[openedx-platform] P1[REST API: create taxonomy] end subgraph CORE[openedx-core: oel_tagging] C1[create_taxonomy] end subgraph CBEAPP[openedx-core: CBE app] B0N[new combined endpoint] end subgraph DB[Database] T[(Taxonomy<br/>unchanged)] CT[(CompetencyTaxonomy)] end FE -->|"Tags: plain call"| P1 --> C1 --> T FE -->|"Competency: combined call"| B0N B0N --> T B0N --> CTSchema is unchanged. One new endpoint handles both tables in one request, but the frontend still needs both this call and the plain create-taxonomy call, and has to choose between them since nothing upstream tells it whether a Tags or Competency taxonomy is being created. That's the type-awareness moving into the frontend rather than disappearing.
Option 4 — router in openedx-platform's REST view
flowchart TD FE[Studio MFE] subgraph PLAT["openedx-platform (router lives here)"] P1["Serializer: adds taxonomy_type<br/>via hasattr, on the same instance"] end subgraph CORE[openedx-core: oel_tagging] C1[Base Taxonomy query/serializer, stays pure] end subgraph DB[Database] T[(Taxonomy<br/>unchanged)] CT[(CompetencyTaxonomy)] end FE --> P1 P1 -->|delegates for the base object| C1 C1 --> T P1 -.->|"hasattr check on that<br/>same object, no new API call"| CToel_taggingstays untouched and fetches plainTaxonomyrows as it always has, same schema as options 2/3. openedx-platform's serializer runs one extra check on that same already-fetched instance, no second endpoint or separate CBE call needed for GET, since the two tables are already joinable at the database/ORM level. Query-filtering cost is identical to option 2. The already-decided #614 create-flow wiring is left out of this diagram since it applies the same way regardless of which GET option is chosen.The dotted line from openedx-platform's serializer to
CompetencyTaxonomyisn't a network call, a Python import, or a call into any CBE API. It's Django exposing an existing database join: becauseCompetencyTaxonomyis defined as a subclass ofTaxonomy, the two tables are already linked at the schema level, and any code in the same process, including openedx-platform's serializer, can ask "does a matching row exist in the other table?" without ever referencing CBE's code.There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, this is super helpful! Let's discuss on Tuesday.
I think we both agree that any of these approaches are all OK in this case, as the CBE feature has a pretty small footprint on the overall tagging/taxonomy code and UI.
I think this is a really important question and it's what I'm most concerned about. If we set a precedent that taxonomies are a foundational layer and other things can be built on top of them without modifying the taxonomy code or UI, I think there's no problem at all. People can build whatever they imagine on top of tagging and taxonomies. But in the case of something like CBE, I would prefer to see such features built as separate apps, with their own UI, and not "built in" to the tagging/taxonomy code.
We're instead setting the opposite precedent: integrating a new feature with taxonomies involves adding some code directly to the taxonomies backend and integrating custom UI components into the tagging frontend as well.
So we're neither building a generic plugin API nor maintaining a clean abstraction/layering, but instead introducing a bit of coupling of competency + taxonomy app in a few places. (Exactly which places depends on which approach from the options above, but it seems like product folks want the UI integrated in any case.)
So: I think this is reasonable if competencies is the only special "type" of taxonomy that we or anyone else will have in the platform for the foreseeable future. But if we expect other use cases, then we should set a cleaner architectural precedent that builds a proper plugin API for integration (e.g. allows you to inject components into the tagging/taxonomy UI, and register new taxonomy types dynamically) or just builds on top of taxonomies without modifying them at all (e.g. fully separate frontend, and REST APIs, layering respected).