s-m-r-t
Open the s-m-r-t source on GitHub Switch to dark color scheme
← Capabilities

Field policies 01

Defaults and visibility resolve in layers

A field policy sets the field label, initial visibility, default value, and help text. The code states a starting arrangement. An organization can adapt it. A person can adapt it again for one account.

Seed the arrangement next to the model

@field({ ui }) records presentation hints beside the field they describe. They ride the manifest under the field’s _meta.ui, reach the browser in generated collection definitions, and have no schema, persistence, or security effect. The field description becomes the starting help text. The class-level ui.description round-trips through the manifest as the seed for form-level help, but nothing reads it for you — a host passes it to FormHelp.

  • basic seeds the field into the tier shown before the advanced disclosure.
  • group, order, and locked carry the grouping key, sort order, and an initial organization lock.
  • Cold-start rule: an object with no basic markers renders every field basic; once any field is marked, unmarked fields start advanced.
src/lib/objects/Invoice.ts
typescript
import { field, smrt, SmrtObject } from '@happyvertical/smrt-core';

@smrt({
  api: { include: ['list', 'get', 'create', 'update'] },
  ui: { label: 'Invoices', description: 'Bills you send to a customer.' }
})
export class Invoice extends SmrtObject {
  @field({
    required: true,
    description: 'Who the invoice is addressed to.',
    ui: { basic: true, order: 1 }
  })
  customerName = '';

  @field({
    description: 'Payment terms printed on the invoice.',
    ui: { basic: true, order: 2 }
  })
  terms = 'Net 30';

  @field({
    description: 'Internal note for the accounts team.',
    ui: { group: 'Accounting', order: 10 }
  })
  internalNote = '';
}

Four layers merge into one answer

Resolution runs low to high: the code seed, app rows, tenant chain, and signed-in user. Each stored row is sparse. A column with a NULL value inherits from the layer below it. Thus, a default change does not require repeated label, help, or visibility values.

  • resolveFieldPolicy returns the merged policy for one object.
  • resolveFieldPolicyExplained returns the same result plus the ordered per-layer contributions, so a gear or admin view never re-derives precedence itself.
  • Without a db the resolver returns the code seed alone, which is the correct answer for a page that has no policy store yet.
src/lib/server/article-policy.ts
typescript
import {
  resolveFieldPolicy,
  resolveFieldPolicyExplained
} from '@happyvertical/smrt-fields';

const policy = await resolveFieldPolicy(
  '@happyvertical/smrt-content:Article',
  { tenantId, userId, db }
);

policy.fields.summary.visibility;   // 'basic' | 'advanced' | 'hidden'
policy.fields.summary.hasDefault;   // true when any layer resolved one
policy.fields.summary.defaultValue; // the parsed value
policy.fields.summary.locked;

// Per-layer contributions for a gear or control panel
const explained = await resolveFieldPolicyExplained(
  '@happyvertical/smrt-content:Article',
  { tenantId, userId, db }
);

explained.layers.summary;
// [{ layer: 'code', delta }, { layer: 'tenant', tenantId, delta }, ...]

Resetting deletes the row

There is no “reset” flag. Removing a customization deletes the override row, which means the layer below it applies again — including later changes to that lower layer. Rows are keyed by object reference, field name, scope type, and scope key, so a reset is always precise.

  • To return one property to the layer below while keeping the rest of the row, set that column to null. Deleting the row resets every property at that scope.
  • App rows carry no tenant or user; tenant rows carry only a tenant; user rows carry only a user.
  • A user row follows the person rather than the membership, so a personal preference persists across the tenants they belong to.
  • Writes inside a request derive the missing tenant or user from the ambient context and stamp who changed the row.
src/lib/server/set-house-default.ts
typescript
import { FieldPolicyCollection } from '@happyvertical/smrt-fields';

const policies = await FieldPolicyCollection.create({ db });

// The organization sets a house default and moves the field back a step
await policies.create({
  objectRef: '@happyvertical/smrt-content:Article',
  fieldName: 'summary',
  scopeType: 'tenant',
  tenantId,
  visibility: 'advanced',
  defaultValueRaw: 'TBD'
});

// Undoing the whole customization is an ordinary delete
const row = await policies.get({ id: rowId });
await row?.delete();

A tenant chain contributes from the root down

For a tenant hierarchy, resolution checks the chain from root to leaf. A parent organization can set a default that its branches inherit. A branch can override that default. A node can break permission inheritance. The break discards all earlier contributions in the chain. Only tenants at and after the last break contribute to merged and explained results.

  • The default hierarchy loader reads the tenant tree from smrt-users.
  • When no hierarchy is available the resolver falls back to a flat, single-tenant chain.
  • That fallback concerns tenant ancestry only. It is never a fallback around authorization.

Locks are how an organization says no

Only an app or tenant row can set locked. A lock can come from the code, app, or tenant tier. The lock rejects user-scope writes for that field. Resolution also skips an existing user row. Thus, old personal overrides cannot stay active under a later lock.

  • A lock can be seeded in code with ui: { locked: true } and lifted by an organization administrator.
  • Locks cascade with the tenant chain, so an ancestor tenant can lock a field for every branch beneath it.
  • Unlocking restores the personal row that was being skipped rather than recreating it.

A required field cannot quietly disappear

A required field can leave the basic tier only when it has a usable resolved default. The framework enforces this rule when it writes the row and when it reads the policy. Deletion of the row that supplied the default cannot make a form impossible to submit.

  • At read time a required field with no usable default always resolves basic and is flagged visibilityForced.
  • During an update, the projected lower-layer lookup excludes the row that will be replaced. The framework rejects removal of its only default during a field demotion. This rejection occurs before the write.
  • “Usable” excludes null and empty values, not false or zero.

Policy is presentation, not permission

A field policy changes how a field is presented and pre-filled. It is not a security boundary and cannot be used as one. Two controls protect sensitive fields. The framework rejects stored defaults for these fields. The batch resolve endpoint also omits the fields from every response.

  • Defaults are refused outright on transient, sensitive, and read-permission-gated fields. A policy may still set their visibility, label, help, or order — it just cannot put a value in them.
  • A policy row cannot address system fields, relationship pseudo-fields, or single-table-inheritance meta storage fields. A row that targets one of these fields cannot apply.
  • Reference-field defaults must be UUID strings unless the field declares a text id type, because those columns are native UUIDs on PostgreSQL and DuckDB.