Architecture & engineering

Custom Objects: Build Plan & Sizing

Companion to ADR-001-object-registry-system-and-custom-objects.md — what the decision costs to build, sliced and sequenced. Status: ESTIMATE (pre-spec) · 2026-07-07 · Assumes one senior dev familiar with the repo. Cross-refs: platform-readiness-scorecard.md (#8), public-api-external-tenant-hardening.md (F2 prerequisite)

The discovery that shrinks the estimate

The hardest third of a custom-objects engine — the field-definition machinery — already exists in production:

  • custom_field_definitions (supabase/migrations/20260213000001_shared-baseline.sql): account-scoped, field_key, field_type (text / number / date / boolean / email / url / array, select options[]), validation_rules jsonb, is_required, defaults, help text, placeholders.
  • custom_field_category_mappings with an entity_type column, plus the applies_to migrations (20260615070121, 20260616025551) — the schema already anticipates fields attaching to multiple entity kinds.

So "let a client define typed fields on a new thing" is not a new engine — it is an applies_to extension. This removes ~1–2 weeks and most of the definition-editor UI risk from the naive estimate.

Calibration anchors (our own velocity)

Benefits catalogue (20260610134107) and contact relationships (20260509000002) — comparable single features with schema + service + UI + API — each shipped within roughly a fortnight's window in May/June 2026. The slices below are sized against that observed velocity, not wishful thinking.

Slices

SliceScopeSizeNotes
0 · Relationship-type custom fields (ADR Decision 5)Extend applies_to to relationship types; render typed fields on crm_contact_relationships records; expose via relationships API~1 weekField engine reused as-is. The proof-run: ships client-visible value (typed mentorship data instead of metadata jsonb smuggling) and de-risks every later slice
1 · Custom objects coreThin object_definitions (name, plural, relations — fields ride the existing engine); object_records (JSONB + GIN index + extracted contact FK); generic CRUD/search service validating against definitions; /v1/crm/objects/{type} gateway routes; crm.{type}.{verb} scopes; crm.object.{type}.* webhook events~2.5–3 weeksValidation, scope matching, response envelope, audit and metering are all inherited from the gateway; the genuinely new code is the generic service + routes
2 · Registry (minimal)object_registry table; register benefits / relationships / contacts as system entries; custom types auto-register; OpenAPI enumerates registered types~1 weekSystem-object route aliases deferred — bespoke routes stay canonical in v1 (per ADR-001 Decision 3, aliases are additive later)
3 · Platform integrationWorkflows triggers on object events (~3–4 days, Inngest plumbing exists); segments filtering on object JSONB (~1 week)~1.5–2 weeksBoth ride existing engines
4 · Generic admin UIAuto-generated list/detail/edit views for any object type in the CRM app2–4 weeksThe big variable. Deliberately deferred: API-only first — client developers (AHRI premise) build their own UI. Buy this only when a non-developer client asks

Totals

PackageSlicesEstimate
MVP — "model your own concepts" is true0 + 1 + 2~4–6 weeks
Full platform integration+ 3~6–8 weeks
With generic admin UI+ 4~9–12 weeks (not recommended yet)

Prerequisites & sequencing

  1. F2 (RLS backstop / tenancy invariants) lands first. Custom objects are Agend-native data — no Upbeat dependency, no write-back dependency — so external keys can get write access to their own object records early. That makes object_records the first table an external key writes to, which is precisely why the tenancy hardening (hardening findings P1s) gates this work.
  2. Slice 0 is independently shippable and useful even if everything after it pauses — the right first bet.
  3. Custom objects and the write-back programme (writeback-two-way-sync.md) run on independent tracks; neither blocks the other.

Known inflation risks (budgeted, not ignored)

RiskBudget
Per-account dynamic OpenAPI — /v1/openapi currently builds from static domain registrations; enumerating client-defined types per account is a design wrinkle+2–3 days in slice 1
Quota/abuse design — per-account caps on definitions, fields, records; easy to skip, painful to retrofit+1–2 days in slice 1
api_slug collision policy vs existing system routes (ADR-001 open question)decide in slice 2, cheap if decided early
Scope catalogue UX — key-issuance UI must list dynamic crm.{type}.* scopes per account+1–2 days in slice 1

Slice 0 story sketch (pickup-ready seed)

Story: As an association admin, I can add typed fields to a relationship type (e.g. cohort: select, sessions_completed: number on "Mentor of / Mentee of"), and those fields are validated, stored on the relationship record, visible in the CRM UI, and returned by the relationships API.

Touches: custom_field_definitions.applies_to (+ mapping rows for relationship types) · relationship record create/update validation path · relationship drawer UI (render dynamic fields) · relationships API response + OpenAPI.

Acceptance: field definable per relationship type (not globally); records reject invalid values; existing records without the new fields remain valid; API returns typed values (not smuggled metadata); segments can filter on at least one typed field (stretch — may move to slice 3).

Out of scope: new field types; changes to crm_relationship_types semantics (labels, symmetry untouched).