Data model stewardship runbook

Status: procedure reference. The rules — schema audit before large features, Organization as canonical identity, use the shared primitives — live in AGENTS.md §11 and stay always-on. This file holds the primitive inventory, helper boundaries and audit indicators. Relocated by BI-0020D511 Phase 1; no rule was dropped.

Before adding any large feature, audit the existing schema for refactoring opportunities. Indicators that refactoring is needed: a domain model being reused as a shared concept; the same logical data appearing in two+ existing models; a new feature needing meta-data with no canonical home. → kernel principle

Organization is the canonical platform identity model. Any feature needing org name, slug, logo, address, or contact info reads from Organization — not from BrandingConfig, env vars, or bespoke fields elsewhere. → kernel principle

The Organization.address JSON has one canonical shape + helpers in apps/web/lib/shared/org-address.ts (OrgAddress, parseOrgAddress / serializeOrgAddress / formatOrgAddressLines, resolveTimezoneFromAddress). Read and write the address through those — do not hand-roll a parallel address field or shape. It is captured at setup via the business-context step (/storefront/settings/business) and is the precise source for state-accurate timezone derivation (BI-AAAA0691). The same JSON carries the business’s own map point (latitude, longitude, validationSource, validatedAt; ORG_LOCATION_KEYS), written by geocode-on-save and read by nearby discovery. serializeOrgAddress drops a provider-derived point when the address text changes and keeps a hand-entered or person-confirmed one (BI-C318C227).

Shared micro-primitives (BET-6, BI-6A505BFF). Cross-cutting helpers that were hand-inlined at hundreds of sites now have one home each — import them, do not re-copy:

Route-segment helpers vs shared domain modules (BI-IMP-BC5AA87E). Page-local helpers under a route segment (e.g. apps/web/app/(shell)/…/_helpers.ts or a colocated *.ts next to page.tsx) may format, adapt, or present canonical domain data for that surface only. They must not invent a second home for reusable agent/delegation policy, tool metadata, grant maps, persisted outcome contracts, or identifiers that other routes need — those live in shared modules under apps/web/lib/ (or packages). If a helper is imported from a second route or encodes policy that would change coworker behavior platform-wide, promote it to a shared canonical module in the same PR.

Metadata governance (BI-IMP-FA900452, BI-IMP-52761525). JSON metadata columns (e.g., unstructured payload fields) are reserved for optional, unstructured, or rapidly-evolving context. They must not be used as primary query or reporting sources. Any property that becomes frequently filtered, queried, or joined must be promoted to a typed schema field (via a database migration). When reading or writing JSON metadata in code, use typed accessor helpers and centralized key constants rather than raw string indexing. A provider publication/thread/conversation id is normalized through the owning domain’s canonical identity or relationship mapping before a report joins on it; an incidental metadata key is never an implicit foreign key. → kernel principle · reporting boundary

Principal convergence (2026-05-09). Per the addendum on docs/superpowers/specs/2026-04-22-enterprise-auth-directory-federation-design.md, any new identity-bearing entity introduced after 2026-05-09 must be modeled as a PrincipalAlias linked to a single Principal, not as a parallel identity table. The convergence target covers User, CustomerContact, Agent, EdgeNode, MobileDevice, and ServiceAccount. Authorization decisions resolve on the Principal; alias kind tells the platform which surface authenticated the request. → kernel principle

Record lifecycle convention

The ONE “not active” convention (BI-C357FA5A, Simplify & Strengthen W20, architecture pass 2026-08-16 §3.2-d). The pass measured “not active” said six ways across the schema — archivedAt, supersededById, mergedIntoId, quarantinedAt/overlapQuarantinedAt/conflictQuarantinedAt, retiredAt, and status="quarantined" — so every reader had to know which convention applies per table. The unified convention is:

Semantics per state: archived = kept for reference, excluded from operational reads; retired = permanently withdrawn from service; superseded = replaced by a successor record (pointer required); merged = collapsed into a survivor record (pointer required); quarantined = excluded pending integrity review (the record is suspect, not the data subject).

Enforcement. scripts/check-no-new-notactive-conventions.mjs blocks NEW legacy-convention columns schema-wide; the 29 existing carriers live in the shrink-only owned baseline scripts/notactive-conventions-baseline.json (owner platform-architecture). New String status columns carrying quarantine vocabulary are already blocked by scripts/check-no-new-closed-set-strings.mjs.

Pilot family. The W19 unified resource-scheduling models (Resource, ResourceAvailability, ResourceCapacityPool, ResourceCapacityAllocation) are born on the convention — zero-data adoption. Existing carriers migrate per the operator-reviewed plan: docs/superpowers/plans/2026-08-18-w20-lifecycle-convention-migration-plan.md. Business-state machines (e.g. an allocation’s reserved→confirmed→released flow) remain domain enums — the record lifecycle says whether the ROW is live, not where the BUSINESS process stands.

Model metadata lives in the schema and the catalog

One declaration, one carrier, many readers (BI-D9F158AF, EP-A33A5C61 slice 4, founder direction 2026-09-08). Governance metadata for a model is declared ONCE as a /// @dpf documentation line directly above the model block and converged into the Postgres catalog as COMMENT ON TABLE "X" IS 'dpf:{...}' at every portal boot (packages/db/scripts/apply-model-metadata-comments.ts, called from scripts/portal-migrate-boot.sh right after prisma migrate deploy). The parser, vocabularies and carrier format are in packages/db/src/model-metadata.ts.

/// @dpf lifecycle=telemetry-bounded retention=365d sensitivity=internal categories=telemetry,security-audit owner=platform-architecture steward=data-steward timeAxis=createdAt
model ToolExecution { ... }

The steward watches growth, not only structure

Nightly growth pass (BI-592F1E7E, EP-A33A5C61 slice 5). After the structural drift detectors, the Data Architect steward samples every table from pg_class / pg_stat_user_tables into TableGrowthSample (heap, index, TOAST, live and dead tuples, rows in the last 24 h on the model’s declared timeAxis) and runs two detectors (apps/web/lib/ea/table-growth.ts):

Findings reconcile into EaConformanceIssue beside the structural ones (visible on /ea/data-model). A finding that stays open for three consecutive nightly samples is filed once as a fingerprinted backlog item with source data-growth (captureCorrectiveFailureBI), owned by AGT-BUILD-DA, and re-observed nightly until it clears. The sample table is itself tagged telemetry-bounded retention=90d, so the steward’s telemetry is governed by the mechanism it enforces.

Retention floors come from the obligations that bind this install

Derived, not hardcoded (BI-69C29492, EP-A33A5C61 slice 6). A retention window may only ever LENGTHEN: effective = max(base, industry floor, obligation floor, processing-activity floor). The obligation floor is the one derived from the compliance plane:

  1. Obligation.retentionMinimumDays and Obligation.retentionFloorBuckets carry the duration a regulator states. Before this, durations existed only as prose in description (“wage records … for at least three years”) and no engine could read them.
  2. apps/web/lib/operate/retention/obligation-floors.ts reads the minimums of the obligations whose regulation applies to this install, using the existing classifier (compliance-library.ts), which already scopes by archetype and jurisdiction. There is one answer to “what binds us”, not two.
  3. The sweep max()es that against the base window. Each floor carries its citation, so a sweep can name the regulator behind a lengthened window.

The screen must not describe a narrower rule than the sweep enforces. formatRetentionBuckets renders an unnamed bucket set as “all categories” for the same reason foldObligationFloors treats it as binding every bucket. If one of those two readings changes, both change.

Evidence payloads never live inline

The ceiling (BI-39AAE9B8, EP-A33A5C61 slice 2). A ledger row records that something happened and what it carried, by digest. Any string leaf above EVIDENCE_INLINE_CEILING_BYTES (64 KB, apps/web/lib/evidence/bounded-evidence-output.ts) leaves the JSON column:

Retiring a model nothing reads

How a model leaves the schema (BI-911840CB). The prismaModelCount ratchet makes every new model owned debt, and a retirement is how that debt is paid back. Show that no live code path reads or writes the model (the Prisma accessor, its back-relation fields, and raw SQL against the table), then land in one PR:

Task-specific operating rules

Read these when this domain is touched. These statements are relocated from AGENTS.md; its invariant core still applies.