dbt logo

Building an Ownership Register That Survives Contact With a Real Team

Ownership registers fail in a predictable way: someone fills a spreadsheet in a week of good intentions, three people leave over the next year, and the file becomes an archaeological record of who used to work here. This guide builds one that does not do that.

Building an Ownership Register That Survives Contact With a Real Team

Ownership registers fail in a predictable way: someone fills a spreadsheet in a week of good intentions, three people leave over the next year, and the file becomes an archaeological record of who used to work here. This guide builds one that does not do that.

Prerequisite: a dbt project imported from manifest.json.

Why it is columns, not a document

The register is a set of mixin columns on the governed model — not a separate artifact. That is the whole design, and it is what makes it survivable.

A document about the model drifts from the model, because nothing connects them. Columns on the model move with it: rename a concept and its owner follows, because the owner is a property of the thing rather than a row in a file keyed by a name that just changed.

The recipe provisions five fields on every column and vocabulary:

FieldWhat it holdsWhy it is there
termOwnerThe person who decides what this meansThe question everyone actually asks
stewardTeamThe team accountableSurvives the person leaving
decisionForumWhere the decision gets madeSurvives the team being reorganized
reviewCadenceHow often it is revisitedTurns "is this still right?" into a date
lastReviewedWhen it last wasMakes staleness visible instead of assumed

Plus businessDomain and accountableTeam at model level, for the coarser cut.

Step 1 — Set a default before you set anything else

Counterintuitive, and it is the step people skip.

Assign your whole estate to one team on day one — analytics engineering, or whoever imported it. The register is now wrong in an interesting way rather than empty in a useless one, because an imperfect default makes the exceptions visible. "This is ours" invites "no, that one is finance's" in a way that a blank column never does.

The recipe's third wizard step takes that default so it is applied at setup rather than as a thousand edits afterwards.

Step 2 — Work the "no owner" filter

Filter the ownership grid to rows where termOwner is empty. That list is the work. It is also usually longer than anyone in the room expects, which is the useful part — ambiguous ownership feels like a vague cultural complaint right up until it is a filtered grid with a count on it.

Work it in this order:

  1. Vocabularies first. They are where the disagreements live. Nobody argues about customer_id; people argue for weeks about what counts as an active customer.
  2. Columns that appear in more than one model. Shared concepts, shared confusion.
  3. Everything else, at whatever pace is honest.

Step 3 — Prefer a forum to a person

decisionForum is the field that makes this durable, and it is the one that looks least necessary until you have watched a register rot.

A named individual is a single point of failure: they change teams, go on leave, or leave entirely, and the register silently becomes wrong while still looking authoritative. A forum — a weekly review, a working group, a channel with a stated convention — survives all three.

It also lets you record the honest answer. Sometimes nobody decides a definition, and the right entry is the forum where that conversation now has to happen, rather than a name picked to make the field non-empty.

Step 4 — Make the cadence real

reviewCadence plus lastReviewed is the difference between a register and a snapshot.

Quarterly is a reasonable default for most concepts, annual for stable ones, and "on change" for things that only matter when they move. The pair matters more than either field: a cadence with no last-reviewed date cannot tell you what is overdue, and a date with no cadence cannot tell you whether it is late.

What you get once it is populated

A question that has an answer. "Who decides whether this counts as an active customer?" resolves to a name and a forum instead of a thread.

A queryable register, at no extra cost. Because CoreModels generates its GraphQL schema from the model's mixins and relation groups, the ownership fields become queryable the moment the mixin exists. Nobody builds an API for this; it follows from the columns being columns.

The prerequisite for everything else. Review workflows need to know who reviews. Impact answers are only actionable when they name someone. Notifications need somewhere to route. Each of those is a different feature, and all of them are inert on an estate where nothing has an owner — which is the real argument for doing this unglamorous thing first.

What this does not do

  • It does not enforce anything. Nothing blocks a change because an owner did not approve it; that is a review workflow, and it is a different piece of work that depends on this one.
  • It does not notify the owner. Change notification for governed-model edits is not built. Any claim otherwise is wrong today.
  • It does not know your org chart. There is no directory sync — these are values you set, which is also why they can record a forum, a rotating role, or "unassigned, discuss on Thursday" when those are the true answers.

Where to go next

With owners in place, the impact question becomes actionable — "this affects four contracts" is only useful when it also says who to talk to about each. The dbt quickstart that ships with the CoreModels integration docs covers the import this guide assumed.