First Class Modules
Record relations
Explores authorized records as a bounded relationship graph and supports typed relation creation/removal only when both endpoints are editable.
module.record-graphUse Record Relations to show how people, companies, work, files, conversations, events, and custom records connect without duplicating those records into a separate graph store.
The bundled renderer key is module.record-graph. First-class means BuildWithHQ supplies a native, typed, secured runtime experience inside normal app provisioning and the signed-in user's existing permissions.
What it does
Explores authorized records as a bounded relationship graph and supports typed relation creation/removal only when both endpoints are editable.
Key capabilities
- Search visible seed records across modules.
- Traverse secured relationships through bounded depth levels.
- Show nodes, typed edges, module, location, status, activity counts, and favorites.
- Add or remove a typed relation only when both record endpoints and the relation operation are authorized.
Common uses
- Customer/account 360-degree context.
- Case, project, asset, and work dependency exploration.
- Impact analysis and relationship-first CRM experiences.
How it connects
The graph is a view over canonical Records and RecordRelations. Opening a node returns to the appropriate native or dynamic module; the graph does not become a competing source of record data.
Where applicable, its records use the universal RecordId conventions so they can participate in secured relationships, activity history, favorites, dynamic fields, notifications, Inbox attention, and global search without copying the source record.
Security and data boundary
Every traversed node is independently checked for SaaS, tenant, DataRole, record, and location visibility. Inaccessible nodes and edges are omitted, and mutation requires edit authority on both endpoints.
- The authenticated service derives the SaaS app, tenant account, user, DataRole, and location scope; browser identifiers are never authorization proof.
- The page editor composes React components with validated data bindings. Those bindings call typed runtime APIs, whose application services execute reviewed stored procedures.
- List, search, detail, relation, activity, favorite, and write operations reapply their required server-side permissions.
Add it to an app
- Open the SaaS app in the Developer Console and identify the user journey and page where this module belongs.
- Add the validated
module.record-graphmodule block through the supported page/template authoring flow. - Configure the module with the page editor's React components and validated data bindings; the bindings call authenticated platform APIs backed by reviewed stored procedures.
- Place the page in the correct user-type menus and assign existing DataRole, record, field, and location permissions.
- Test list, detail, search, empty, denied, stale-update, and cross-location behavior before publishing an exact version.
Copy/paste the complete Record Relations page
The native block provides secured seed-record search, bounded graph traversal, typed edges, relation add/remove, favorites, dynamic fields, and related/contextual Knowledge Core.
{
"blocks": [
{ "_id": "connected-records", "_type": "module.record-graph", "props": {}, "children": [] }
]
}
Custom unstyled React graph explorer
The seed picker uses the stable title/RecordId cursor returned by SQL. The graph itself is deliberately bounded to depth 1–5 and 2–200 nodes. truncated means the browser has a safe partial graph, not permission to fetch an unbounded one.
import { FormEvent, useEffect, useMemo, useState } from "react";
import type { RecordGraphResponse, RecordGraphSearchResponse } from "@buildwithhq/module-sdk";
import { getRecordGraph, searchRecordGraph } from "./api";
const empty: RecordGraphSearchResponse = { contractVersion: 1, pageNumber: 1, pageSize: 50, totalRecords: 0, totalPages: 0, totalIsExact: false, hasMore: false, modules: [], items: [] };
type Cursor = { afterTitle?: string; afterRecordId?: string };
export function UnstyledRecordRelations({ locationId = "", maxDepth = 2, maxNodes = 100 }) {
const [draft, setDraft] = useState("");
const [search, setSearch] = useState("");
const [moduleKey, setModuleKey] = useState("");
const [cursors, setCursors] = useState<Cursor[]>([{}]);
const [records, setRecords] = useState<RecordGraphSearchResponse>(empty);
const [graph, setGraph] = useState<RecordGraphResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const cursor = cursors[cursors.length - 1];
useEffect(() => {
const controller = new AbortController();
searchRecordGraph(moduleKey, search, locationId, controller.signal, cursor.afterTitle, cursor.afterRecordId, 50)
.then(setRecords)
.catch((caught: unknown) => { if (!controller.signal.aborted) setError(caught instanceof Error ? caught.message : "Records could not be loaded."); });
return () => controller.abort();
}, [cursor.afterRecordId, cursor.afterTitle, locationId, moduleKey, search]);
const byId = useMemo(() => new Map(graph?.nodes.map(node => [node.recordId, node]) || []), [graph]);
function submit(event: FormEvent) { event.preventDefault(); setCursors([{}]); setGraph(null); setSearch(draft.trim()); }
async function open(recordId: string) { setGraph(await getRecordGraph(recordId, maxDepth, undefined, maxNodes)); }
function next() { if (records.hasMore && records.nextRecordId) setCursors(current => [...current, { afterTitle: records.nextTitle || undefined, afterRecordId: records.nextRecordId || undefined }]); }
return <section className="custom-record-relations">
<h1>Record relations</h1>
<form role="search" onSubmit={submit}><label>Find a starting record<input value={draft} maxLength={200} onChange={event => setDraft(event.target.value)} /></label><label>Module<select value={moduleKey} onChange={event => { setModuleKey(event.target.value); setCursors([{}]); }}><option value="">All modules</option>{records.modules.map(module => <option key={module.moduleKey} value={module.moduleKey}>{module.moduleName}</option>)}</select></label><button>Search</button></form>
{error && <p role="alert">{error}</p>}
<div className="relation-columns"><aside><ol>{records.items.map(record => <li key={record.recordId}><button onClick={() => void open(record.recordId)}><strong>{record.title}</strong><span>{record.moduleName} - {record.locationName || "All locations"}</span></button></li>)}</ol><nav aria-label="Record result pages"><button disabled={cursors.length === 1} onClick={() => setCursors(current => current.slice(0, -1))}>Previous</button><span>Batch {cursors.length}</span><button disabled={!records.hasMore || !records.nextRecordId} onClick={next}>Next</button></nav></aside><main>{graph ? <><header><h2>{byId.get(graph.seedRecordId)?.title || "Record graph"}</h2><p>{graph.nodeCount} nodes - {graph.edges.length} edges - depth {graph.depthReached}{graph.truncated ? " - truncated at the requested limit" : ""}</p></header><section><h3>Nodes</h3>{graph.nodes.map(node => <button key={node.recordId} onClick={() => void open(node.recordId)}><strong>{node.title}</strong><span>Depth {node.depth} - {node.moduleName}</span></button>)}</section><section><h3>Edges</h3>{graph.edges.map(edge => <p key={edge.recordRelationId}><strong>{byId.get(edge.fromRecordId)?.title || "Unavailable"}</strong> - {edge.relationName || edge.relationKey || "Related"} - <strong>{byId.get(edge.toRecordId)?.title || "Unavailable"}</strong></p>)}</section></> : <p>Select a record.</p>}</main></div>
</section>;
}
Add/remove typed relations and favorite the seed
Use only relation keys returned in graph.relationTypes. The helper checks the displayed edit flags for a good UI, while SQL independently reauthorizes both endpoints and the relation operation.
import type { RecordGraphResponse } from "@buildwithhq/module-sdk";
import { addRecordGraphRelation, getRecordGraph, removeRecordGraphRelation, setRecordGraphFavorite } from "./api";
export async function connectRecords(graph: RecordGraphResponse, toRecordId: string, relationKey: string) {
const from = graph.nodes.find(node => node.recordId === graph.seedRecordId);
const to = graph.nodes.find(node => node.recordId === toRecordId);
if (!from?.canEdit || !to?.canEdit) throw new Error("Both visible records must be editable.");
if (!graph.relationTypes.some(type => type.relationKey === relationKey)) throw new Error("Choose a returned relation type.");
await addRecordGraphRelation(from.recordId, { toRecordId, relationKey });
return getRecordGraph(from.recordId, 2, undefined, 100);
}
export async function disconnectRecords(seedRecordId: string, recordRelationId: string) {
await removeRecordGraphRelation(recordRelationId);
return getRecordGraph(seedRecordId, 2, undefined, 100);
}
export async function toggleGraphFavorite(graph: RecordGraphResponse) {
const seed = graph.nodes.find(node => node.recordId === graph.seedRecordId);
if (!seed) throw new Error("The seed record is not visible.");
await setRecordGraphFavorite(seed.recordId, !seed.isFavorite);
return getRecordGraph(seed.recordId, 2, undefined, 100);
}
Related and contextual Knowledge Core
Direct relations and bounded graph candidates are distinct. Set requireAiRead: true when assembling AI context so candidates must pass the additional AI-read gate; normal record visibility alone is not enough.
import { getContextualKnowledge, getRelatedKnowledge, linkKnowledgeToRecord, unlinkKnowledgeFromRecord } from "./api";
export async function loadRecordKnowledge(recordId: string) {
const [direct, aiCandidates] = await Promise.all([
getRelatedKnowledge(recordId, 25),
getContextualKnowledge(recordId, true, 25),
]);
return { direct: direct.items, aiCandidates: aiCandidates.items };
}
export async function linkApprovedKnowledge(recordId: string, knowledgeRecordId: string) {
return linkKnowledgeToRecord(recordId, { knowledgeRecordId, relationKey: "related-knowledge" });
}
export async function unlinkApprovedKnowledge(recordRelationId: string) {
return unlinkKnowledgeFromRecord(recordRelationId);
}
Optional starter styling
.custom-record-relations { width: 100%; max-width: none; }
.custom-record-relations > form, .relation-columns nav { align-items: end; display: flex; flex-wrap: wrap; gap: .75rem; }
.relation-columns { display: grid; gap: 1rem; grid-template-columns: 280px minmax(0, 1fr); margin-top: 1rem; }
.relation-columns ol { list-style: none; margin: 0; padding: 0; }
.relation-columns aside li button, .relation-columns main section button { background: transparent; border: 0; display: grid; padding: .65rem; text-align: left; width: 100%; }
.relation-columns main { border: 1px solid var(--line, #d8dee8); padding: 1rem; }
.relation-columns main section { border-top: 1px solid var(--line, #d8dee8); padding-top: .75rem; }
@media (max-width: 760px) { .relation-columns { grid-template-columns: 1fr; } }
Exact data path
| Purpose | Route | Procedure |
|---|---|---|
| Cursor seed search | GET /api/modules/record-graph/records | sp_RecordGraph_SearchSecured |
| Bounded graph | GET /api/modules/record-graph/{recordId} | sp_RecordGraph_GetSecured |
| Add/remove relation | PUT /{recordId}/relations, DELETE /relations/{recordRelationId} | sp_RecordGraph_AddRelationSecured, sp_RecordGraph_RemoveRelationSecured |
| Favorite | PUT /{recordId}/favorite | sp_Favorites_SetSecured |
| Knowledge reads | GET /{recordId}/knowledge, GET /{recordId}/knowledge/candidates | sp_kc_GetRelatedKnowledgeSecured, sp_kc_GetContextualKnowledgeCandidatesSecured |
| Knowledge link/unlink | PUT /{recordId}/knowledge, DELETE /knowledge-relations/{recordRelationId} | sp_kc_LinkKnowledgeToRecord, sp_kc_UnlinkKnowledgeFromRecord |
The canonical RecordRelations rows are the relationship source. BuildWithHQ does not require a massive pre-expanded user/role/location graph table: each bounded read intersects current record, DataRole, location, direct-conversation, and AI-read policy before returning nodes or knowledge.
Build a professional Record relations dashboard
These six registry-backed presentation blocks let a designer turn the secured Record relations API into a complete admin page without writing a chart, grid, status badge, empty state, or timeline from scratch. The normal operational API begins at /api/modules/record-graph; a dashboard-wide count or trend should come from a separate purpose-built presentation binding so the browser never downloads a broad record population to calculate one number.
The ZIP contains this feature's fictional design composition at pages/first-class/record-relations-sample.json, its executable authenticated page at pages/first-class/record-relations-live.json, and the catalog-driven FirstClassPresentationGallery.tsx. Use the sample only for visual design. The live page calls the real native API and reviewed stored procedures under the signed-in user's scope.
| Payload kind | Runtime block | Useful Record relations projection |
|---|---|---|
metric-set | core.metric-strip | Visible relations, connected records, types, and recent links |
entity-list | core.entity-list | Highly connected records inside the current scope |
progress-list | core.progress-list | Distribution by relation type or owning module |
series-chart | core.series-chart | Relationship creation and removal |
data-grid | core.presentation-grid | One bounded edge page with both endpoints authorized |
timeline | core.timeline | Create, update, remove, source loss, and graph traversal events |
Copy/paste design preview: all six blocks
This complete static page document renders immediately in Puck/Monaco and is useful while styling a template. Its names, counts, dates, and IDs are fictional design fixtures; static preview values are not live tenant facts.
Copy the complete six-block page JSON
{
"blocks": [
{
"_id": "record-relations-metrics",
"_type": "core.metric-strip",
"props": {
"title": "Relationship overview",
"asOfUtc": "2026-09-04T18:00:00Z",
"items": [
{
"key": "relations",
"label": "Visible relations",
"value": 3218,
"format": "number",
"tone": "neutral"
},
{
"key": "records",
"label": "Connected records",
"value": 1407,
"format": "number",
"tone": "primary"
},
{
"key": "types",
"label": "Relation types",
"value": 18,
"format": "number",
"tone": "success"
},
{
"key": "new",
"label": "Added this week",
"value": 96,
"format": "number",
"tone": "warning"
}
]
},
"children": []
},
{
"_id": "record-relations-recent",
"_type": "core.entity-list",
"props": {
"title": "Highly connected records",
"hasMore": true,
"items": [
{
"id": "record-relations-sample-1",
"recordId": "record-relations-record-1",
"primary": "Acme Field Services",
"secondary": "Contact - 34 visible relations",
"status": {
"key": "connected",
"label": "Connected",
"tone": "success"
},
"trailing": "34 links"
},
{
"id": "record-relations-sample-2",
"recordId": "record-relations-record-2",
"primary": "Acme Field Services - Follow-up",
"secondary": "Contact - 34 visible relations - Updated two hours ago by the assigned owner",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"trailing": "Today"
},
{
"id": "record-relations-sample-3",
"recordId": "record-relations-record-3",
"primary": "Acme Field Services - West region",
"secondary": "Contact - 34 visible relations - Related to three visible records at the Reno location",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"trailing": "3 related"
},
{
"id": "record-relations-sample-4",
"recordId": "record-relations-record-4",
"primary": "Acme Field Services - Customer response",
"secondary": "Contact - 34 visible relations - Waiting for an external response before work can continue",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"trailing": "Tomorrow"
},
{
"id": "record-relations-sample-5",
"recordId": "record-relations-record-5",
"primary": "Acme Field Services - Regional operations review with a deliberately long title",
"secondary": "Contact - 34 visible relations - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"trailing": "Review",
"tertiary": "Long-content fixture: verify keyboard focus, wrapping, narrow columns, and mobile overflow before publishing."
},
{
"id": "record-relations-sample-6",
"recordId": "record-relations-record-6",
"primary": "Acme Field Services - Completed preview",
"secondary": "Contact - 34 visible relations - Closed after review with its related evidence retained",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"trailing": "Closed"
}
]
},
"children": []
},
{
"_id": "record-relations-bystatus",
"_type": "core.progress-list",
"props": {
"title": "Relations by type",
"items": [
{
"key": "customer",
"label": "Customer of",
"value": 1210,
"maximum": 3218,
"displayValue": "1210",
"tone": "primary",
"status": {
"key": "customer",
"label": "Customer of",
"tone": "primary"
}
},
{
"key": "attached",
"label": "Attached to",
"value": 1084,
"maximum": 3218,
"displayValue": "1084",
"tone": "success",
"status": {
"key": "attached",
"label": "Attached to",
"tone": "success"
}
},
{
"key": "depends",
"label": "Depends on",
"value": 924,
"maximum": 3218,
"displayValue": "924",
"tone": "warning",
"status": {
"key": "depends",
"label": "Depends on",
"tone": "warning"
}
}
]
},
"children": []
},
{
"_id": "record-relations-trend",
"_type": "core.series-chart",
"props": {
"title": "Relationship activity",
"variant": "bar",
"defaultPeriodKey": "d7",
"periods": [
{
"key": "d7",
"label": "7 days",
"labels": [
"Fri",
"Sat",
"Sun",
"Mon",
"Tue",
"Wed",
"Thu"
],
"series": [
{
"key": "primary",
"label": "Created",
"tone": "primary",
"values": [
8,
5,
4,
12,
15,
11,
17
]
},
{
"key": "secondary",
"label": "Removed",
"tone": "success",
"values": [
6,
4,
3,
9,
12,
10,
14
]
}
]
}
]
},
"children": []
},
{
"_id": "record-relations-table",
"_type": "core.presentation-grid",
"props": {
"title": "Visible relationships",
"columns": [
{
"key": "from",
"label": "From",
"type": "text",
"align": "left"
},
{
"key": "relation",
"label": "Relationship",
"type": "text",
"align": "left"
},
{
"key": "to",
"label": "To",
"type": "text",
"align": "left"
},
{
"key": "status",
"label": "Status",
"type": "status",
"align": "left"
},
{
"key": "created",
"label": "Created",
"type": "date",
"align": "left"
}
],
"rows": [
{
"id": "record-relations-row-1",
"recordId": "record-relations-record-1",
"cells": {
"from": "Acme Field Services",
"relation": "Customer of",
"to": "WO-1048",
"status": {
"key": "active",
"label": "Active",
"tone": "success"
},
"created": "2026-09-04T11:11:00Z"
}
},
{
"id": "record-relations-row-2",
"recordId": "record-relations-record-2",
"cells": {
"from": "Acme Field Services - Follow-up",
"relation": "Customer of",
"to": "WO-1048",
"status": {
"key": "in-review",
"label": "In review",
"tone": "primary"
},
"created": "2026-09-04T15:42:00Z"
}
},
{
"id": "record-relations-row-3",
"recordId": "record-relations-record-3",
"cells": {
"from": "Acme Field Services - West region",
"relation": "Customer of",
"to": "WO-1048",
"status": {
"key": "on-track",
"label": "On track",
"tone": "success"
},
"created": "2026-09-04T12:18:00Z"
}
},
{
"id": "record-relations-row-4",
"recordId": "record-relations-record-4",
"cells": {
"from": "Acme Field Services - Customer response",
"relation": "Customer of",
"to": "WO-1048",
"status": {
"key": "scheduled",
"label": "Scheduled",
"tone": "warning"
},
"created": "2026-09-03T21:07:00Z"
}
},
{
"id": "record-relations-row-5",
"recordId": "record-relations-record-5",
"cells": {
"from": "Acme Field Services - Regional operations review with a deliberately long title",
"relation": "Customer of - This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"to": "WO-1048",
"status": {
"key": "needs-attention",
"label": "Needs attention",
"tone": "danger"
},
"created": "2026-09-03T16:31:00Z"
}
},
{
"id": "record-relations-row-6",
"recordId": "record-relations-record-6",
"cells": {
"from": "Acme Field Services - Completed preview",
"relation": "Customer of",
"to": "WO-1048",
"status": {
"key": "complete",
"label": "Complete",
"tone": "success"
},
"created": "2026-09-02T19:14:00Z"
}
}
],
"page": {
"pageNumber": 1,
"pageSize": 6,
"totalRecords": 3218,
"totalIsExact": true,
"hasMore": true
}
},
"children": []
},
{
"_id": "record-relations-timeline",
"_type": "core.timeline",
"props": {
"title": "Relationship activity",
"hasMore": true,
"items": [
{
"id": "record-relations-event-1",
"recordId": "record-relations-record-1",
"occurredUtc": "2026-09-04T17:58:00Z",
"title": "Relationship created",
"description": "Acme Field Services was related to WO-1048.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "record-relations-event-2",
"recordId": "record-relations-record-2",
"occurredUtc": "2026-09-04T15:42:00Z",
"title": "Relationship created - Follow-up",
"description": "Acme Field Services was related to WO-1048. Updated two hours ago by the assigned owner.",
"actor": "Avery Patel",
"tone": "primary"
},
{
"id": "record-relations-event-3",
"recordId": "record-relations-record-3",
"occurredUtc": "2026-09-04T12:18:00Z",
"title": "Relationship created - West region",
"description": "Acme Field Services was related to WO-1048. Related to three visible records at the Reno location.",
"actor": "Sam Rivera",
"tone": "success"
},
{
"id": "record-relations-event-4",
"recordId": "record-relations-record-4",
"occurredUtc": "2026-09-03T21:07:00Z",
"title": "Relationship created - Customer response",
"description": "Acme Field Services was related to WO-1048. Waiting for an external response before work can continue.",
"actor": "Maya Chen",
"tone": "warning"
},
{
"id": "record-relations-event-5",
"recordId": "record-relations-record-5",
"occurredUtc": "2026-09-03T16:31:00Z",
"title": "Relationship created - Regional operations review with a deliberately long title",
"description": "Acme Field Services was related to WO-1048. This deliberately longer supporting line verifies wrapping, truncation, responsive spacing, and dense dashboard behavior.",
"actor": "Automation",
"tone": "danger"
},
{
"id": "record-relations-event-6",
"recordId": "record-relations-record-6",
"occurredUtc": "2026-09-02T19:14:00Z",
"title": "Relationship created - Completed preview",
"description": "Acme Field Services was related to WO-1048. Closed after review with its related evidence retained.",
"actor": "Jordan Lee",
"tone": "success"
}
]
},
"children": []
}
]
}Copy/paste React composition
Copy InteractivePresentationComponents.tsx and its optional CSS from the Professional Foundation Developer Kit, then add this module-specific composition. It is semantic and unstyled by default; pass styled after importing interactive-presentation-components.css for the supplied polished foundation. Either version accepts only a bounded already-authorized document and fails closed through the shared strict parsers.
import {
EntityList,
MetricStrip,
PresentationGrid,
ProgressList,
SeriesChart,
Timeline,
} from "./InteractivePresentationComponents";
export interface RecordRelationsPresentationData {
readonly metrics: unknown;
readonly recent: unknown;
readonly byStatus: unknown;
readonly trend: unknown;
readonly table: unknown;
readonly timeline: unknown;
}
export interface RecordRelationsPresentationProps {
/** Pass only the already-authorized presentation document returned by the API. */
readonly data?: RecordRelationsPresentationData | null;
readonly loading?: boolean;
readonly error?: boolean;
readonly styled?: boolean;
/** Record identity is navigation context; the detail API must authorize it again. */
readonly onOpenRecord?: (recordId: string) => void;
}
export function RecordRelationsPresentation({
data,
loading = false,
error = false,
styled = false,
onOpenRecord,
}: RecordRelationsPresentationProps) {
if (error) return <p role="alert">The Record relations presentation could not be loaded.</p>;
if (loading || !data) return <p role="status">Loading Record relations presentation...</p>;
return (
<main className={styled ? "bwhq-api-example" : undefined}>
<header>
<p>Record relations</p>
<h1>Relationship overview</h1>
<p>Authorized connections, relation types, highly connected records, and graph changes.</p>
</header>
<MetricStrip data={data.metrics} styled={styled} />
<div className={styled ? "bwhq-api-example__split" : undefined}>
<ProgressList data={data.byStatus} styled={styled} />
<EntityList data={data.recent} styled={styled} onOpenRecord={onOpenRecord} />
</div>
<SeriesChart data={data.trend} styled={styled} />
<PresentationGrid data={data.table} styled={styled} onOpenRecord={onOpenRecord} />
<Timeline data={data.timeline} styled={styled} onOpenRecord={onOpenRecord} />
</main>
);
}
Copy the live, authenticated module page
This document has no placeholder key and needs no invented endpoint. Save it to a page and add that page to a User Type menu. The registered native component calls /api/modules/record-graph, uses the current tenant session, and preserves the module's real list, detail, create/update, pagination, empty, loading, and error behavior. Dynamic Records discovers the organization's real tenant-owned modules when no module key is configured.
Copy the executable live page
{
"blocks": [
{
"_id": "record-relations-live",
"_type": "module.record-graph",
"props": {},
"children": []
}
]
}The server derives SaaS app, organization, user, DataRoles, locations, module-specific membership/privacy, and any AI-read gate from verified identity. A returned identifier can select a detail target, but the detail or write endpoint authorizes it again. Page layout, status, tone, totals, action names, and identifiers never grant authority.
Keep relation types meaningful and limited. A consistent typed relationship vocabulary produces better navigation, automation, and AI context than many near-duplicate labels.
A renderer being bundled in the tenant application does not make its data visible in every app. The server returns only components and records authorized for the current app and signed-in user; unavailable or unauthorized blocks fail closed.
Capability review: 2026-09-14. For exact current technical availability, use the generated API Map and first-class module inventory.