Custom fields are the usual ceiling on runtime schema changes. Actionary lifts that ceiling. A platform admin defines entirely new entities through the UI and the rest of the product notices.
The entities, entity_fields, entity_config metadata layer is the source of truth for the SPA renderer, the admin REST API, the MCP tool surface, the workflow engine, the search index, the audit log, and the CI gates that keep the whole thing coherent. Add a Warranty Claims entity through Schema Admin: the list view renders, the modal forms render, the polymorphic pickers offer it, the agent’s tool catalog grows a list_warranty_claims shape, the workflow builder can trigger on it, the portal can accept form submissions against it. It ships as a data change through the admin UI. The app deploy is untouched.
This is the moat. Every agent capability on the /product/agent page, every signal on /product/signals, every workflow on /product/workflows rests on it.
One entity_config payload, four consumers
GET /entity-configs/{slug} returns a JSON Schema with oneOf shapes for many-to-many fields, render_kind per field, FK targets, enum options, and required-field flags. Four independent surfaces read the same payload:
- The SPA’s
EntityManagerdispatches onrender_kindto render list pages, detail modals, and forms. - The admin REST API routes read
entity_configto validate writes and shape responses. - The MCP tool surface exposes
get_entity_configandlist_entities(include_catalog=true)— the exact payload the embedded agent and any outside MCP client consume. - The agent’s per-turn prompt injects a slim per-tenant catalog memoised by
(tenant, schema_version)withMAX(updated_at)invalidation, so it stops spending orientation tool calls on cold conversations.
The agent reads the same schema every other surface reads. Adding a field surfaces in every configurator automatically because there is nothing to update.
Schema Admin stamps physical schema and metadata together
When a platform admin adds an entity, a single transaction applies a migration to the physical database AND stamps the entity_fields metadata rows. A new column exists on disk and in the metadata layer at the same commit. Coherence is a transaction boundary; the database enforces it directly, so no background reconciler needs to catch up.
Every entity table follows one canonical shape stamped by _ensure_base_table: id BIGSERIAL, tenant_id UUID, owner_id UUID, created_by UUID, created_at / updated_at TIMESTAMPTZ, deleted_at TIMESTAMPTZ NULL. Three companion indexes. The ownership_mode metadata column on entities decides how the router treats owner_id — single stamps the caller as owner on create, none skips the default. Migrations that carry tenant_id without the audit trio fail CI at scripts/lint_migration_policy.py.
The Generic Repository reads shape from entity_fields
Two categories of aggregate live behind the code. Entity-registered aggregates — accounts, contacts, deals, and any bespoke entity Schema Admin adds — share one Generic Repository at entity_repository.py that reads shape from entity_fields and composes SQL against the physical columns Schema Admin stamped. Adding a new entity leaves the repository count unchanged. System aggregates — partners, inbound forms, deploy history, schema migrations, audit log — get one bespoke repository each.
Sixty-plus repositories under one directory. Every db.execute_* call lives inside services/core_api/infra/repositories/; routers, application services, and helpers call repository methods, and the database sits behind that boundary. The rule is enforced by scripts/lint_no_inline_sql.py against a per-file budget at db/inline_sql_budget.json. .github/workflows/inline_sql_budget.yml runs on every PR that touches services/core_api/**.py. A regression cannot ship.
Cross-cutting concerns land as sidecars
Two polymorphic sidecars keyed on (tenant_id, entity_slug, record_id) carry cross-cutting state on their own tables, so every entity table stays clean:
record_sync_state— external-system synchronisation metadata across every entity that syncs outbound. One shape,LEFT JOINread pattern.record_currency_conversion— converted-to-functional-currency representation of money amounts across every entity that holds money.
entity_relations carries a JSONB attributes column so any many-to-many edge holds arbitrary typed data — {"role": "billing_contact"}, {"weight": 0.8} — and one edge table serves every relationship type. record_access is the single polymorphic grant primitive shared by every share flow: grantee_type in user | team | department | role | tenant | partner, live-row WHERE revoked_at IS NULL partial unique index. One grant primitive for internal collaborators, teams, roles, whole tenants, and external channel partners.
Three logging entities, attachable by one checkbox
Activities, Tasks, and Interactions are first-class entities. Any other entity in the system — built-in or admin-created — opts into being “loggable against” by ticking Enable Activities, Enable Tasks, or Enable Interactions in Schema Admin. The moment the flag flips, that entity appears in the logging entity’s Related To picker, its record-detail modal grows a timeline panel, and the agent’s links array surfaces those records with the right role. It ships as a metadata edit; the app deploy is untouched. The polymorphic FK pair on the logging row accepts any (entity_slug, record_id); the visible picker respects three composing gates — capability flag (metadata), module enablement (commercial), per-role read scope (permission).
The Revenue module collapses the twenty-year leads-plus-opportunities split into one unified deals entity with six seeded stages, three first-class lookups (deal_sources, deal_types, lost_reasons), and a canonical is_closed + is_won pair as the single source of truth for status.
An in-app runner with sha256 drift detection
Migrations are numbered SQL files under db/migrations/postgres/. Platform admins apply them through /platform/operations/db-admin. The runner reads files from disk, computes sha256, applies under pg_advisory_xact_lock, and records every apply in schema_migrations with content hash, duration, applying user, and stdout. Sha256 mismatch flags drift; the runner refuses to silently re-run an edited file. CREATE INDEX CONCURRENTLY routes outside the advisory transaction because Postgres requires it. The statement splitter respects strings, dollar-quoted bodies, and comments.
Every migration declares a phase: additive, expand, switch, contract, data. The deploy pipeline runs the pre-roll set (additive / expand / switch / data) before the rolling app cutover and the post-roll set (contract) after. Destructive verbs live only in contract. A column rename is three migrations across two deploys — the industry-standard expand/contract discipline from Refactoring Databases (Ambler & Sadalage, 2006), applied cleanly. Deeper reasoning at /trust/design-principles.
The /platform/entity-metadata/health surface reads the physical schema back against entity_fields and reports any drift — a column that exists on disk but not in metadata, or the reverse — with a one-click self-heal action for the trivially-recoverable cases.
The takeaway
A tenant adds a Warranty Claims entity through Schema Admin as a data change. The list view renders because EntityManager dispatches on render_kind. The forms render for the same reason. The polymorphic pickers offer it because the logging capability flags flipped. The agent sees list_warranty_claims, create_warranty_claim, update_warranty_claim in its MCP catalog because the tool catalog is generated from entity_config. The workflow builder can trigger on it. The portal can accept submissions against it. The FK columns render as human labels through the same hydrator every list uses. The search-and-filter chips work because pg_trgm reads searchable + is_label off the metadata.
One source of truth, four consumers, zero drift. That makes every downstream capability — the ones the CTO evaluation page walks through end-to-end — a property of the substrate. Every capability the product ships follows from it.