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.json5withfieldKind: "lookup"is rejected by the loader with a compile error directing you here.FieldDefinition::Lookupstill 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.
What a Relationship Means
Section titled “What a Relationship Means”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:
{ relationshipKey: "contactAccount", label: "Account", from: "crm.Contact", to: "crm.Account", cardinality: "manyToOne", fromField: "account",}This guide uses a
crmmodule for its examples. Thecoremodule is builtin and authored in Rust, not from files – seedev/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.
The Inverse Side
Section titled “The Inverse Side”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:
{ 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.
Composable Properties
Section titled “Composable Properties”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 aBEFORE UPDATEtrigger) has no managed-relationship equivalent yet – every synthesized lookup ismutable: trueinternally, with no way to opt out from a*_relationship.json5file. If your migration from a raw lookup relied onmutable: false, that specific guarantee is not preserved by the managed layer today.
Required vs. optional
Section titled “Required vs. optional”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.
Delete behavior
Section titled “Delete behavior”onTargetDelete, declared on the to endpoint using the nested map form, controls what happens to child records when the referenced parent is deleted:
{ 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 tosetNull– clearing the reference is safe. - Required relationships (
required: true) default torestrict–setNullwould violate theNOT NULLconstraint.
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.
Uniqueness
Section titled “Uniqueness”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.
Common Relationship Patterns
Section titled “Common Relationship Patterns”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.
Inheritance-Aware Relationships
Section titled “Inheritance-Aware Relationships”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.
{ 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)
Polymorphic (Multi-Target) Relationships
Section titled “Polymorphic (Multi-Target) Relationships”When a relationship needs to reference records from several unrelated objects, to accepts an array instead of a single object name:
{ 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.
Rollups: Aggregation Across Relationships
Section titled “Rollups: Aggregation Across Relationships”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.
{ 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.
Data API Behavior
Section titled “Data API Behavior”When you create or update a record with a to-one relationship field, the API validates:
- The value must be a valid UUID (or null, if the field is optional).
- The referenced record must exist in
hf.object. - For polymorphic relationships, the referenced record’s
object_typemust 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.
Authoring a junction relationship
Section titled “Authoring a junction relationship”{ 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}Junction table structure
Section titled “Junction table structure”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.
Payload fields
Section titled “Payload fields”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:
{ 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.
Seed data
Section titled “Seed data”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.
Ownership and lifecycle
Section titled “Ownership and lifecycle”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.json5whose 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 validto: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.
sharing: endpoint-aware edge visibility
Section titled “sharing: endpoint-aware edge visibility”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 theto-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.sharingwidens read-time visibility only. - Default (
sharingabsent oredge: "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.
v1 limitations
Section titled “v1 limitations”- 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 Traits
Section titled “Relationship Traits”*_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 CASCADEto_id UUID NOT NULL REFERENCES crm.contact(id) ON DELETE RESTRICT
Validation rules:
onTargetDeleteis rejected on value-set targets (no FK exists).setNullis rejected on any junction endpoint or anyrequired: truelookup FK —SET NULLcannot satisfy aNOT NULLconstraint.
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.
temporal: validator gate seam
Section titled “temporal: validator gate seam”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.
sharing: opt-in edge visibility policy
Section titled “sharing: opt-in edge visibility policy”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.
stateMachine: enum field with transitions
Section titled “stateMachine: enum field with transitions”For value-set relationships (to: { values: [...] }) with pick: "one", you
can declare a transitions array to enforce a state machine at the write layer:
{ 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.
Master-Detail: a Preset, Not a Type
Section titled “Master-Detail: a Preset, Not a Type”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.
What’s Next
Section titled “What’s Next”With objects, fields, and relationships defined in metadata, the next step is turning those definitions into a running database – see From Metadata to Database.
Reference
Section titled “Reference”- 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