← Files InsForgeARCHIVED FILE

skills/insforge-cli/references/database/integrity.md

7.71 KB · Oct 4, 2026 · 12:28 UTC

↓ Download file

# Database Integrity

Use this reference when a migration must enforce database invariants: counters,
balances, latest pointers, append-only history, lifecycle states, quotas,
protected deletes, immutable ownership fields, or trigger-maintained columns.

DDL belongs in a migration. Use SQL constraints for row-local invariants, unique
indexes for uniqueness, foreign keys for references, and triggers only when the
rule depends on transitions, related rows, or server-maintained derived state.

## Choose the Smallest Database Primitive

| Need | Prefer |
|------|--------|
| Required field or valid range | `NOT NULL` / `CHECK` |
| Unique active value | partial unique index |
| Parent-child reference | foreign key plus index on the referencing column |
| Immutable owner or tenant | `BEFORE UPDATE` guard trigger |
| Append-only history | revoke client `UPDATE`/`DELETE`, plus optional guard trigger |
| Counter, balance, latest pointer, current status | trusted trigger-maintained derived field |
| Cross-row state transition | trigger or SQL function with clear transition checks |

## Server-Maintained Derived Fields

Derived fields include `comment_count`, `balance_cents`, `latest_revision_id`,
`current_status`, `last_event_at`, and similar values maintained by database
logic. Design them so normal client writes still work, but direct client edits
to the derived value do not.

Required shape:

1. A legal client can create the parent row with defaults, `NULL`, or zero for
   server-maintained fields.
2. A legal client can create the child/event row that should update the parent.
3. The trigger updates the derived parent field.
4. A client cannot directly update the derived field.
5. Guard triggers must not block the trusted maintenance path.

### Bad: Guard Blocks Its Own Maintenance Trigger

```sql
CREATE OR REPLACE FUNCTION public.protect_post_fields()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  IF NEW.comment_count IS DISTINCT FROM OLD.comment_count THEN
    RAISE EXCEPTION 'comment_count is server maintained';
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER protect_post_fields
BEFORE UPDATE ON public.posts
FOR EACH ROW EXECUTE FUNCTION public.protect_post_fields();

CREATE OR REPLACE FUNCTION public.bump_comment_count()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  UPDATE public.posts
  SET comment_count = comment_count + 1
  WHERE id = NEW.post_id;
  RETURN NEW;
END;
$$;
```

The child insert fires `bump_comment_count`, which updates `posts`, which fires
`protect_post_fields`, which rejects the legitimate maintenance update.

### Good: Restrict Client Update Surface, Let Trigger Maintain

```sql
CREATE TABLE public.posts (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  owner_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  title TEXT NOT NULL,
  comment_count INTEGER NOT NULL DEFAULT 0,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

CREATE TABLE public.comments (
  id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
  post_id UUID NOT NULL REFERENCES public.posts(id) ON DELETE CASCADE,
  author_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
  body TEXT NOT NULL,
  created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);

ALTER TABLE public.posts ENABLE ROW LEVEL SECURITY;
ALTER TABLE public.comments ENABLE ROW LEVEL SECURITY;

CREATE POLICY "owners can create posts"
ON public.posts FOR INSERT TO authenticated
WITH CHECK (owner_id = (SELECT auth.uid()));

CREATE POLICY "owners can edit post title"
ON public.posts FOR UPDATE TO authenticated
USING (owner_id = (SELECT auth.uid()))
WITH CHECK (owner_id = (SELECT auth.uid()));

GRANT SELECT, INSERT ON public.posts TO authenticated;
REVOKE UPDATE ON public.posts FROM authenticated;
GRANT UPDATE (title) ON public.posts TO authenticated;

CREATE OR REPLACE FUNCTION public.bump_comment_count()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, pg_temp
AS $$
BEGIN
  UPDATE public.posts
  SET comment_count = comment_count + 1
  WHERE id = NEW.post_id;
  RETURN NEW;
END;
$$;

CREATE TRIGGER comments_bump_post_count
AFTER INSERT ON public.comments
FOR EACH ROW EXECUTE FUNCTION public.bump_comment_count();
```

Here the client has no column privilege to update `comment_count` directly, but
the trusted trigger function can maintain it.

## Legal Insert Payloads

InsForge gives runtime roles broad default DML privileges on `public` tables so
RLS can decide row access. For integrity rules that narrow writes, explicitly
`REVOKE` broad privileges before adding column-level or operation-specific
`GRANT`s.

Column-level grants can accidentally block legitimate API payloads. Before using
column-level `INSERT` grants, list every column a normal SDK/REST caller may send.

Avoid this when callers may send `balance_cents: 0`:

```sql
GRANT INSERT (id, owner_id, name) ON public.accounts TO authenticated;
```

Prefer allowing the legal create payload and protecting later mutation:

```sql
GRANT SELECT, INSERT ON public.accounts TO authenticated;
REVOKE UPDATE ON public.accounts FROM authenticated;
GRANT UPDATE (name) ON public.accounts TO authenticated;
```

The same rule applies to `latest_revision_id = NULL`, `comment_count = 0`,
`current_status = 'draft'`, and other server-maintained initial values.

## Immutable Fields and Append-Only Tables

Guard fields that must never change after creation: `owner_id`, `tenant_id`,
business identifiers, immutable slugs, or ledger account IDs.

```sql
CREATE OR REPLACE FUNCTION public.prevent_owner_change()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN
  IF NEW.owner_id IS DISTINCT FROM OLD.owner_id THEN
    RAISE EXCEPTION 'owner_id cannot be changed';
  END IF;
  RETURN NEW;
END;
$$;

CREATE TRIGGER prevent_owner_change
BEFORE UPDATE ON public.documents
FOR EACH ROW EXECUTE FUNCTION public.prevent_owner_change();
```

For append-only rows such as revisions, ledger entries, audit events, and claims:

```sql
REVOKE UPDATE, DELETE ON public.ledger_entries FROM authenticated;
GRANT SELECT, INSERT ON public.ledger_entries TO authenticated;
```

Add trigger guards only if privileged or future grants might otherwise mutate
history.

## Latest Pointer and History Pattern

For document revisions or status history, keep history append-only and maintain a
latest pointer on the parent.

```sql
CREATE OR REPLACE FUNCTION public.set_latest_revision()
RETURNS trigger
LANGUAGE plpgsql
SECURITY DEFINER
SET search_path = pg_catalog, public, pg_temp
AS $$
BEGIN
  UPDATE public.documents
  SET latest_revision_id = NEW.id
  WHERE id = NEW.document_id;
  RETURN NEW;
END;
$$;

CREATE TRIGGER revisions_set_latest
AFTER INSERT ON public.document_revisions
FOR EACH ROW EXECUTE FUNCTION public.set_latest_revision();
```

Do not add a parent guard that rejects every `latest_revision_id` change unless
it also allows this trusted transition. The simpler pattern is to prevent client
updates to that column with privileges and let the trigger maintain it.

## Self-Check Before Finishing

- Can a legal parent insert pass with default, `NULL`, or zero derived values?
- Can a legal child/event insert pass?
- Does the child/event insert update the parent derived field?
- Is direct client mutation of derived fields blocked?
- Are immutable owner, tenant, and business identity fields protected?
- Are append-only child/history rows protected from update and delete?
- Do trigger functions that must bypass runtime privileges use `SECURITY DEFINER`
  with `SET search_path = pg_catalog, public, pg_temp` and schema-qualify
  references such as `public.documents`?
- Do RLS helpers that query RLS-enabled tables use `SECURITY DEFINER` and
  `SET search_path = pg_catalog, public, pg_temp`, then schema-qualify references
  such as `public.team_members` and `auth.uid()`?
- Are foreign keys and columns used by guards, RLS, and lookups indexed where
  the table can grow?

SHA-256: 4042039c30dbebef009459fcbf807370ff7e3abd7a4c95313b759ba130e3a34e