Define the category taxonomy #26

Closed
opened 2026-08-02 03:56:02 +00:00 by christian · 3 comments
Owner

Question

Surfaced by the visibility resolution, where category turned out to be load-bearing in three
places at once: eligibility is scoped to (retailer, vendor, category), group rules select over
it, and vendor-to-RFP matching will operate on it.

The tension is that a shared taxonomy is what makes the cross-retailer rail work — one profile
reaching many buyers only means something if "Produce" means the same thing to Walmart and Kroger —
while retailers already run their own internal category structures and will not abandon them.

Resolve: whether categories are a shared taxonomy, retailer-scoped, or a shared spine with
retailer-specific extensions; who may create a category and what stops the set sprawling; how an
existing retailer taxonomy is mapped in at onboarding; whether a category is a flat list or a
hierarchy, and if hierarchical, whether eligibility inherits down it; and what happens to
eligibility and group rules when a category is renamed, split, or merged.

Note the research finding that no common retail rail exists today precisely because Walmart,
Target, Kroger, and Costco each structure sourcing differently — so a shared taxonomy is an
assertion the product makes, not a fact it can discover.


Parent: #1

## Question Surfaced by the visibility resolution, where category turned out to be load-bearing in three places at once: eligibility is scoped to `(retailer, vendor, category)`, group rules select over it, and vendor-to-RFP matching will operate on it. The tension is that a shared taxonomy is what makes the cross-retailer rail work — one profile reaching many buyers only means something if "Produce" means the same thing to Walmart and Kroger — while retailers already run their own internal category structures and will not abandon them. Resolve: whether categories are a shared taxonomy, retailer-scoped, or a shared spine with retailer-specific extensions; who may create a category and what stops the set sprawling; how an existing retailer taxonomy is mapped in at onboarding; whether a category is a flat list or a hierarchy, and if hierarchical, whether eligibility inherits down it; and what happens to eligibility and group rules when a category is renamed, split, or merged. Note the research finding that no common retail rail exists today precisely because Walmart, Target, Kroger, and Costco each structure sourcing differently — so a shared taxonomy is an assertion the product makes, not a fact it can discover. --- Parent: #1
christian added the
wayfinder:grilling
wayfinder:ticket
labels 2026-08-02 03:56:02 +00:00
christian added a new dependency 2026-08-02 03:56:02 +00:00
christian self-assigned this 2026-08-02 04:42:27 +00:00
Author
Owner

Resolution

Category was doing two incompatible jobs. Retailer-facing, eligibility is scoped to it and group
rules select over it, so it must match the retailer's own world or onboarding is a fight they win by
not adopting. Network-facing, matching and the portable vendor profile run on it, so it must
mean the same thing across retailers or one-profile-many-buyers means nothing.

Four decisions separate those jobs and then keep the seam honest.

1. A shared spine, with retailer-local categories mapped onto it

Each retailer keeps their own category names and structure as retailer-scoped rows. Each maps to
a leaf on a global spine.

  • Eligibility and group rules operate on the retailer's local category — their world, their
    words.
  • Matching, the portable vendor profile, and the acceptance graph operate on the spine.

This is the same resolution as roster entry vs. vendor org, for the same reason: the local thing is
what the customer actually has, the global thing is what makes the network worth joining, and
forcing either to be the other breaks one of them.

Retailers create their own local categories freely — it is their vocabulary, and gatekeeping it
would reintroduce the adoption fight. Each local category should map to a live spine leaf. An
unmapped local category still works locally — a retailer can run an RFP on it, and eligibility
attaches to it normally — it is simply invisible to cross-retailer matching. Same graceful
degradation as an ad-hoc document request in the vault decision: the unusual case never blocks a
sourcing event, it just does not earn network benefit.

Onboarding. A retailer already coded in Ariba or Coupa carries UNSPSC codes, so their mapping is
auto-proposed from those and confirmed rather than authored. A retailer with a homegrown
structure maps by hand against a set small enough to do in a sitting.

What it costs. Mapping is real onboarding work, mappings drift as retailers reorganise, and a bad
mapping degrades matching silently rather than loudly — there is no error, just worse results.
Mapping quality needs to be observable.

2. The spine is curated by us, annotated with UNSPSC

Order of 50–150 nodes, pitched at the level a retailer actually runs a sourcing event at. Each
node carries the UNSPSC families it subsumes, purely as an interop annotation — never as its
identity.

Why not adopt UNSPSC wholesale. Its Family level is too coarse to be a sourcing category and its
Commodity level is far too fine; neither sits where a sourcing category sits. 150k+ codes is a
worse mapping target, not a better one, and it drags in an external reissue cycle that would move
under our eligibility rows.

Why not GS1 GPC. Retail-native and familiar to suppliers, but it classifies items — the wrong
altitude — and has no coverage for services, which the artifacts research found throughout real
solicitations (logistics, facilities, marketing). Half the RFPs would have nowhere to sit.

Same argument as the document catalogue, applied to a second shared vocabulary: its whole value
is that it is small and shared. Self-service fragments it; wholesale adoption drowns it.

What it costs. We own taxonomy politics, and we will be wrong about granularity somewhere — too
coarse and matching is useless, too fine and every retailer maps differently.

3. Two levels; eligibility attaches only to leaves

A shallow group -> category spine. Groups exist for navigation, roll-up reporting, and
match-widening — a vendor qualified in Citrus can be surfaced as a near-fit for a Stone Fruit
RFP because they share a group.

Eligibility, group rules, and the acceptance graph attach only to leaves. Nothing inherits.

Why no inheritance. Eligibility is scoped to category and group rules select over eligibility, so
downward inheritance means a broad approval silently qualifies a vendor for every specialised
niche underneath
— and that error does not stay quiet, it propagates directly into who receives
the next RFP. Under RLS it is worse still: a derived eligibility becomes a recursive closure
evaluated inside a policy, which is slow and hard to defend when an award is protested.

This keeps every authorization-relevant value exact and non-derived.

What it costs. Two levels will be wrong for someone, and a vendor genuinely qualified across a
whole group needs N rows rather than one.

4. Nodes are immutable; temporal validity plus supersession

Nodes are never edited or deleted. Labels may be corrected — a label is not an identity.

node: id, label, group_id, valid_from, valid_to (null = live), superseded_by[]
  • Live spinevalid_to is null. New RFPs and new grants select only from this.
  • Spine as of any past datevalid_from <= D and (valid_to is null or valid_to > D). The
    historical view is a where-clause, derived, not a stored snapshot.
  • Old eligibility rows and acceptance edges keep pointing at their node and keep meaning exactly
    what they meant.
    A node id never changes meaning, so a row referencing it is self-describing.
  • A split is two inserts plus a deprecation; a merge is the reverse. Nobody is migrated.
  • Matching across a split follows superseded_by — a bounded one-hop traversal, not a
    translation layer.

Why not full taxonomy versioning. It buys the same property more expensively. Versioning pins
every row to a snapshot of the whole spine, which forces a cross-version translation layer into v1
before anything works. Node-level immutability is per-node versioning without the global snapshot —
the node is the version.

Why not deprecation without supersession. A vendor on a deprecated node would become invisible to
matching the day it is split, with no path from their old qualification to the new one. The split
would silently cost them RFPs.

Where this is genuinely weaker than versioning: a mass reorganisation redrawing the whole
spine at once turns supersession into a many-to-many tangle and makes the as-of query unreadable.
Accepted as a hand-managed event. If it stops being rare, that is the signal to add real versioning.

Already handled, and worth noting: in-flight RFPs are immune to taxonomy churn by construction,
because the visibility decision materialises the audience as an explicit list at issue.

Constraints handed to other tickets

  • Schema: two category entities (retailer-local and spine leaf) plus the mapping between them;
    spine nodes carry temporal columns and supersession edges; eligibility references the local
    category, the acceptance graph references the spine leaf.
  • Data release / RLS: eligibility stays exact and non-derived, so policies never compute a
    closure. This was the point of decision 3 and must not be eroded later.
  • Matching: operates on the spine, widens within a group, and follows supersession one hop.
    Unmapped local categories are simply out of scope for it.
  • Audit: supersession edges and node validity windows are part of reconstructing why a vendor
    was or was not in an audience at a past date.
  • Observability: mapping quality degrades matching silently. There needs to be a way to see that
    a retailer's mapping is poor or stale.
  • The spine's actual contents — which 50–150 nodes ship and their UNSPSC annotations —
    graduated to its own ticket.
## Resolution Category was doing two incompatible jobs. **Retailer-facing**, eligibility is scoped to it and group rules select over it, so it must match the retailer's own world or onboarding is a fight they win by not adopting. **Network-facing**, matching and the portable vendor profile run on it, so it must mean the same thing across retailers or one-profile-many-buyers means nothing. Four decisions separate those jobs and then keep the seam honest. ### 1. A shared spine, with retailer-local categories mapped onto it Each retailer keeps their own category names and structure as **retailer-scoped rows**. Each maps to a leaf on a **global spine**. - **Eligibility and group rules operate on the retailer's local category** — their world, their words. - **Matching, the portable vendor profile, and the acceptance graph operate on the spine.** This is the same resolution as roster entry vs. vendor org, for the same reason: the local thing is what the customer actually has, the global thing is what makes the network worth joining, and forcing either to be the other breaks one of them. **Retailers create their own local categories freely** — it is their vocabulary, and gatekeeping it would reintroduce the adoption fight. Each local category *should* map to a live spine leaf. An **unmapped local category still works locally** — a retailer can run an RFP on it, and eligibility attaches to it normally — it is simply **invisible to cross-retailer matching**. Same graceful degradation as an ad-hoc document request in the vault decision: the unusual case never blocks a sourcing event, it just does not earn network benefit. **Onboarding.** A retailer already coded in Ariba or Coupa carries UNSPSC codes, so their mapping is **auto-proposed** from those and confirmed rather than authored. A retailer with a homegrown structure maps by hand against a set small enough to do in a sitting. **What it costs.** Mapping is real onboarding work, mappings drift as retailers reorganise, and a bad mapping **degrades matching silently rather than loudly** — there is no error, just worse results. Mapping quality needs to be observable. ### 2. The spine is curated by us, annotated with UNSPSC Order of **50–150 nodes**, pitched at the level a retailer actually runs a sourcing event at. Each node carries the **UNSPSC families it subsumes**, purely as an interop annotation — never as its identity. **Why not adopt UNSPSC wholesale.** Its Family level is too coarse to be a sourcing category and its Commodity level is far too fine; neither sits where a sourcing category sits. 150k+ codes is a *worse* mapping target, not a better one, and it drags in an external reissue cycle that would move under our eligibility rows. **Why not GS1 GPC.** Retail-native and familiar to suppliers, but it classifies *items* — the wrong altitude — and has no coverage for services, which the artifacts research found throughout real solicitations (logistics, facilities, marketing). Half the RFPs would have nowhere to sit. **Same argument as the document catalogue, applied to a second shared vocabulary**: its whole value is that it is small and shared. Self-service fragments it; wholesale adoption drowns it. **What it costs.** We own taxonomy politics, and we will be wrong about granularity somewhere — too coarse and matching is useless, too fine and every retailer maps differently. ### 3. Two levels; eligibility attaches only to leaves A shallow **group -> category** spine. Groups exist for navigation, roll-up reporting, and **match-widening** — a vendor qualified in Citrus can be surfaced as a near-fit for a Stone Fruit RFP because they share a group. **Eligibility, group rules, and the acceptance graph attach only to leaves. Nothing inherits.** **Why no inheritance.** Eligibility is scoped to category and group rules select over eligibility, so downward inheritance means a broad approval **silently qualifies a vendor for every specialised niche underneath** — and that error does not stay quiet, it propagates directly into who receives the next RFP. Under RLS it is worse still: a derived eligibility becomes a recursive closure evaluated inside a policy, which is slow and hard to defend when an award is protested. This keeps every authorization-relevant value **exact and non-derived**. **What it costs.** Two levels will be wrong for someone, and a vendor genuinely qualified across a whole group needs N rows rather than one. ### 4. Nodes are immutable; temporal validity plus supersession Nodes are never edited or deleted. **Labels may be corrected — a label is not an identity.** ``` node: id, label, group_id, valid_from, valid_to (null = live), superseded_by[] ``` - **Live spine** — `valid_to is null`. New RFPs and new grants select only from this. - **Spine as of any past date** — `valid_from <= D and (valid_to is null or valid_to > D)`. The historical view is a **where-clause, derived**, not a stored snapshot. - **Old eligibility rows and acceptance edges keep pointing at their node and keep meaning exactly what they meant.** A node id never changes meaning, so a row referencing it is self-describing. - **A split** is two inserts plus a deprecation; a **merge** is the reverse. Nobody is migrated. - **Matching across a split** follows `superseded_by` — a bounded one-hop traversal, not a translation layer. **Why not full taxonomy versioning.** It buys the same property more expensively. Versioning pins every row to a snapshot of the *whole* spine, which forces a cross-version translation layer into v1 before anything works. Node-level immutability is per-node versioning without the global snapshot — **the node is the version**. **Why not deprecation without supersession.** A vendor on a deprecated node would become invisible to matching the day it is split, with no path from their old qualification to the new one. The split would silently cost them RFPs. **Where this is genuinely weaker than versioning:** a **mass reorganisation** redrawing the whole spine at once turns supersession into a many-to-many tangle and makes the as-of query unreadable. Accepted as a hand-managed event. If it stops being rare, that is the signal to add real versioning. **Already handled, and worth noting:** in-flight RFPs are immune to taxonomy churn by construction, because the visibility decision materialises the audience as an explicit list at issue. ### Constraints handed to other tickets - **Schema**: two category entities (retailer-local and spine leaf) plus the mapping between them; spine nodes carry temporal columns and supersession edges; eligibility references the *local* category, the acceptance graph references the *spine* leaf. - **Data release / RLS**: eligibility stays exact and non-derived, so policies never compute a closure. This was the point of decision 3 and must not be eroded later. - **Matching**: operates on the spine, widens within a group, and follows supersession one hop. Unmapped local categories are simply out of scope for it. - **Audit**: supersession edges and node validity windows are part of reconstructing why a vendor was or was not in an audience at a past date. - **Observability**: mapping quality degrades matching silently. There needs to be a way to see that a retailer's mapping is poor or stale. - **The spine's actual contents** — which 50–150 nodes ship and their UNSPSC annotations — graduated to its own ticket.
Author
Owner

Amendment needed — surfaced by a skeptical review pass

Decisions 3 and 4 contradict each other on the retailer-facing side.

Decision 4 splits a node by deprecating it and wiring superseded_by, with nobody migrated, and
rescues matching by following supersession one hop. Decision 3 forbids eligibility and group rules
from inheriting or deriving at all, so that authorization-relevant values stay exact.

Consequence: after a split, new RFPs select only live nodes. A group rule asks "qualified for
new-node-A". The vendor's eligibility row still points at the now-dead node. The vendor is
silently dropped from the audience, with no error anywhere
— the exact silent-degradation failure
mode decision 3 existed to prevent, arriving through decision 4.

Supersession fixes the network side and breaks the retailer side. Neither escape is free: migrating
eligibility rows on split breaks "nobody is migrated", and letting group rules follow supersession
puts derivation back into an authorization path.

Graduated to Resolve taxonomy supersession against eligibility exactness.

## Amendment needed — surfaced by a skeptical review pass **Decisions 3 and 4 contradict each other on the retailer-facing side.** Decision 4 splits a node by deprecating it and wiring `superseded_by`, with nobody migrated, and rescues matching by following supersession one hop. Decision 3 forbids eligibility and group rules from inheriting or deriving *at all*, so that authorization-relevant values stay exact. Consequence: after a split, new RFPs select only live nodes. A group rule asks "qualified for new-node-A". The vendor's eligibility row still points at the now-dead node. **The vendor is silently dropped from the audience, with no error anywhere** — the exact silent-degradation failure mode decision 3 existed to prevent, arriving through decision 4. Supersession fixes the network side and breaks the retailer side. Neither escape is free: migrating eligibility rows on split breaks "nobody is migrated", and letting group rules follow supersession puts derivation back into an authorization path. Graduated to [Resolve taxonomy supersession against eligibility exactness](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/31).
Author
Owner

Correction to the earlier review note

The previous comment claimed decisions 3 and 4 contradict each other via a spine split silently
un-qualifying vendors. That was wrong — eligibility runs on the retailer-local category per
decision 1, which is also what the schema encoded, so a spine split does not reach it.

The real defect is narrower and still real: decision 1 and decision 4 disagree about what an
eligibility row references.
Decision 4 states that "old eligibility rows and acceptance edges keep
pointing at their node", and its whole supersession rescue assumes eligibility points at spine
nodes. Decision 1 says it points at the local category.

And the immutability question decision 4 answered so carefully was asked only of the spine — which
retailers never edit — and never of retailer-local categories, which they author freely and
which eligibility, group rules, and live solicitations all reference.

Both are now the subject of
Resolve what eligibility references, and whether local categories are mutable.

## Correction to the earlier review note The previous comment claimed decisions 3 and 4 contradict each other via a **spine** split silently un-qualifying vendors. That was wrong — eligibility runs on the **retailer-local** category per decision 1, which is also what [the schema](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/10) encoded, so a spine split does not reach it. The real defect is narrower and still real: **decision 1 and decision 4 disagree about what an eligibility row references.** Decision 4 states that "old eligibility rows and acceptance edges keep pointing at their node", and its whole supersession rescue assumes eligibility points at spine nodes. Decision 1 says it points at the local category. And the immutability question decision 4 answered so carefully was asked only of the spine — which retailers never edit — and **never of retailer-local categories, which they author freely** and which eligibility, group rules, and live solicitations all reference. Both are now the subject of [Resolve what eligibility references, and whether local categories are mutable](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/31).
Sign in to join this conversation.
No description provided.