Skip to content

Relationships and Lookups

This page explains how objects reference each other in Henceforth. You author a relationship once, as a *_relationship.json5 file, and the compiler synthesizes the underlying lookup field(s) on the participating objects – you never hand-author a raw lookup field. Rather than offering rigid relationship types with bundled behaviors, Henceforth gives every relationship a set of independent, composable properties. You control whether the reference is required, what happens when the referenced record is deleted, and whether the relationship is one-to-one – each decision independent of the others.

Relationships also support inheritance-aware targeting and polymorphic (multi-target) references, and rollup fields provide aggregation across any to-one relationship.

Field-level fieldKind: "lookup" authoring is not available. A hand-authored *_field.json5 with fieldKind: "lookup" is rejected by the loader with a compile error directing you here. FieldDefinition::Lookup still exists internally – it is the synthesis target every managed relationship compiles down to, and it is still what a lookup field looks like on the read/API side (see FieldDefinition: Lookup) – but you only ever produce one by authoring a relationship.

A single-target, to-one relationship stores a UUID reference to a record in another object. When you define a manyToOne relationship from Contact to Account, each contact record can reference one account. The compiler enforces this with a real foreign key at the database level – not just an application convention.

Here is a relationship definition for a Contact’s parent account:

crm_contactAccount_relationship.json5
{
relationshipKey: "contactAccount",
label: "Account",
from: "crm.Contact",
to: "crm.Account",
cardinality: "manyToOne",
fromField: "account",
}

This guide uses a crm module for its examples. The core module is builtin and authored in Rust, not from files – see dev/metadata/README.md.

This compiles to:

ALTER TABLE "crm"."contact"
ADD COLUMN "account_id" UUID
REFERENCES "crm"."account"("id")
ON DELETE SET NULL;
CREATE INDEX "idx_crm__contact__account_id"
ON "crm"."contact"("account_id");

Every to-one relationship produces three database artifacts on the from object’s table: a UUID column (named {fromField}_id), a foreign key constraint to the to object’s table, and a B-tree index on the FK column.

toField declares an inverse projection on the to object – e.g., “an Account has Contacts.” Unlike the field-level metadata it replaces, toField is optional and synthesizes a real, queryable field (a RelatedRecords field) on the target object, not just descriptive documentation:

crm_contactAccount_relationship.json5
{
relationshipKey: "contactAccount",
label: "Account",
from: "crm.Contact",
to: "crm.Account",
cardinality: "manyToOne",
fromField: "account",
toField: "contacts", // Account.contacts becomes a real, queryable field
}

Omit toField when nothing needs to navigate from the parent back to its children – it costs nothing to add later, since it only adds a field, never changes the FK column itself.

In platforms like Salesforce, choosing between “Lookup” and “Master/Detail” bundles several independent decisions into a single choice. Henceforth unbundles them:

Concern Property Default
Is the parent required? required false
What happens on parent delete? onTargetDelete (nested under to) derived from required
Is this a one-to-one relationship? cardinality: "oneToOne" manyToOne

Each property is independent. Any combination is valid (with one exception, noted below).

Reparenting lock not yet exposed. The old field-level mutable: false (locking a reference at creation time via a BEFORE UPDATE trigger) has no managed-relationship equivalent yet – every synthesized lookup is mutable: true internally, with no way to opt out from a *_relationship.json5 file. If your migration from a raw lookup relied on mutable: false, that specific guarantee is not preserved by the managed layer today.

When required: true, the FK column is NOT NULL. Every record must reference a parent – the API rejects creates and updates that omit or null the field.

When required: false (the default), the reference is optional. Records can exist without a parent.

onTargetDelete, declared on the to endpoint using the nested map form, controls what happens to child records when the referenced parent is deleted:

crm_contactAccount_relationship.json5
{
relationshipKey: "contactAccount",
label: "Account",
from: "crm.Contact",
to: { object: "crm.Account", onTargetDelete: "cascade" },
cardinality: "manyToOne",
fromField: "account",
}
Value Effect
restrict Blocks the parent delete. The operation fails if any child references the parent.
setNull Clears the FK on all children. The children remain, but their reference becomes null.
cascade Deletes all children along with the parent.

These behaviors are enforced by the foreign key constraint the compiler generates from this property, not by an application-level check – if a restrict FK blocks a delete, PostgreSQL returns the error.

When not specified, the default is derived from required:

  • Optional relationships (required: false) default to setNull – clearing the reference is safe.
  • Required relationships (required: true) default to restrictsetNull would violate the NOT NULL constraint.

Invalid combination: required: true with onTargetDelete: "setNull" is contradictory. The compiler auto-corrects it to restrict and emits a warning diagnostic. For details, see Diagnostics.

See Relationship Traits: cascadeDelete below for the full nested-map syntax, including the junction (M:N) case where both endpoints independently accept onTargetDelete.

Set cardinality: "oneToOne" (instead of "manyToOne") to add a UNIQUE constraint on the FK column, making it a one-to-one relationship. Each parent record can then have at most one child with this relationship.

Different combinations of these properties express different relationship semantics. Here are common patterns and their Henceforth equivalents:

Pattern required onTargetDelete cardinality Notes
Optional reference false setNull manyToOne The most permissive. Records can exist without a parent.
Required parent true restrict manyToOne Every child must have a parent, but the parent cannot be deleted while children exist.
Parent owns child true cascade manyToOne Deleting the parent deletes all children. Closest analog to a Salesforce Master/Detail – reparenting-lock is not yet available (see the callout above).
One-to-one false setNull oneToOne At most one child per parent. Useful for extension-style relationships.

The key insight is that these are not four “types” – they are points in a continuous space of property combinations. You can mix and match freely. A required: true, onTargetDelete: "restrict" relationship (required parent, but the parent can’t be deleted out from under it) is something Salesforce literally cannot express.

When you target a base type, the FK automatically accepts all derived types. This is a natural consequence of class-table inheritance – every CreditCard and BankTransfer record has a row in the PaymentMethod table, so an FK to crm.payment_method(id) accepts them all.

crm_orderPaymentMethod_relationship.json5
{
relationshipKey: "orderPaymentMethod",
label: "Payment Method",
from: "crm.Order",
to: { object: "crm.PaymentMethod", onTargetDelete: "restrict" },
cardinality: "manyToOne",
fromField: "paymentMethod",
toField: "orders",
required: true,
}

This compiles to REFERENCES "crm"."payment_method"("id"). PostgreSQL enforces it natively. No discriminator column, no service-layer validation, no performance penalty.

You control the scope through your to target:

  • to: "crm.PaymentMethod" – accepts PaymentMethod and all derived types (CreditCard, BankTransfer, and future subtypes)
  • to: "crm.BankTransfer" – accepts BankTransfer only (and its subtypes, if any)

When a relationship needs to reference records from several unrelated objects, to accepts an array instead of a single object name:

crm_commentParent_relationship.json5
{
relationshipKey: "commentParent",
label: "Related To",
from: "crm.Comment",
to: ["crm.Account", "crm.Contact", "crm.Order"],
cardinality: "manyToOne",
fromField: "parent",
required: true,
}

The compiler generates an FK to hf.object(id) – the universal root table. Every record has a row in hf.object, so the FK is always valid. The service layer restricts writes: when creating or updating a comment, the runtime checks that the referenced record’s object_type is one of the allowed targets.

Polymorphic relationships have the same physical shape as any other to-one relationship – a UUID column, an FK, and an index. The only difference is the FK target and the service-layer type restriction. toField is still available: declaring it synthesizes an inverse RelatedRecords field on every one of the listed targets.

If all targets share a common ancestor, the compiler suggests a tighter FK. For example, if you write to: ["crm.CreditCard", "crm.BankTransfer"] and both extend crm.PaymentMethod, the compiler emits a diagnostic suggesting to: "crm.PaymentMethod" for stronger referential integrity – while noting that this also accepts any future PaymentMethod subtypes.

Rollup fields compute aggregate values across child records. They are a separate field kind, decoupled from relationship type – available on any to-one relationship, not restricted to cascade-delete or master/detail relationships.

crm_Account.contactCount_field.json5
{
fieldKind: "rollup",
fieldKey: "contactCount",
label: "Number of Contacts",
returnType: "int",
relationship: "contacts",
function: "count",
}

The relationship property references the toField value declared on the relevant relationship file – in this case, the contacts inverse from the Contact-to-Account relationship above. The compiler generates a stored column on the Account table and a trigger on the Contact table that keeps the count synchronized on every insert, update, delete, and reparent.

Five aggregation functions are available: count, sum, min, max, avg. For sum, min, max, and avg, you also specify sourceField – the field on the child object to aggregate.

For the full property reference, see FieldDefinition: Rollup.

When you create or update a record with a to-one relationship field, the API validates:

  1. The value must be a valid UUID (or null, if the field is optional).
  2. The referenced record must exist in hf.object.
  3. For polymorphic relationships, the referenced record’s object_type must be in the allowed set.

The synthesized field appears in API responses as a UUID string under its fromField name:

{
"id": "019502a4-b4e7-7f3a-8c1e-0a1b2c3d4e5f",
"objectType": "crm.Contact",
"firstName": "Jane",
"lastName": "Smith",
"account": "019502a4-a1b2-7c3d-4e5f-6a7b8c9d0e1f"
}

For the full property reference including compilation output and SQL examples, see FieldDefinition: Lookup.

Many-to-Many Relationships: Junction Objects

Section titled “Many-to-Many Relationships: Junction Objects”

When records on both sides of a relationship can reference multiple records on the other side, you need a junction object. Henceforth synthesizes junction objects from *_relationship.json5 files with cardinality: manyToMany. A junction is not a separate file type – it is an ordinary object synthesized at load time and fully queryable, with its own CRUD surface.

crm_accountContacts_relationship.json5
{
relationshipKey: "accountContacts",
label: "Account ↔ Contact",
from: "crm.Account",
to: "crm.Contact",
cardinality: "manyToMany",
fromField: "contacts", // inverse projection on Account (optional)
toField: "accounts", // inverse projection on Contact (optional)
uniquePair: true, // UNIQUE(from_id, to_id) -- prevents duplicate edges
ordered: false, // set true to add a `sortOrder` column
}

The loader synthesizes a junction object whose objectKey defaults to PascalCase(relationshipKey) – here AccountContacts. To override:

{
relationshipKey: "accountContacts",
objectKey: "AccountMembership", // explicit; required when payload field files
... // reference a specific name
}

The compiler emits the junction table with these managed columns:

Plain M:N (cardinality: manyToMany, single-object to):

Column Type Notes
from_id UUID NOT NULL FK → from-object table
to_id UUID NOT NULL FK → to-object table
sort_order INTEGER Only when ordered: true

With uniquePair: true, the compiler adds UNIQUE(from_id, to_id).

Polymorphic M:N (to is an array of objects):

Column Type Notes
from_id UUID NOT NULL FK → from-object table
to_type TEXT NOT NULL Qualified object name, e.g. crm.Contact
to_id UUID NOT NULL FK → hf.object(id)

With uniquePair: true, the compiler adds UNIQUE(from_id, to_type, to_id).

The (to_type, to_id) index on the polymorphic case enables cheap narrowing for typed traversal. The to_type column is a deliberate departure from the inheritance-based polymorphic FK approach: the denormalization is safe because objectType is immutable, and the local column is required for efficient #456 traversal. A mandatory write-boundary check enforces both to_type ∈ target set and to_type == hf.object.object_type[to_id] (the FK alone cannot enforce type).

The table name is {schema}.{snake_object} derived from the module schema and the junction’s objectKey, with the 63-byte PostgreSQL identifier cap applied.

A junction may carry additional edge attributes – for example, role, since, or weight. Author them as ordinary *_field.json5 files targeting the junction’s objectKey:

crm_AccountMembership.role_field.json5
{
fieldKind: "scalar",
fieldKey: "role",
label: "Role",
fieldType: { kind: "text" },
}

The loader rejects any payload field whose logical key (from, to, toType, sortOrder) or derived physical column (from_id, to_id, to_type, sort_order) collides with a managed column.

Use a separate *_seed.json5 file targeting the junction’s objectKey – the same format as any other object’s seed file. The loader resolves junction seed files after junction synthesis, so a <junction>_seed.json5 is valid.

Junction ownership is split into two layers:

  • Authoring provenance: the relationship (ObjectDefinition.owned_by_relationship) is the single source of truth. You cannot author a *_object.json5 whose key shadows a junction key – the loader rejects it with a hard error.
  • Runtime membership: the synthesized object is first-class in module.objects. It has full CRUD, RLS, and query support; it appears in the object list and is a valid to: target for other lookups.

Removing the relationship removes the synthesized object from the desired metadata, but the junction table is not dropped in v1. The apply engine performs orphan cleanup at schema granularity only (DROP SCHEMA for a schema that no module maps to) – it has no per-table drop. Because the owning module schema usually still maps to a loaded module (its other objects remain), the now-unreferenced junction table survives as a table-level orphan, exactly as removing any single object from a multi-object module would. The only way to reclaim it today is to drop the whole owning module schema. Per-table orphan cleanup (vault-gated) is a follow-up.

Supported relationship attributes for junction relationships: description, seed, rules (edge-level validation), label, uniquePair, ordered, fromField, toField, objectKey, sharing. The attributes identity, abstract, and extends have no meaning on a junction object and are silently ignored.

By default, junction edges use the standard owner-only RLS policy — only the principal who created an edge can read it. Add sharing: { edge: "endpoints" } to widen SELECT visibility: a principal may read a junction edge when they can SELECT both endpoint rows.

{
relationshipKey: "accountContacts",
label: "Account ↔ Contact",
from: "crm.Account",
to: "crm.Contact",
cardinality: "manyToMany",
sharing: { edge: "endpoints" },
}

Semantics:

  • Read: a principal sees an edge when they own it or can see both the from-side row and the to-side row. This is the “more restrictive of both masters” rule — the same policy Salesforce applies to junction objects.
  • Write: INSERT/UPDATE/DELETE remain gated by edge owner_id. sharing widens read-time visibility only.
  • Default (sharing absent or edge: "owner"): preserves today’s behavior — only the edge creator can SELECT the edge.

Scope: sharing.edge applies to manyToMany strategies only (junction and polymorphicJunction). Setting it on a lookup or value-set relationship is a compile-time error.

Forward propagation (edges visible to principals who can see the source record) and reverse propagation are tracked in the parent epic.

  • Junction rename (changing objectKey) is not a rename in v1: the new key is treated as a new object, so a fresh table is created and the old table is left behind as a table-level orphan (the engine has no per-table drop). Both coexist until the owning module schema is dropped. Rename detection plus row preservation (ALTER TABLE … RENAME TO …) is tracked in issue #582.
  • Table-level orphan cleanup on object/relationship removal does not exist: only whole orphaned schemas are dropped under --destructive. Tracked as a follow-up to #582.
  • Studio click-through editability (editing junction edge attributes inline) is tracked in issue #583.

*_relationship.json5 files support four independently opt-in traits that layer additional behavior onto any relationship strategy. Traits are declared at the relationship level; they are always false / absent by default so existing files are unaffected.

cascadeDelete: per-endpoint onTargetDelete

Section titled “cascadeDelete: per-endpoint onTargetDelete”

For lookup (N:1) and junction (M:N) relationships, you can declare what happens when a referenced record is deleted — equivalent to ON DELETE in PostgreSQL. The three values are the same as the field-level onParentDelete property: cascade, restrict, or setNull.

For a lookup relationship (the FK lives on the from object pointing to to), declare onTargetDelete on the to endpoint using the nested map form:

// crm_contactAccount_relationship.json5 — cascade when Account is deleted
{
relationshipKey: "contactAccount",
label: "Account",
from: "crm.Contact",
to: { object: "crm.Account", onTargetDelete: "cascade" },
cardinality: "manyToOne",
fromField: "account",
required: true,
}

When the nested map form is used for to, onTargetDelete on the from endpoint is rejected — lookup relationships have only one FK (on the from object targeting to) so from-side delete behavior has no FK to apply to.

For a junction relationship (the junction table has FKs to both endpoints), both endpoints independently accept onTargetDelete:

// crm_accountContacts_relationship.json5 — asymmetric cascade/restrict
{
relationshipKey: "accountContacts",
label: "Account ↔ Contact",
from: { object: "crm.Account", onTargetDelete: "cascade" },
to: { object: "crm.Contact", onTargetDelete: "restrict" },
cardinality: "manyToMany",
fromField: "contacts",
toField: "accounts",
}

This compiles to:

  • from_id UUID NOT NULL REFERENCES crm.account(id) ON DELETE CASCADE
  • to_id UUID NOT NULL REFERENCES crm.contact(id) ON DELETE RESTRICT

Validation rules:

  • onTargetDelete is rejected on value-set targets (no FK exists).
  • setNull is rejected on any junction endpoint or any required: true lookup FK — SET NULL cannot satisfy a NOT NULL constraint.

Bare forms are unchanged. Existing from: "crm.Contact" and to: "crm.Account" syntax continues to work exactly as before. The nested map form is opt-in only.

The temporal: true flag marks a relationship as valid-time-capable. In this version it is a validator gate only — it rejects the flag on strategies that lack a dedicated edge table, reserving the form for when valid-time column support is added in a follow-up issue.

{
relationshipKey: "accountContacts",
label: "Account ↔ Contact",
from: "crm.Account",
to: "crm.Contact",
cardinality: "manyToMany",
temporal: true, // accepted: junction has an edge table
}

temporal: true is accepted for manyToMany relationships (both plain junction and polymorphic junction), because both synthesize a dedicated edge table where valid-time columns will be added. It is rejected for all other strategies (manyToOne, oneToOne, and value-set) since they have no edge table.

No DDL change is emitted today; the flag is accepted and carried forward so existing relationship files do not need updating when the valid-time feature lands.

The sharing block controls SELECT visibility for junction edge rows. Phase 1 exposes only the edge knob; forward and reverse propagation are deferred to a later phase.

Field Type Default Meaning
edge "owner" | "endpoints" "owner" Who can SELECT an edge
  • "owner" — only the edge creator can SELECT the edge (default, unchanged).
  • "endpoints" — any principal who can SELECT both endpoint rows may also SELECT the edge. The edge creator retains access unconditionally.
{
relationshipKey: "accountContacts",
label: "Account ↔ Contact",
from: "crm.Account",
to: "crm.Contact",
cardinality: "manyToMany",
sharing: { edge: "endpoints" },
}

Coherence rule: sharing.edge = "endpoints" is only accepted on manyToMany strategies (plain junction and polymorphic junction). Setting it on any other strategy is a compile-time error: only junction tables have the from_id/to_id endpoint columns required by the policy.

For value-set relationships (to: { values: [...] }) with pick: "one", you can declare a transitions array to enforce a state machine at the write layer:

ticket_ticketStatus_relationship.json5
{
relationshipKey: "ticketStatus",
label: "Status",
from: "ticket.Ticket",
to: {
values: ["open", "inProgress", "closed"],
transitions: [
{ from: "open", to: ["inProgress"] },
{ from: "inProgress", to: ["closed", "open"] },
],
},
fromField: "status", // required: the synthesized column is keyed by this
}

The loader synthesizes a nullable Enum scalar field on the from object (here ticket.Ticket.status). The field is nullable by default — a required enum state field is a separate concern.

Migration note: before this version, pick: "one" value-set relationships synthesized nothing. Adding this relationship file now applies a new nullable column the first time hf apply runs. No data migration is needed (the column is nullable and no existing rows are affected), but the column addition is a DDL change.

Transition enforcement rides the existing server-side validate_enum_transitions path — the same enforcement that applies to authored Enum scalar fields. A PATCH that violates the declared transitions returns 422 INVALID_TRANSITION without any new server code.

fromField is required for stateMachine relationships. The validator emits a hard error if it is absent. transitions declared on a non-pick: "one" or non-value-set target are also rejected with a hard error.

In Salesforce, choosing “Master-Detail” bundles multiple decisions into a single opaque type. In Henceforth, each decision is an independent property. The “master-detail” pattern is a preset — a well-known composition of properties that has no special runtime status:

// Henceforth equivalent of a Salesforce Master-Detail relationship
{
relationshipKey: "contactAccount",
label: "Account",
from: "crm.Contact",
to: { object: "crm.Account", onTargetDelete: "cascade" }, // cascade on parent delete
cardinality: "manyToOne",
fromField: "account",
required: true, // child requires a parent (NOT NULL FK)
}

This expresses most of what Salesforce’s Master-Detail means:

  • The child cannot exist without a parent (required: true).
  • Deleting the parent deletes all children (onTargetDelete: "cascade").
  • Rollup summary fields work on any to-one relationship (not gated behind the relationship type).

One piece is still missing: Salesforce’s Master-Detail also locks reparenting by default. The managed relationship layer has no equivalent yet – see the “Reparenting lock not yet exposed” callout under Composable Properties.

Because each property is independent, you can express asymmetric relationships that Salesforce cannot. A required: true, onTargetDelete: "restrict" lookup requires a parent but blocks parent deletion rather than cascading.

With objects, fields, and relationships defined in metadata, the next step is turning those definitions into a running database – see From Metadata to Database.

  • FieldDefinition: Lookup – lookup field compilation output and API behavior
  • FieldDefinition: Rollup – rollup field properties and aggregation functions
  • Diagnostics – the diagnostic catalog, including the required+setNull auto-correction and subtype-widening notices referenced above