document: architecture-decision-record version: 1.0 project: Agend Platform (agend-dashboard) created: 2026-07-07 status: Accepted makerkit: true upstream:
- writeback-two-way-sync.md
- docs/features/drivers/product-vision-agend-as-crm-value-layer.md
- ahri-build-on-agend-overview.html (Build on Agend partner overview)
ADR-001: System Objects and Custom Objects Behind One Object Registry
Document References
- Benefits schema:
supabase/migrations/20260610134107_crm-benefits-catalogue.sql(crm_benefits,crm_tier_benefits) - Relationships schema:
supabase/migrations/20260509000002_crm-contact-relationships.sql(crm_relationship_types,crm_contact_relationships) - Entitlement runtime:
packages/@agend/entitlements(check-entitlement.ts),@agend/member-gatescapability registry - Public API gateway:
apps/api(/v1/**), scope model<app>.<resource>.<verb>inpackages/@agend/public-api - Strategic context: Build on Agend program — client self-development on the public API, AHRI first
- Build plan & sizing:
custom-objects-build-plan.md(companion — slices, estimates, prerequisites)
Executive Summary
To honestly claim "clients can build any feature" on the Agend platform, clients need to model their own concepts (mentoring programs, award nominations, special-interest groups) without Agend schema work. We will not replace the existing behaviour-rich features (benefits, contact relationships) with a generic custom-objects engine. Instead we keep them as system objects, add a client-defined custom-objects engine alongside them, and unify both behind a single object registry so the public API, scopes, segments, workflows and widgets treat every object type uniformly. Custom object types that become load-bearing can be graduated to system objects without breaking the API contract.
Base Platform
This is the existing Makerkit-based (next-supabase-turbo) federated monorepo. Nothing in this ADR rebuilds Makerkit capabilities: accounts, memberships, roles/permissions, billing and the per-tenant api_keys model are used as-is. All new tables follow the established account-scoping pattern (account_id uuid NOT NULL REFERENCES accounts(id)).
What we are NOT building
- ❌ A replacement for
crm_benefits/crm_tier_benefits - ❌ A replacement for
crm_relationship_types/crm_contact_relationships - ❌ A migration of any shipped feature onto generic storage
- ❌ New auth, tenancy or key machinery (the unified API key + scope model already covers new object routes)
What we ARE building
- ✅
object_registry— one catalogue over system and custom object types - ✅
object_definitions/object_records— the client-defined custom-objects engine - ✅ Uniform API surface
/v1/crm/objects/{type}driven by the registry - ✅ A graduation path from custom to system object
- ✅ Near-term: per-type custom fields on relationship types
Technical Decisions
Decision 1: Keep benefits and relationships as system objects (do not genericise)
Context: Benefits and contact relationships are both structurally "objects related to contacts", which invites the tidy-looking consolidation onto one generic engine. Both are shipped, load-bearing production features.
Options considered:
| Option | Pros | Cons |
|---|---|---|
| A. Replace both with a generic custom-objects engine | One storage model; conceptual purity | Loses SQL-level guarantees (no_self_link, unique_pair_type CHECK/UNIQUE constraints become app code); loses FK integrity and native indexes on hot paths (entitlement checks run per gated request); gate_key → @agend/member-gates behaviour coupling cannot be expressed declaratively; high-risk rebuild of working features; classic second-system trap |
| B. Keep them native; add custom objects alongside; unify via registry | Zero migration risk; keeps behaviour and performance; matches the pattern every mature platform converged on (Salesforce standard vs custom objects, HubSpot core vs custom objects, Shopify products vs metaobjects) | Two storage models to maintain; registry layer must be kept honest |
| C. Do nothing (custom fields + relationships are enough) | No work | Fails the "any feature" platform test: clients cannot model their own entities; long-tail feature requests remain an Agend queue |
Decision: Option B.
Rationale: Behaviour-rich core concepts and client-defined concepts have genuinely different needs. Benefits carry code-wired behaviour (gating, limits, MRR); relationships carry SQL-enforced semantics (inverse labels, symmetry, pair-type uniqueness). Generic storage would re-implement those guarantees as application logic, trading correctness and performance for purity.
Consequences:
- ✅ Shipped features stay untouched; no regression surface
- ✅ Benefits and relationships become the first two proof-cases registered as system objects
- ⚠️ Two storage models exist by design; the registry is the single source of truth for "what objects exist"
- 🔒 Risk: registry drift (an object type that behaves differently from its registry description). Mitigation: registry entries are code-reviewed alongside the features they describe; contract tests assert every registry entry answers the uniform API.
Decision 2: Add a client-defined custom-objects engine
Context: The platform ladder observed in our own schema: custom fields let clients author values; benefits let them author records of an Agend-shaped type; relationship types let them author edge types of an Agend-shaped edge. The missing rung is authoring the type itself.
Decision: Build object_definitions (client-authored type: name, plural, description, relations to contacts and other object types) and object_records (JSONB payload validated against the type's field set, with extracted/indexed relation columns). Field definitions are not re-invented: they attach to object types through the existing custom_field_definitions engine and its applies_to/entity_type mapping (see Amendment A1).
Rationale: This is the union of two patterns already proven in production: record-authoring (benefits) and type-authoring (crm_relationship_types, including its is_system flag, account scoping and uniqueness discipline). It is a well-trodden build (HubSpot custom objects, Salesforce objects, Shopify metaobjects), not a research project.
Consequences:
- ✅ Clients (AHRI first) model their own programs without Agend schema work — closes the biggest gap in the "build anything" claim
- ✅ Client-owned data becomes visible to platform segments, workflows and reporting (unlike the current workaround of client-side WordPress tables keyed by contact id)
- ⚠️ JSONB/EAV storage is inherently slower and looser than native tables — acceptable for long-tail concepts, and the graduation path (Decision 4) is the pressure valve
- 🔒 Risk: unbounded client schemas. Mitigation: per-account limits on definitions/fields/records, enforced at the definition API.
Decision 3: One object registry; uniform API, scopes, segments, workflows, widgets
Context: Consumers (client developers, widgets, workflows) should not need to know whether an object is system or custom.
Decision: An object_registry catalogue where every object type is one row: system objects point at their dedicated tables and hand-written services; custom objects point at the generic engine. All platform capabilities resolve object types through the registry:
- API:
/v1/crm/objects/{type}(list/get/create/update/delete/search), same envelope and pagination as existing/v1routes; system objects may additionally keep their existing bespoke routes (/v1/crm/tiersetc.) as aliases - Scopes:
crm.{type}.{verb}falls out of the existing<app>.<resource>.<verb>scheme with zero new auth machinery - Segments, workflows, widgets: trigger on and filter by any registered type
Rationale: This is the Salesforce describe/metadata model: uniform interface, heterogeneous storage. It converts two shipped features plus a new engine into one coherent platform surface.
Consequences:
- ✅ One documented pattern for client developers; the OpenAPI spec enumerates object types per account from the registry
- ✅ Uniform audit/metering: object routes inherit the gateway's existing key, scope, entitlement, audit and billing middleware
- ⚠️ The registry becomes critical infrastructure; it needs its own migrations discipline and caching
Decision 4: Graduation path (custom → system)
Decision: When a custom object type becomes hot-path or needs code-wired behaviour, Agend may promote it: build a dedicated table and service, migrate records, flip the registry row's storage pointer and is_system flag. The API contract (/v1/crm/objects/{type}, scopes, webhooks) is unchanged; clients feel nothing.
Consequences:
- ✅ De-risks the JSONB performance trade-off; product discovery can happen in custom objects before engineering investment
- 🔒 Graduations are one-way and require a migration plan per type (records, indexes, in-flight workflow references).
Decision 5 (near-term enhancement): per-type custom fields on relationship types
Context: A "Mentor of / Mentee of" relationship type is definable today, but its programme data (cohort, session count) can only be smuggled into the untyped metadata jsonb on crm_contact_relationships — invisible to segments and unvalidated.
Decision: Reuse the existing custom-fields machinery to let a relationship type declare typed fields that its relationship records carry.
Rationale: Enhances both shipped features, replaces neither, and exercises the applies_to extension of the existing field engine (custom_field_definitions) that Decision 2 reuses — the engine itself already exists in production (Amendment A1). Natural first slice of the programme.
Technical Decisions Log
| Area | Decision | Rationale | Alternatives rejected |
|---|---|---|---|
| Existing features | Keep benefits + relationships native (system objects) | Behaviour coupling, SQL guarantees, hot-path performance | Full genericisation; status quo |
| Extensibility engine | object_definitions + object_records (JSONB + relation columns) | Union of two proven in-house patterns; industry-standard shape | Client-side-only storage (invisible to platform); per-client bespoke tables (queue returns) |
| Unification | object_registry over both storage models | Uniform API/scopes/workflows without rebuild | Uniform storage (rejected per Decision 1) |
| Evolution | Graduation path custom → system | Pressure valve for performance/behaviour needs | Permanent generic storage; premature native builds |
| Sequencing | Relationship-type custom fields first | Builds the field engine on a shipped feature | Big-bang custom objects |
Data Model (sketch)
object_registry
├── id: text (PK, e.g. 'benefit', 'relationship', 'mentorship')
├── account_id: uuid | null -- null = platform-wide system type
├── is_system: boolean
├── storage: 'native' | 'generic'
├── native_table: text | null -- e.g. 'crm_benefits'
├── definition_id: uuid | null -- FK → object_definitions (generic types)
└── api_slug: text -- /v1/crm/objects/{api_slug}
object_definitions -- client-authored types
├── id: uuid (PK)
├── account_id: uuid (FK → accounts)
├── name / plural_name / description
├── relations: jsonb -- [{key, target: 'contact' | object type, cardinality}]
└── is_active, created_at, updated_at
(typed fields attach via the EXISTING custom_field_definitions
engine + applies_to/entity_type mapping — no new field schema here)
object_records
├── id: uuid (PK)
├── account_id: uuid (FK → accounts)
├── definition_id: uuid (FK → object_definitions)
├── data: jsonb -- validated against definition.fields (GIN-indexed)
├── contact_id: uuid | null -- extracted primary contact relation, FK + index
└── created_at / updated_at / created_by
System tables (crm_benefits, crm_tier_benefits, crm_relationship_types, crm_contact_relationships) are unchanged; they are described by the registry, not moved into it.
Integration Points
| Surface | Integration |
|---|---|
Public API (apps/api) | /v1/crm/objects/{type} routes resolved via registry; OpenAPI generated per account |
| Scopes / keys | crm.{type}.{verb} in the existing unified-key scope model |
| Webhooks | crm.object.{type}.created/updated/deleted events via @agend/webhooks |
Workflows (apps/workflows) | Registry-listed types available as triggers and actions |
| Upbeat sync | Out of scope: source drivers only touch system entities; custom objects are Agend-native data (see writeback-two-way-sync.md) |
Security Considerations
- RLS on
object_definitions/object_recordsfrom day one (account-scoped, matching existing table patterns) - Gateway object routes must scope by
auth.context.accountIdlike all handlers; note the platform-wide follow-up that gateway routes run on the RLS-bypassing admin client — an RLS backstop or invariant tests are a prerequisite for external write access (also flagged in writeback-two-way-sync.md §8) - Definition API validates field keys/types against an allowlist (no arbitrary SQL, no reserved keys)
- Per-account quotas on definitions, fields and records
Performance Considerations
- GIN index on
object_records.data; extracted FK column for the primary contact relation -
indexed: truefields materialised into a side index table if query patterns demand it - Hot-path features stay native (that is the point of Decision 1); graduation path covers custom types that get hot
Open Technical Questions
- Where does the registry live relative to the CRM app vs the gateway (package boundary)?
- Do custom objects appear in the CRM admin UI generically (auto-generated list/detail views) in v1, or API-only first?
- Event volume: do
object_recordswrites emit workflow events by default or opt-in (flood-guard precedent:UPBEAT_INGEST_EMIT_EVENTS)? - Naming collision policy between account-defined
api_slugand existing system routes
Validation Checklist
- All major decisions documented with rationale and rejected alternatives
- Existing (Makerkit + shipped Agend) capability vs new build clearly distinguished
- Data model sketched for new entities; shipped tables explicitly untouched
- Integration points identified against the live gateway/scope/webhook machinery
- Security and performance considerations recorded, including the known gateway RLS caveat
- Aligns with the Build on Agend platform strategy (client self-development, AHRI first)
Context Summary (handoff condensate)
Project type: Makerkit-based federated monorepo (agend-dashboard); this ADR covers the platform object model.
Problem in one sentence: Clients building on the public API cannot model their own entities, which caps the "build any feature" platform claim.
Key decisions: (1) benefits + relationships stay native system objects; (2) new object_definitions/object_records engine for client-defined types; (3) one object_registry unifies both behind /v1/crm/objects/{type} + existing scope model; (4) custom types can graduate to system; (5) first slice = per-type custom fields on relationship types.
Constraints to honour: no migration of shipped tables; account scoping + RLS patterns as per existing migrations; gateway RLS-backstop prerequisite before external writes; per-account quotas.
Out of scope: replacing any shipped feature; syncing custom objects to Upbeat; admin UI auto-generation (open question).
Next step: spec the Decision 5 slice (relationship-type custom fields), then the registry + definitions engine.
Amendments
- A1 (2026-07-07, same day): The sizing investigation (
custom-objects-build-plan.md) found the field-definition engine already exists in production:custom_field_definitions(typed fields,options[],validation_rules jsonb, required/defaults) plus theapplies_to/entity_typemapping (supabase/migrations/20260213000001_shared-baseline.sql,20260615070121,20260616025551). Decision 2's field machinery is therefore a reuse, not a build, and Decision 5 extendsapplies_torather than constructing a new engine. Decision 2, Decision 5 and the data-model sketch were corrected accordingly; estimates live in the build plan.
This ADR captures the object-model decision for the Build on Agend platform strategy. Changes should be documented as amendments or a superseding ADR.