ANC CRM — Rules of Engagement for AI Agents

Everything below was paid for with a production incident, a wedged chat, a wrong number in a stakeholder's inbox, or a day of someone's time. Read before touching crm.ancsports.net.

Canonical living source: the crm-knowledge skill at /root/.claude/skills/crm-knowledge/SKILL.md. Read it before, update it after.


0. Before you touch anything


1. The cache is the enemy — there are three layers

Twenty caches metadata in three places: an in-process local cache (30 min TTL), Redis flat-maps, and the DB. A correct DB row means nothing if the layer in front is stale.


2. The overrides jsonb trap

Any entity seeded by twenty-standard-application (viewField, viewFieldGroup, viewFilter, viewSort, viewGroup, view, dashboard, pageLayout) has an overrides JSONB column, and the DTO converter spreads overrides OVER the column values:

const { createdAt, updatedAt, deletedAt, overrides, ...rest } = flatEntity;
return { ...rest, ...overrides ?? {} };   // overrides win

So UPDATE ... SET position = -1 is a silent no-op when overrides->>'position' is also set. Update both in lockstep, or clear overrides entirely to fall back to columns:

UPDATE core."viewFieldGroup"
SET position = N,
    overrides = jsonb_set(COALESCE(overrides,'{}'::jsonb), '{position}', to_jsonb(N))
WHERE id = '<uuid>';

Same lens applies to fieldMetadata.standardOverrides.


3. Standard vs custom metadata


4. Changing a SELECT / enum option

Adding one option rebuilds the whole postgres enum (rename column → drop). Check dependencies first — do not assume:

-- columns still on the enum type (incl. stale backup tables)
SELECT n.nspname||'.'||c.relname||'.'||a.attname FROM pg_attribute a
  JOIN pg_class c ON c.oid=a.attrelid JOIN pg_namespace n ON n.oid=c.relnamespace
  JOIN pg_type t ON t.oid=a.atttypid
 WHERE t.typname LIKE '%<fieldName>%' AND a.attnum>0 AND NOT a.attisdropped;
-- triggers naming the field
SELECT tgname FROM pg_trigger t JOIN pg_class c ON c.oid=t.tgrelid
 WHERE NOT tgisinternal AND pg_get_triggerdef(t.oid) ILIKE '%<fieldName>%';

Then: DROP the dependent trigger → detype backup-table columns to text → run updateOneField → recreate the trigger verbatim. Back up the options array first.


5. Views


6. Permissions — the counter-intuitive ones


7. The AI layer (models, Scout, chat)


8. Data — the rules that prevent wrong numbers


9. Apps and front components


10. Env-var safety (hard rule)

A previous AI session wiped every env var on a production service with a write-only "add". The only sanctioned pattern is read → merge → verify → write:

  1. Snapshot the full env to /root/.env-snapshots/<svc>-<ts>.json first.
  2. Merge; never full-replace.
  3. Diff and assert the result is a strict superset — no var lost, only the intended key changed.
  4. Write with docker service update --env-add "KEY=VALUE" --update-order start-first <svc>.
  5. Re-read and confirm.

CRM env changes must be applied to BOTH abc_twenty and abc_twenty-worker. Each write restarts the container: ~60s of 502s. Plan for it.


11. Infrastructure facts


12. Verification — the part that is not optional


Compiled 2026-08-20 from the crm-knowledge skill, project memory, and the incident record.