Resolve what eligibility references, and whether local categories are mutable #31

Closed
opened 2026-08-02 15:23:56 +00:00 by christian · 2 comments
Owner

Question

Surfaced by the skeptical review pass, then sharpened after the first framing turned out to
be wrong
— see the correction comment below.

The category taxonomy contradicts itself about what an eligibility row actually points at.

  • Decision 1: "Eligibility and group rules operate on the retailer's local category. Matching,
    the portable vendor profile, and the acceptance graph operate on the spine."
  • Decision 4: "Old eligibility rows and acceptance edges keep pointing at their node and keep
    meaning exactly what they meant" — and its entire supersession rescue is built on eligibility
    referencing spine nodes.

The schema encoded decision 1 (solicitation.local_category_id). Decision 4's eligibility
paragraph is therefore either wrong text or a second, unacknowledged model.

The question decision 4 was reaching for was never asked of the right object. The spine is
immutable with temporal validity and supersession. Retailer-local categories are the retailer's
own rows and nothing says whether they are mutable at all
— and those are the rows eligibility,
group rules, and live solicitations actually reference.

Resolve:

  1. What does an eligibility row reference — the retailer-local category, the spine leaf, or
    both? Settle it once and correct whichever half of #26 is wrong.
  2. Are retailer-local categories mutable? They are authored freely by retailers (decision 1),
    which argues for mutability; they are referenced by eligibility rows, materialised audience
    snapshots, and live solicitations, which argues for the same immutability the spine got.
  3. What happens when a retailer splits, merges, renames, or deletes a local category they have
    eligibility rows and past solicitations against? A rename is safe if a label is not an identity.
    A split is the dangerous one: vendors qualified in "Dairy" are not automatically qualified in
    "Fluid Milk" and "Cheese", and guessing wrong in either direction has a cost —
    over-qualification leaks an RFP to a vendor the retailer did not intend, which is the failure
    this product exists to prevent
    , while under-qualification silently costs vendors bids.
  4. What happens to a local category whose spine leaf is deprecated? Auto-follow supersession, or
    flag the retailer to remap? Matching degrades either way; the question is whether it degrades
    silently.

Constraints to respect: whatever is chosen must be expressible in RLS policy SQL without a
recursive closure
(#26 decision 3); authorization-relevant values stay exact and non-derived; and
in-flight RFPs should already be immune via the audience snapshot (#12 decision 3) — confirm rather
than assume.


Parent: #1

## Question Surfaced by the [skeptical review pass](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/33), then **sharpened after the first framing turned out to be wrong** — see the correction comment below. [The category taxonomy](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/26) contradicts itself about what an eligibility row actually points at. - **Decision 1:** "Eligibility and group rules operate on the retailer's local category. Matching, the portable vendor profile, and the acceptance graph operate on the spine." - **Decision 4:** "Old eligibility rows and acceptance edges keep pointing at their node and keep meaning exactly what they meant" — and its entire supersession rescue is built on eligibility referencing **spine** nodes. [The schema](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/10) encoded decision 1 (`solicitation.local_category_id`). Decision 4's eligibility paragraph is therefore either wrong text or a second, unacknowledged model. **The question decision 4 was reaching for was never asked of the right object.** The spine is immutable with temporal validity and supersession. **Retailer-local categories are the retailer's own rows and nothing says whether they are mutable at all** — and those are the rows eligibility, group rules, and live solicitations actually reference. Resolve: 1. **What does an eligibility row reference** — the retailer-local category, the spine leaf, or both? Settle it once and correct whichever half of #26 is wrong. 2. **Are retailer-local categories mutable?** They are authored freely by retailers (decision 1), which argues for mutability; they are referenced by eligibility rows, materialised audience snapshots, and live solicitations, which argues for the same immutability the spine got. 3. **What happens when a retailer splits, merges, renames, or deletes a local category** they have eligibility rows and past solicitations against? A rename is safe if a label is not an identity. A split is the dangerous one: vendors qualified in "Dairy" are not automatically qualified in "Fluid Milk" *and* "Cheese", and guessing wrong in either direction has a cost — **over-qualification leaks an RFP to a vendor the retailer did not intend, which is the failure this product exists to prevent**, while under-qualification silently costs vendors bids. 4. **What happens to a local category whose spine leaf is deprecated?** Auto-follow supersession, or flag the retailer to remap? Matching degrades either way; the question is whether it degrades silently. Constraints to respect: whatever is chosen must be expressible in RLS policy SQL **without a recursive closure** (#26 decision 3); authorization-relevant values stay exact and non-derived; and in-flight RFPs should already be immune via the audience snapshot (#12 decision 3) — confirm rather than assume. --- Parent: #1
christian added the
wayfinder:grilling
wayfinder:ticket
labels 2026-08-02 15:23:57 +00:00
christian changed title from Resolve taxonomy supersession against eligibility exactness to Resolve what eligibility references, and whether local categories are mutable 2026-08-02 15:27:27 +00:00
Author
Owner

Correction to the framing that created this ticket

The review pass claimed a spine split silently un-qualifies vendors. That was wrong: it
conflated retailer-local categories with spine leaves. Under decision 1 — which is what
the schema actually encoded — eligibility never references the spine, so a spine split
cannot touch it.

What survives the correction is sharper, and is now the ticket above:

  • #26 decisions 1 and 4 disagree about what eligibility references, and decision 4's rescue
    mechanism only works under the reading the schema did not adopt.
  • The immutability question was asked of the spine, which retailers do not edit, and never asked
    of local categories, which they author freely
    — and those are the rows eligibility and live
    solicitations point at.

Noted on the category taxonomy as well.

## Correction to the framing that created this ticket The review pass claimed a **spine** split silently un-qualifies vendors. That was wrong: it conflated retailer-local categories with spine leaves. Under decision 1 — which is what [the schema](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/10) actually encoded — eligibility never references the spine, so a spine split cannot touch it. What survives the correction is sharper, and is now the ticket above: - #26 decisions 1 and 4 **disagree about what eligibility references**, and decision 4's rescue mechanism only works under the reading the schema did not adopt. - The immutability question was asked of the spine, which retailers do not edit, and **never asked of local categories, which they author freely** — and those are the rows eligibility and live solicitations point at. Noted on [the category taxonomy](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/26) as well.
christian self-assigned this 2026-08-02 15:27:27 +00:00
Author
Owner

Resolution

Four decisions. The through-line: authorization never derives, and a taxonomy edit never changes
what was approved.
Everything below falls out of holding those two lines together, which is what
the category taxonomy failed to do — it held the first for the spine and the second for
matching, and left the retailer's own rows in between unaddressed.

1. Eligibility references the retailer-local category, and only that

An eligibility row points at a local_category. The spine is reached transitively through the
mapping
, only by matching and the acceptance graph, and never by anything that decides access.

eligibility     retailer_id, vendor_org_id, local_category_id, ...
local_category  id, retailer_id, label, spine_leaf_id?      -- null = unmapped

authz     eligibility -> local_category                     -- stops here
matching  eligibility -> local_category -> spine_leaf -> supersession

This confirms decision 1 of #26 and corrects decision 4. Decision 4's sentence — "old
eligibility rows and acceptance edges keep pointing at their node"
— is only true of acceptance
edges. It was written as though eligibility referenced the spine, and its supersession rescue was
built on that reading. The schema had already encoded the other one
(solicitation.local_category_id). Decision 4's eligibility clause is struck; its acceptance-edge
clause stands.

What this buys, beyond consistency. Spine churn can now never reach an authorization decision.
Taxonomy politics, external UNSPSC reissues, our own curation mistakes — none of it can move who
sees an RFP. The eligibility policy stays a plain equality on local_category_id with no closure
and no traversal
, which is exactly the RLS-expressibility constraint #26 decision 3 was protecting.

What it costs. Any question of the form "which vendors are qualified for this spine leaf, across
retailers"
must join through the mapping and inherits its staleness. That is a matching-quality
problem, never an access problem — which is the correct place for it to be.

2. Local categories get the spine's mutability model

One model across both category kinds, not two sets of rules.

local_category  id, retailer_id, label, spine_leaf_id?,
                valid_from, valid_to (null = live), superseded_by[]
  • Rename is an UPDATE label. A label is not an identity — carried over from #26 decision 4.
  • Retire sets valid_to. Rows referencing it still resolve and still mean what they meant.
  • Split and merge are inserts. The predecessor is retired and superseded_by records the edge.
  • Delete is not offered.

Why not free mutability. These rows are referenced by eligibility grants, materialised audience
snapshots, and issued solicitations. Free mutability permits repurposing — editing "Dairy" into
"Frozen" retroactively rewrites what every one of those records claims, and audit reconstruction
stops being trustworthy. Awards get protested years later; the record has to still be readable.

Why not push durability into consumers (each solicitation and grant carrying its own label copy).
It works, and it is more denormalisation than a POC needs — the same fact in four places, diverging
by design, with "who is currently eligible for X" becoming a harder question than it should be.

What it costs. A retailer cannot delete a category created by mistake, only retire it. Retiring
needs a real UI story, and "retired" must not read as an error state.

3. Split propagates to every successor; merge propagates only the intersection

The asymmetry is the substance. A split does not narrow an approval — it renames its parts. A
vendor approved for "Dairy" was approved for cheese, by the retailer's own act, because Dairy
covered cheese. Propagating to every successor therefore preserves the grant exactly; propagating
to fewer would revoke approval the retailer granted, silently.

A merge is the direction that actually widens. A vendor approved only for "Fluid Milk" was never
approved for cheese, so carrying the union into a merged "Dairy" would grant scope the retailer never
gave — and that vendor could receive a cheese RFP before any review happened. So a merge carries only
vendors approved for every input.

SPLIT   Dairy -> {Fluid Milk, Cheese}
        INSERT eligibility for BOTH successors        widening: none  revocation: none

MERGE   {Fluid Milk, Cheese} -> Dairy
        INSERT only where approved for ALL inputs     widening: none  revocation: visible

Propagated rows are real rows, written once, stamped with the event that caused them — not
derived at read time. #26 decision 3's ban is on read-time derivation inside policy; an explicit
audited write is not that, and the resulting rows are as exact as hand-granted ones. The two
decisions were never actually in conflict; the ticket that framed them as such was reading
"immutability" as "no writes".

Pruning is an explicit act. A retailer who split precisely because they now want finer control
prunes the propagated rows, and each prune is a recorded revocation. That is a new decision they
make deliberately, not something a taxonomy edit performs on their behalf.

What it costs. Propagation can touch thousands of rows and must be transactional with the
structural change — a half-propagated split is a live authorization defect. A merge visibly drops
vendors, and the retailer must be told plainly at the point of merge, not left to discover it
when an audience comes back small.

This is a privileged write path. It writes authorization rows across a retailer's entire vendor
set, triggered by a taxonomy operation rather than a grant. It belongs on the enumerated
privileged-path list the review pass handed to the layout.

4. A deprecated spine leaf auto-resolves when unambiguous, and raises a task when not

  • 1 -> 1 (rename, or merge into a single leaf): the mapping is rewritten at deprecation time.
    There is nothing to decide.
  • 1 -> N (split): the pointer is left alone, matching follows all successors, and a remap
    task is raised for the retailer
    .

So no local category ever silently points at a dead leaf, and the retailer is asked only when their
answer changes the outcome. Matching never goes dark for a decision the retailer did not make.

Why not read-time traversal always (#26 decision 4 as written). Every match read pays the hop
forever, and one hop stops being enough after a second split#26 accepted mass reorganisation
as a hand-managed event, and always-traverse quietly accrues that debt on the read path instead.

Why not treat it as unmapped. #26 already defines unmapped as a working state, so this is
tempting and cheap. But matching is a paid vendor feature, and switching it off for every vendor
in a category until an unrelated party does remapping work is a real cost imposed for our taxonomy
edit.

What it costs. We write to retailer-owned rows on our schedule. That must be visible in their
history and reversible.

Confirmed rather than assumed

In-flight RFPs are immune, as #26 claimed. Visibility decision 3 materialises the
audience as an explicit vendor list at issue, so no structural change to any category — local or
spine — can alter who was invited to a live solicitation.

Constraints handed to other tickets

  • Schema: local_category gains valid_from, valid_to, superseded_by[]; eligibility gains a
    grant provenance (manual / split_event / merge_event, with the event id) so a propagated
    grant is distinguishable from a hand-granted one at audit; a category_change_event entity records
    splits, merges, retirements, and remaps.
  • Data release / RLS: the eligibility policy is a plain equality on local_category_id — no
    closure, no traversal. This is now settled and must not be eroded.
  • Privileged paths: split/merge propagation writes authorization rows outside any single actor's
    context. Add it to the enumerated list alongside the acceptance-count read and the expiry
    scheduler.
  • Audit: category change events, grant provenance, and prune-revocations are all protest surface
    — "was this vendor eligible on the date of issue" must be answerable across a split.
  • App framework: split, merge, and retire are retailer surfaces with real consequence. The merge
    screen must state which vendors will be dropped before it commits.
  • Notification: a merge that drops vendors and a raised remap task are both events someone must
    be told about. Folds into notification design.
  • The v1 category spine: unaffected — this ticket touched the retailer's side of the mapping,
    not the spine's contents.
## Resolution Four decisions. The through-line: **authorization never derives, and a taxonomy edit never changes what was approved.** Everything below falls out of holding those two lines together, which is what [the category taxonomy](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/26) failed to do — it held the first for the spine and the second for matching, and left the retailer's own rows in between unaddressed. ### 1. Eligibility references the retailer-local category, and only that An eligibility row points at a `local_category`. The spine is reached **transitively through the mapping**, only by matching and the acceptance graph, and never by anything that decides access. ``` eligibility retailer_id, vendor_org_id, local_category_id, ... local_category id, retailer_id, label, spine_leaf_id? -- null = unmapped authz eligibility -> local_category -- stops here matching eligibility -> local_category -> spine_leaf -> supersession ``` **This confirms decision 1 of #26 and corrects decision 4.** Decision 4's sentence — *"old eligibility rows and acceptance edges keep pointing at their node"* — is only true of acceptance edges. It was written as though eligibility referenced the spine, and its supersession rescue was built on that reading. [The schema](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/10) had already encoded the other one (`solicitation.local_category_id`). Decision 4's eligibility clause is **struck**; its acceptance-edge clause stands. **What this buys, beyond consistency.** Spine churn can now never reach an authorization decision. Taxonomy politics, external UNSPSC reissues, our own curation mistakes — none of it can move who sees an RFP. The eligibility policy stays a plain equality on `local_category_id` with **no closure and no traversal**, which is exactly the RLS-expressibility constraint #26 decision 3 was protecting. **What it costs.** Any question of the form *"which vendors are qualified for this spine leaf, across retailers"* must join through the mapping and inherits its staleness. That is a matching-quality problem, never an access problem — which is the correct place for it to be. ### 2. Local categories get the spine's mutability model One model across both category kinds, not two sets of rules. ``` local_category id, retailer_id, label, spine_leaf_id?, valid_from, valid_to (null = live), superseded_by[] ``` - **Rename** is an `UPDATE label`. A label is not an identity — carried over from #26 decision 4. - **Retire** sets `valid_to`. Rows referencing it still resolve and still mean what they meant. - **Split and merge are inserts.** The predecessor is retired and `superseded_by` records the edge. - **Delete is not offered.** **Why not free mutability.** These rows are referenced by eligibility grants, materialised audience snapshots, and issued solicitations. Free mutability permits *repurposing* — editing "Dairy" into "Frozen" retroactively rewrites what every one of those records claims, and audit reconstruction stops being trustworthy. Awards get protested years later; the record has to still be readable. **Why not push durability into consumers** (each solicitation and grant carrying its own label copy). It works, and it is more denormalisation than a POC needs — the same fact in four places, diverging by design, with "who is currently eligible for X" becoming a harder question than it should be. **What it costs.** A retailer cannot delete a category created by mistake, only retire it. Retiring needs a real UI story, and "retired" must not read as an error state. ### 3. Split propagates to every successor; merge propagates only the intersection The asymmetry is the substance. **A split does not narrow an approval — it renames its parts.** A vendor approved for "Dairy" was approved for cheese, by the retailer's own act, because Dairy covered cheese. Propagating to every successor therefore **preserves the grant exactly**; propagating to fewer would *revoke* approval the retailer granted, silently. **A merge is the direction that actually widens.** A vendor approved only for "Fluid Milk" was never approved for cheese, so carrying the union into a merged "Dairy" would grant scope the retailer never gave — and that vendor could receive a cheese RFP before any review happened. So a merge carries only vendors approved for **every** input. ``` SPLIT Dairy -> {Fluid Milk, Cheese} INSERT eligibility for BOTH successors widening: none revocation: none MERGE {Fluid Milk, Cheese} -> Dairy INSERT only where approved for ALL inputs widening: none revocation: visible ``` **Propagated rows are real rows, written once, stamped with the event that caused them** — not derived at read time. #26 decision 3's ban is on *read-time derivation inside policy*; an explicit audited write is not that, and the resulting rows are as exact as hand-granted ones. The two decisions were never actually in conflict; the ticket that framed them as such was reading "immutability" as "no writes". **Pruning is an explicit act.** A retailer who split precisely because they now want finer control prunes the propagated rows, and each prune is a recorded revocation. That is a new decision they make deliberately, not something a taxonomy edit performs on their behalf. **What it costs.** Propagation can touch thousands of rows and must be transactional with the structural change — a half-propagated split is a live authorization defect. A merge visibly drops vendors, and the retailer must be **told plainly at the point of merge**, not left to discover it when an audience comes back small. **This is a privileged write path.** It writes authorization rows across a retailer's entire vendor set, triggered by a taxonomy operation rather than a grant. It belongs on the enumerated privileged-path list the [review pass](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/33) handed to the layout. ### 4. A deprecated spine leaf auto-resolves when unambiguous, and raises a task when not - **1 -> 1** (rename, or merge into a single leaf): the mapping is **rewritten at deprecation time**. There is nothing to decide. - **1 -> N** (split): the pointer is left alone, **matching follows all successors**, and a **remap task is raised for the retailer**. So no local category ever silently points at a dead leaf, and the retailer is asked only when their answer changes the outcome. Matching never goes dark for a decision the retailer did not make. **Why not read-time traversal always** (#26 decision 4 as written). Every match read pays the hop forever, and **one hop stops being enough after a second split** — #26 accepted mass reorganisation as a hand-managed event, and always-traverse quietly accrues that debt on the read path instead. **Why not treat it as unmapped.** #26 already defines unmapped as a working state, so this is tempting and cheap. But matching is a **paid vendor feature**, and switching it off for every vendor in a category until an unrelated party does remapping work is a real cost imposed for our taxonomy edit. **What it costs.** We write to retailer-owned rows on our schedule. That must be visible in their history and reversible. ### Confirmed rather than assumed **In-flight RFPs are immune**, as #26 claimed. [Visibility](https://gitea.stephenmann.io/christian/helmdocs-proposal-system/issues/12) decision 3 materialises the audience as an explicit vendor list at issue, so no structural change to any category — local or spine — can alter who was invited to a live solicitation. ### Constraints handed to other tickets - **Schema**: `local_category` gains `valid_from`, `valid_to`, `superseded_by[]`; eligibility gains a **grant provenance** (`manual` / `split_event` / `merge_event`, with the event id) so a propagated grant is distinguishable from a hand-granted one at audit; a `category_change_event` entity records splits, merges, retirements, and remaps. - **Data release / RLS**: the eligibility policy is a plain equality on `local_category_id` — no closure, no traversal. This is now settled and must not be eroded. - **Privileged paths**: split/merge propagation writes authorization rows outside any single actor's context. Add it to the enumerated list alongside the acceptance-count read and the expiry scheduler. - **Audit**: category change events, grant provenance, and prune-revocations are all protest surface — "was this vendor eligible on the date of issue" must be answerable across a split. - **App framework**: split, merge, and retire are retailer surfaces with real consequence. The merge screen must state which vendors will be dropped **before** it commits. - **Notification**: a merge that drops vendors and a raised remap task are both events someone must be told about. Folds into notification design. - **The v1 category spine**: unaffected — this ticket touched the retailer's side of the mapping, not the spine's contents.
Sign in to join this conversation.
No description provided.