Guards - Field Modification Rules
Control which fields can be modified during CREATE vs UPDATE operations. Protect sensitive fields and enforce data integrity rules.
Control which fields can be modified in CREATE vs UPDATE operations.
Basic Usage
// features/applications/applications.ts
import { sqliteTable, text } from 'drizzle-orm/sqlite-core';
import { defineTable } from '@quickback/compiler';
export const applications = sqliteTable('applications', {
id: text('id').primaryKey(),
candidateId: text('candidate_id').notNull(),
jobId: text('job_id').notNull(),
stage: text('stage').notNull().default('applied'),
appliedAt: text('applied_at').notNull(),
notes: text('notes'),
organizationId: text('organization_id').notNull(),
// ── quickback:audit (compiler-managed — edits are validated, not merged) ──
createdAt: text('created_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()),
modifiedAt: text('modified_at').notNull().default('1970-01-01T00:00:00.000Z').$defaultFn(() => new Date().toISOString()).$onUpdate(() => new Date().toISOString()),
createdBy: text('created_by'),
modifiedBy: text('modified_by'),
deletedAt: text('deleted_at'),
deletedBy: text('deleted_by'),
});
export default defineTable(applications, {
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
guards: {
createable: ["candidateId", "jobId", "notes"],
updatable: ["notes"],
protected: {
stage: ["advance-stage", "reject"],
},
immutable: ["appliedAt", "candidateId", "jobId"],
},
create: { /* ... */ },
update: { /* ... */ },
delete: { /* ... */ },
});Configuration Options
guards: {
// Fields allowed on CREATE
createable?: string[];
// Fields allowed on UPDATE/PATCH
updatable?: string[];
// Fields only modifiable via specific actions
protected?: Record<string, string[]>;
// Fields set on CREATE, blocked on every generic update.
// Actions can still write them — see the note below.
immutable?: string[];
}How It Works
| List | What it controls |
|---|---|
createable | Fields allowed in create (POST) request body |
updatable | Fields allowed in update (PATCH) request body |
protected | Fields blocked from direct writes; writable only by the actions named for them |
immutable | Fields allowed on create, then never written again — by CRUD, batch, changeset, or actions |
Guards bind actions too. createable and updatable shape the CRUD
request bodies. immutable and protected are additionally enforced against
your action handlers at compile time: the compiler scans each action bound
to the table for db.update(<table>).set({ … }) / db.insert(<table>).values({ … })
and refuses an immutable column in any update, or a protected column
written by an action that is not in its list — with an error naming the action,
the column, and the fix. Every offending action in the project is listed in
that one error, so a single compile surfaces all of them. The two tiers are therefore distinct: immutable is
written once at create and never again; if a column needs an action to change
it, it is protected, mapped to that action.
The scan sees object-literal payloads only. A spread (.set({ ...input })) or
a variable payload (.set(patch)) is not analysed, so keep guarded columns out
of such payloads yourself — that is the documented residual.
Combining lists:
createable+updatable- Most fields go in both (can set on create AND modify later)createableonly - Field is set once, cannot be changed via updateprotected- Don't also list increateableorupdatable(they're mutually exclusive)immutable- Don't also list inupdatable(contradiction)
Workflow-owned fields have one writer. If a status or state column is
managed by transitions or named actions, put it only in protected, mapped to
those transition/action names. Never also put it in createable or
updatable: the compiler rejects either contradiction, and a direct POST or
PATCH path would bypass the workflow. Give the column its initial value with a
schema default. Records then begin in that state, and only the declared
transitions/actions may write the field.
guards: {
createable: ["candidateId", "jobId", "notes", "source"],
updatable: ["notes"],
// "candidateId", "jobId" are only in createable = set once, can't change via update
protected: {
stage: ["advance-stage", "reject"], // NOT in createable/updatable
},
immutable: ["appliedAt"], // NOT in updatable
}If a field is not listed anywhere, it cannot be set by the client.
System-Managed Fields (Always Protected)
GUARDS_CONFIG.systemManaged rejects any field in this set from client input — even with guards: false. The set always includes the audit fields:
createdAt,createdBymodifiedAt,modifiedBydeletedAt,deletedBy
It also extends with scope columns — any firewall predicate whose equals references ctx.* adds its field to systemManaged. Most commonly this comes from q.scope():
columns: {
organizationId: q.scope('organization'), // → adds 'organizationId' to systemManaged
}The same applies to a hand-rolled firewall:
firewall: [
{ field: 'tenantId', equals: 'ctx.activeOrgId' }, // → systemManaged
{ field: 'workspaceId', equals: 'ctx.activeWorkspaceId' }, // → systemManaged
{ field: 'status', equals: 'active' }, // literal, NOT added
]The semantic rule is "if the firewall says this column's value comes from the request context, the client cannot submit it." The value is auto-populated on create / upsert from ctx.
Changeset ops widen the set per-surface
On changeset ops, an owned child's
systemManaged set additionally includes its spine FK and every inherit
column — the changeset supplies those structurally (the FK from the parent
op's id, inherit columns from the firewall-verified parent row), so a
client-supplied value is a pointer-carrying GUARD_SYSTEM_MANAGED rejection
(never silently stripped or stamped-over).
The child's plain-CRUD guards are unaffected: a junction that keeps its FK
in createable for its raw POST route stays correct — the same field
arriving in a changeset op is rejected because that surface stamps it. Two
surfaces, one declared rule each.
Example
guards: {
createable: ["candidateId", "jobId", "notes"],
updatable: ["notes"],
protected: {
stage: ["advance-stage", "reject"], // Only these actions can modify stage
},
immutable: ["appliedAt", "candidateId", "jobId"],
}Disabling Guards
guards: false // Only system fields protectedWhen guards: false is set, all user-defined fields become writable via direct create/update/upsert operations. This is useful for:
- External sync scenarios where you need full field control
- Batch upsert operations where field restrictions would be limiting
- Simple tables where field-level protection isn't needed
Upsert with External IDs
When you disable guards AND use client-provided IDs, you unlock upsert operations. This is designed for syncing data from external systems.
Requirements for Upsert
generateId: falsein database config (client provides IDs)guards: falsein resource definition
How Upsert Works
PUT /resource/:id
├── Record exists? → UPDATE (replace all fields)
└── Record missing? → CREATE with provided IDDatabase Config
// quickback.config.ts
export default {
database: {
generateId: false, // Client provides IDs (enables upsert)
// Other options: 'uuid' | 'cuid' | 'nanoid' | 'short' | 'prefixed' | 'serial'
}
};Table Definition
// features/integrations/ats-imports.ts
import { q, defineTable } from '@quickback/compiler';
export const atsImports = q.table('atsImports', {
id: q.id(),
externalId: q.text().required(),
payload: q.text().optional(),
organizationId: q.scope('organization'),
...q.audit(),
...q.softDelete(),
});
export default defineTable(atsImports, {
firewall: [{ field: 'organizationId', equals: 'ctx.activeOrgId' }],
guards: false, // Disables field restrictions
upsert: {
access: { roles: ['hiring-manager', 'sync-service'] }
},
});What's Still Protected with guards: false
Even with guards: false, system-managed fields are ALWAYS protected. This includes:
createdAt,createdBy— set on INSERT onlymodifiedAt,modifiedBy— auto-updateddeletedAt,deletedBy— set on soft delete- Any column declared via
q.scope()(or any firewall predicate usingequals: 'ctx.*') — auto-populated from request context
So guards: false only opens up user-defined business fields. Tenant scope and audit fields stay locked down regardless.
Ownership Auto-Population
When upsert creates a new record, ownership fields are auto-set from context:
// Client sends: PUT /ats-imports/ext-123 { candidateName: "Jane Doe" }
// Server creates:
{
id: "ext-123", // Client-provided
candidateName: "Jane Doe", // Client-provided
organizationId: ctx.activeOrgId, // Auto-set from firewall
createdAt: now, // Auto-set
createdBy: ctx.userId, // Auto-set
modifiedAt: now, // Auto-set
modifiedBy: ctx.userId, // Auto-set
}Use Cases for Upsert/External IDs
| Use Case | Why upsert? |
|---|---|
| External API sync | External system controls the ID |
| Webhook handlers | Events come with their own IDs |
| Data migration | Preserve IDs from source system |
| Idempotent updates | Safe to retry (no duplicate creates) |
| Bulk upsert | Create or update in one operation |
ID Generation Options
generateId | Upsert Available? | Notes |
|---|---|---|
'uuid' | No | Server generates UUID |
'cuid' | No | Server generates CUID |
'nanoid' | No | Server generates nanoid |
'short' | No | Server generates 6-char alphanumeric ID (e.g. a3F9xK) |
'prefixed' | No | Server generates prefixed ID (e.g. room_abc123) |
'serial' | No | Database auto-increments |
false | Yes (if guards: false) | Client provides ID |
Need client-generated ids without giving up server minting or guards? Set
allowClientIds: true instead — creates accept an optional
strategy-shaped id (for optimistic updates), guards stay on, and the id is
exempt from createable. See
Client-supplied IDs.
Upsert still requires generateId: false + guards: false.
Compile-Time Validation
The Quickback compiler validates your guards configuration and will error if:
- Field in both
createableandprotected- A field cannot be both client-writable on create and action-only - Field in both
updatableandprotected- A field cannot be both client-writable on update and action-only - Field in both
updatableandimmutable- Contradictory: immutable fields cannot be updated - Field in
protecteddoesn't exist in schema - Referenced field must exist in the table - Field in
createable/updatable/immutabledoesn't exist in schema - All referenced fields must exist
Transition/action-managed status fields belong only in protected, use a
schema default for their initial value, and may be written only by the named
transitions/actions.