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

Field policies 03

Run field policies in production

Two permissions divide organization rules from personal preferences. A gear edits the current form. A control panel shows the complete organization. Identity always comes from the request and not from the request body.

Two permissions, two audiences

fields.policy.manage authorizes app- and tenant-scope administration. fields.policy.personalize authorizes a principal to maintain their own user tier and nothing else. Both are ordinary entries in the smrt-users permission catalog, so they are granted through roles like any other permission.

  • ensureFieldPolicyPermissionsRegistered registers both definitions when the package is loaded.
  • FIELD_POLICY_PERMISSION_DEFINITIONS is the catalog entry list, and the two slugs are exported as MANAGE_FIELD_POLICY_PERMISSION and PERSONALIZE_FIELD_POLICY_PERMISSION.
  • App-scope writes from inside a tenant context additionally require a super-admin bypass.
src/lib/server/field-policy-permissions.ts
typescript
import {
  ensureFieldPolicyPermissionsRegistered,
  MANAGE_FIELD_POLICY_PERMISSION,
  PERSONALIZE_FIELD_POLICY_PERMISSION
} from '@happyvertical/smrt-fields';

ensureFieldPolicyPermissionsRegistered();

MANAGE_FIELD_POLICY_PERMISSION;      // 'fields.policy.manage'
PERSONALIZE_FIELD_POLICY_PERMISSION; // 'fields.policy.personalize'

A missing identity denies rather than skips

Both the write guard and the read guard deny an absent identity component, although a context can carry permissions without a user id. Examples include API-key authentication, a service principal, a background job, and a bare tenant context. Such a context cannot read or change the user tier. User rows do not contain a tenant. Without the check, one principal could write another principal’s row. Thus, an absent identity causes a denial.

  • The batch resolve action takes identity exclusively from the ambient context; the request body cannot select another tenant or user.
  • Saving or deleting an existing row is also authorized against the row’s persisted scope, looked up by primary key and by natural key.
  • Server-side callers that legitimately need explicit identities call resolveFieldPolicy directly instead of going through the endpoint.

The gear edits the form in front of you

FieldPolicyGearProvider makes the context-derived editor state available to any policy-aware form without choosing a transport. It takes a FieldPolicyEditorAdapter — load, create, update, delete — and that adapter must not accept tenant or user identifiers, because the server derives them. The provider also needs the same generated field definitions the form renders.

  • Normally the adapter wraps the generated FieldPolicy collection client’s getEditorState, create, update, and delete calls. An application that routes policy writes through its own endpoints can supply those instead; the contract is transport-neutral.
  • Place the affordance with FieldPolicyGearButton, or set showPolicyGear on ObjectForm.
  • The editor separates an Organization tab from a Personal tab; organizationScope selects app or tenant for hosts that administer the whole application.
  • The gear posts an already-JSON-encoded default, which is the same wire contract the generated write routes use.
  • registerFieldPolicyFocusTool registers the panel into an AdminShell dock without making the package depend on smrt-svelte.
src/lib/field-policy-gear.ts
typescript
import type { FieldPolicyEditorAdapter } from '@happyvertical/smrt-fields/svelte';
import { fieldPolicies } from '$lib/generated-clients';

// No tenant or user identifier appears anywhere in this contract.
export const fieldPolicyAdapter: FieldPolicyEditorAdapter = {
  load: ({ objectRef }) => fieldPolicies.getEditorState({ objectRef }),
  create: (input) => fieldPolicies.create(input),
  update: ({ id, ...input }) => fieldPolicies.update(id, input),
  delete: ({ id }) => fieldPolicies.delete(id)
};

Mount the gear around the form

The provider wraps whatever renders the form. Inside it, ObjectForm can show the gear itself, or a hand-written form can place FieldPolicyGearButton wherever it belongs.

ArticleWorkbench.svelte
typescript
<script lang="ts">
  import {
    FieldPolicyGearProvider,
    ObjectForm
  } from '@happyvertical/smrt-fields/svelte';
  import { fieldPolicyAdapter } from '$lib/field-policy-gear';

  let { definition, policy } = $props();
  let record = $state({});

  const objectRef = '@happyvertical/smrt-content:Article';
</script>

<FieldPolicyGearProvider
  {objectRef}
  fields={definition.fields}
  adapter={fieldPolicyAdapter}
>
  <ObjectForm
    {objectRef}
    fields={definition.fields}
    {policy}
    bind:value={record}
    showPolicyGear
  />
</FieldPolicyGearProvider>

The control panel is the organization view

buildFieldPolicySettingsCatalog is a server-side, URL-driven catalog builder. FieldPolicyControlPanel renders it with a catalog component that the host injects. The panel displays code, app, and organization values from the explained resolver layers. It does not calculate precedence again. The panel requires explicit confirmation before a reset or drift prune.

  • The panel takes the same adapter as the gear plus a loadAudit call, typed FieldPolicyControlPanelAdapter.
  • policyAudit is the only routed organization roll-up. It requires fields.policy.manage, returns the caller tenant’s editable rows and read-only app summaries, and represents other users strictly as per-field counts.
  • Route permission is the host’s responsibility: check it in the server load before building the catalog.
  • fieldPolicyControlPanelNavItem returns a permission-filtered navigation entry for the shell to place.
src/routes/app/settings/field-policies/+page.server.ts
typescript
import {
  buildFieldPolicySettingsCatalog,
  MANAGE_FIELD_POLICY_PERMISSION,
  parseFieldPolicyCatalogQuery
} from '@happyvertical/smrt-fields';

export const load = async ({ locals, url }) => {
  const membership = await requirePermission(locals, MANAGE_FIELD_POLICY_PERMISSION);

  return {
    permissions: membership.permissions,
    fieldPolicies: await buildFieldPolicySettingsCatalog({
      db: locals.db,
      ...parseFieldPolicyCatalogQuery(url.searchParams)
    })
  };
};

Personal preference versus organization rule

The Personal tab writes a user row, which follows the person and not a membership. The Organization tab writes an app or tenant row that supplies the initial scoped value. The difference matters when the two values disagree. The user tier wins unless the organization locked the field. An active lock causes resolution to skip the personal row.

  • A personal draft is shown against a non-disclosing signal of the lower layers, so a member never learns another tenant’s values from the editor.
  • Manifest drift — rows for fields that no longer exist — is listed for administrators and pruned by ordinary deletes.
  • Resolved results are cached briefly per database, object, tenant, user, and hierarchy loader; saving or deleting a row invalidates every entry for that object.

Usage learning turns aggregate patterns into reviewable suggestions

The optional usage-learning loop turns recent aggregated form usage into administrator-reviewed suggestions. It is opt-in at the host boundary, never applies a suggestion automatically, and installs its maintenance and suggestion schedules dormant until an operator enables them.

  • Each host must enable capture. A browser form reports only after its persistence handler acknowledges success. Capture requires an ambient tenant and an authenticated user. A form without a reporter captures nothing.
  • Values are minimized. Only low-cardinality boolean and reference fields send raw values. Text, numbers, dates, JSON, sensitive fields, and read-permission-gated fields send counts only. The server drops fields that the live registry does not recognize.
  • Suggestions are read and decided under fields.policy.manage and are never applied automatically. Accepting one writes an ordinary tenant policy through the normal validation rails.
  • Retention and rate limits are bounded. Counters stay for 90 days and have a 100,000-row limit. Accepted suggestions stay for 180 days. A dismissal starts a 30-day cooldown. Each tenant, user, object, and field can contribute once each UTC day. The maintenance schedules install dormant.

The SaaS starter shows the whole path

The public smrt-saas-starter uses the complete policy rail. It has static ui hints, a policy-aware ObjectForm, a permission-checked control panel route, and a shell navigation entry. The starter also shows an API constraint. Its objects were closed to the generated API. The browser-managed object needed a narrow include list before the form could save and reload data.

  • packages/app-objects/src/models/StarterAppSetting.ts carries the @field({ ui }) seed and the narrowed api include list; its sibling StarterInvitation stays api: false.
  • apps/web/src/lib/field-policy-client.ts contains the adapter. The adapter posts to hand-written /api/field-policies routes instead of wrapping the generated client. The transport-neutral adapter contract permits both shapes.
  • apps/web/src/lib/server/field-policy.ts wraps every call in the membership the application already verified and rejects any object reference other than its own settings object.
  • apps/web/src/routes/app/settings/field-policies/+page.server.ts checks fields.policy.manage before it builds the catalog, and its test asserts a non-manager is refused before any data is loaded.
  • The starter registers role-to-permission mapping in its own authorization module rather than seeding the framework permission catalog, which is worth knowing before copying it.

Adoption checklist

Field policy adoption is additive. PolicyField outside a provider renders its children without changes. Existing forms continue to operate until you wrap them. Each step below is useful by itself. A project can stop after any step.

  • 1. Add @happyvertical/smrt-fields at the same exact version as the rest of your s-m-r-t packages. It pins smrt-core, smrt-tenancy, smrt-ui, and smrt-users to that version, and mixing versions installs a second copy of the object registry.
  • 2. Add it to the package list that the build and runtime share. Pass this packages array to smrtConsumer in vite.config.ts. Import the same list when the application starts. The consumer plugin then merges the package manifest. _smrt_field_policies migrates with the rest of the schema. Installation of the dependency alone does not create the table.
  • 3. Seed presentation in code: add ui hints to the fields that deserve them, and remember the cold-start rule once you mark the first field.
  • 4. Grant fields.policy.manage to administrator roles and fields.policy.personalize to everyone who should be able to adjust their own forms.
  • 5. Resolve a policy in a server load and pass it to a form. PolicyField on a few fields is a complete first step.
  • 6. Open the generated API for each object that a form must save. Keep the include list and writable list as narrow as the interface needs.
  • 7. Add the gear where a form deserves one, then the control panel route behind a server-side permission check.
  • 8. Review drift after a model change, since rows for removed fields survive until an administrator prunes them.