DEV Community

Cover image for Every tag was correct, and together they leaked data
Adab ul Qayyum
Adab ul Qayyum

Posted on

Every tag was correct, and together they leaked data

Anna leads the under-twelves and plays for the under-fourteens.

The system knows both things. Her customer record carries role:leader, team:u12 and team:u14 — accurate in every particular. On Monday she opens the leader report to see how her team's fundraising went, and it shows her the under-twelves' sales. And the under-fourteens'. Her own teammates, by name, with totals.

Nobody configured that. Every tag is correct, and together they say something nobody meant.

The role is on the edge

"Leader" isn't a property of a person. It's a property of a person's relationship with one team.

Anna is a leader of the under-twelves. She is a member of the under-fourteens. Two relationships, each with its own role — and the moment the role is stored on the person rather than on the relationship, they collapse into one: a leader, who is in two teams.

So the model is one row per person per team, and the role belongs to the row. Not novel; it's where any membership system ends up after contact with real organisations. What's worth writing about is how naturally the other version arrives in a Shopify build, where the customer record is right there and tags are free.
`type Role = "member" | "leader" | "club-admin";

/** One row per person per team. The role belongs to the row, not the person. */
type Membership = { personId: string; teamId: string; clubId: string; role: Role };

/**

  • Which sellers' sales may this person see? *
  • Everyone sees their own. A leader sees the members of the teams they lead,
  • and only those. A club admin sees every seller in that club. */ export function visibleSellers(viewerId: string, memberships: readonly Membership[]): Set { const visible = new Set([viewerId]); const own = memberships.filter((m) => m.personId === viewerId);

const ledTeams = new Set(own.filter((m) => m.role === "leader").map((m) => m.teamId));
const adminClubs = new Set(own.filter((m) => m.role === "club-admin").map((m) => m.clubId));

for (const m of memberships) {
if (ledTeams.has(m.teamId) || adminClubs.has(m.clubId)) visible.add(m.personId);
}
return visible;
}`

The line that matters is the filter on role === "leader" before collecting team ids. The version tags produce asks a different question — is this person a leader, and which teams are they in — and for anyone who leads one team and plays in another, the two answers combine into authority over a team they only belong to. Nothing errors. The leader's report simply includes their teammates. Holding the role on the membership row makes that combination impossible to express, which is a stronger guarantee than remembering not to write it.

The function is short because the model does the work. Every visibility question becomes a question about rows, and a row can only say one thing about one relationship.

Attribution has the same shape, and it's quieter

A fundraising sale is attributed to a seller, and totals roll up: seller to team, team to club.

If Anna sells for both her teams and the link she shares identifies only Anna, every sale is ambiguous at the first roll-up. It belongs to Anna, and Anna belongs to two teams. Somebody writes a rule to break the tie — usually "the first team on her record" — and one team's total is quietly short for the rest of the season.

Nobody notices, because the club total is right.

Same fix: attribute the sale to the membership, not the person. Anna-in-the-under-fourteens. The link carries both and the roll-up has nothing to guess.

Attribution and visibility turn out to be the same question asked from opposite ends: which relationship does this fact belong to?

Shopify already has this shape, for buyers

Shopify's own model gets this exactly right in a different context. In B2B, a customer is attached to company locations and permissions are assigned per location — Ordering only, or Location admin, which can see every order placed for that location. A person can be an admin at one location and an orderer at another.

The role is on the edge.

If your hierarchy is buyers purchasing on behalf of an organisation, use it. It's native, supported, and already correct.

It doesn't fit sellers, for two reasons. A customer can belong to only one company, and the people in the opening belong to more than one structure by design. And B2B visibility is over orders placed for a location — a fundraising leader needs to see sales attributed to the people they lead, which are orders placed by members of the public who have never heard of the team.

Holding the structure isn't the hard part

Shopify can hold the structure too. A metaobject per team, with a customer reference for the leader and a list of references for the members, is expressible natively and editable in the admin. If the requirement is to record who's in which team, that's enough.

What it doesn't do is answer who may see what. A customer account shows a customer their own orders. There's no notion in Shopify of a person who isn't staff and may see other people's sales because of where they sit in an organisation — and that question, not the membership list, is what the hierarchy is for.

What this doesn't do

It's current state only. Memberships change between seasons, and a leader's authority starting in March is a fact the report needs to show last autumn's figures correctly. Real rows carry dates; the function above doesn't, because its point is about where the role lives, not when.

It isn't authentication. It answers what an identified person may see. Who that person is, and how they proved it, is a separate problem with its own failure modes.

It doesn't decide policy. Whether a club admin sees individual sellers or only team totals is the organisation's call, and it changes. The model makes either rule easy to write and neither automatic.

When this needs an engineer

Often it doesn't. If tags are labels for filtering — a club tag for an email segment, a team tag for a report you run yourself — they're the right tool, and replacing them with a database is cost without benefit. If your organisation buys rather than sells, Shopify B2B already has the model.

It needs engineering when people who aren't staff must see data about other people, and what they may see depends on where they sit in a structure that changes. That's the point where a tag stops being a label and starts being a permission — and a permission written as a string on a customer record is one nobody can audit.

Takeaways
A role is a property of a relationship, not a person. Anna leads one team and plays in another; storing "leader" on Anna makes her a leader of both.
Customer tags hold up to 250 labels on one record. They filter well and cannot express which relationship a fact belongs to.
Shopify B2B has the right shape — permissions per company location — for buyers. It allows one company per customer and shows orders placed, not sales attributed.
Credit has the same shape. Attribute a sale to the membership, not the person, or a seller in two teams leaves one team's total quietly short.
When a tag starts deciding what someone outside staff can see, it has become a permission, and it belongs in a model that can be audited.
The thing I'd like other people's read on is the temporal version, because I left it out of the function deliberately and I'm not sure the real answer is clean.

Memberships change. Anna leads the under-twelves this season and moves to the under-fourteens next. Add dates to the rows and visibility becomes a question about a point in time rather than about now — which is correct, and means every report has to decide which point. The sale date? The date the report runs? For a season summary those give different answers, and both are defensible.

The version I've seen go wrong is a leader who steps down mid-season and keeps seeing historic figures for a team they no longer lead, because the rows were filtered on the sale date and the sales predate their departure. Technically right. Not what anyone wanted.

If you've built role-based visibility over data with history, how did you resolve which date governs?

Top comments (0)