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, selectoptions[]),validation_rules jsonb,is_required, defaults, help text, placeholders.custom_field_category_mappingswith anentity_typecolumn, plus theapplies_tomigrations (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
| Slice | Scope | Size | Notes |
|---|---|---|---|
| 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 week | Field 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 core | Thin 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 weeks | Validation, 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 week | System-object route aliases deferred — bespoke routes stay canonical in v1 (per ADR-001 Decision 3, aliases are additive later) |
| 3 · Platform integration | Workflows triggers on object events (~3–4 days, Inngest plumbing exists); segments filtering on object JSONB (~1 week) | ~1.5–2 weeks | Both ride existing engines |
| 4 · Generic admin UI | Auto-generated list/detail/edit views for any object type in the CRM app | 2–4 weeks | The 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
| Package | Slices | Estimate |
|---|---|---|
| MVP — "model your own concepts" is true | 0 + 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
- 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_recordsthe first table an external key writes to, which is precisely why the tenancy hardening (hardening findings P1s) gates this work. - Slice 0 is independently shippable and useful even if everything after it pauses — the right first bet.
- 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)
| Risk | Budget |
|---|---|
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).