Owned Records
Owned child relations render inline on the aggregate root's record page — read via ?include=, written through the changeset engine, gated by the child's own access.
A table that declares an owns boundary gets its owned
children rendered inline on the record page: each relation appears as a
section below the record's fields, with a row table and Add / Edit / Delete
controls. Line items live on the expense report, not in a sidebar collection.
When a relation renders
All three must hold — a relation missing any of them is skipped:
- The relation is declared in the root's
owns. - The relation name is in the root's
read.includeallowlist — reads rideGET /:id?include=<relation>, and a non-allowlisted include is a 400. - The child table exists in the schema registry.
export default feature("expenseReports", {
// ...columns, firewall, guards, crud...
owns: {
lineItems: { table: "lineItems", fk: "reportId", inherit: ["organizationId"] },
},
read: {
access: { roles: ["member+"] },
include: ["lineItems"], // without this, the CMS has no way to read the rows
},
});Writes go through the changeset engine
Every Add, Edit, and Delete is a single-op
changeset PATCH on the root —
Content-Type: application/vnd.quickback.changeset+json — never a raw child
route. The generated aggregate engine enforces the child's own access,
guards, body schema, and firewall on every op, and stamps the spine FK and
inherit columns from the firewall-verified parent row.
Consequences in the UI:
- The child's
fkandinheritcolumns are excluded from the form — the engine stamps them, and a client-supplied value is rejected. - The Add / Edit / Delete buttons gate on the child table's
create/update/deleteaccess for the signed-in role, exactly like the child's own raw routes would. - Child deletes are soft (changeset v1 admits soft-delete children only).
routes: false children
The usual shape for a pure composition child — line items that only ever
exist inside a report — is routes: false on every op plus
cms: { internal: true }:
read: { access: { roles: ["member+"] }, routes: false },
create: { access: { roles: ["member+"] }, routes: false },
update: { access: { roles: ["member+"] }, routes: false },
delete: { access: { roles: ["member+"] }, routes: false, mode: "soft" },
cms: { internal: true }, // no standalone sidebar entryroutes: false keeps the access declared (it is the changeset admission
gate) but mounts no raw HTTP route. The CMS reads the registry's
routesDisabled list and suppresses every raw-CRUD affordance for those ops
— New button, row Edit/Delete, record-page Edit/Delete, and inline cell
editing — so it never renders a button whose request can only 405. The
owned-records section is unaffected: its writes are changeset ops, which
routes: false deliberately leaves open.
Note the child's read access above is what gates the embedded rows:
?include= inherits the target's read access, firewall, and masking. A
child readable only by admin+ renders an empty section for members.
Next Steps
- Changesets — the
ownsboundary and the wire contract - Record Layouts — grouping the root's own fields
- Schema Format —
ownsRelations,readInclude,routesDisabled