Guide

Client Rules Editor Kit: JSON Schemas, Approvals, and Audit Log (Retool + Softr)

A paste‑ready kit to ship a client‑facing rules editor with JSON Schema validation, approvals, and a tamper‑evident audit trail. Includes schemas, Retool and Softr variants, n8n validator, and Postgres DDLs.

Ship a safe, client‑facing rules editor in hours—not weeks. This guide gives you paste‑ready schemas, tables, and app snippets for two variants: Retool + Postgres and Softr + Airtable/Softr DB. Both enforce JSON Schema in the UI, re‑validate server‑side before writes, gate changes behind approvals, and append an immutable audit trail.

How to use this kit

Pick one path to start (you can support both if your clients run mixed stacks):

  • Retool + Postgres: Best for B2B clients who already authenticate to a Retool portal and where you control a Postgres config store.
  • Softr + Airtable/Softr DB: Best for lighter client portals or where the client insists on Airtable. Prefer Softr DB if you expect high volume.

What “done” looks like

  • Clients edit rules via constrained forms (no free‑text JSON).
  • Every submit runs server‑side JSON Schema validation and returns human‑readable errors in <2s.
  • Drafts move through Pending → Approved/Rejected, with approver notes.
  • Only approved JSON versions get written to the live Config store.
  • An append‑only, hash‑chained audit_log records who/when/what (+ diffs).

Prereqs

  • A Postgres instance (for Retool path and/or the audit log)
  • An automation runner (n8n recommended here) with an HTTP endpoint
  • Your portal (Retool or Softr) with end‑user auth and role scoping

Core data model and statuses

Status states (unified for both variants)

  • draft: created by end user but not submitted
  • pending: submitted; awaiting approval
  • approved: approved; frozen; written to Config store
  • rejected: declined; stays readable with notes
  • superseded: replaced by a newer approved ruleset

Tables (logical model)

  • rule_drafts: proposals from clients; status lifecycle lives here
  • config_rulesets: the approved, versioned JSON used in production
  • approvals: who approved/rejected a draft and why
  • audit_log: append‑only, hash‑chained ledger of all proposals, decisions, and writes

JSON Schemas (3x) + example rule packs

Use one schema per ruleset type. Keep them small, explicit, and friendly to error messages. These are 2020‑12 JSON Schemas.

Routing thresholds — schema

{
  &quot;$schema&quot;: &quot;https://json-schema.org/draft/2020-12/schema&quot;,
  &quot;title&quot;: &quot;Routing thresholds ruleset&quot;,
  &quot;type&quot;: &quot;object&quot;,
  &quot;required&quot;: [&quot;version&quot;, &quot;rules&quot;],
  &quot;properties&quot;: {
    &quot;version&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1},
    &quot;rules&quot;: {
      &quot;type&quot;: &quot;array&quot;,
      &quot;minItems&quot;: 1,
      &quot;items&quot;: {&quot;$ref&quot;: &quot;#/definitions/rule&quot;}
    }
  },
  &quot;definitions&quot;: {
    &quot;rule&quot;: {
      &quot;type&quot;: &quot;object&quot;,
      &quot;required&quot;: [&quot;id&quot;, &quot;metric&quot;, &quot;op&quot;, &quot;threshold&quot;, &quot;route_to&quot;],
      &quot;properties&quot;: {
        &quot;id&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^[a-z0-9_-]{3,32}$&quot;},
        &quot;metric&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;lead_score&quot;, &quot;aov_usd&quot;, &quot;arr_usd&quot;, &quot;ticket_age_mins&quot;]},
        &quot;op&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;&gt;&quot;, &quot;&gt;=&quot;, &quot;&lt;&quot;, &quot;&lt;=&quot;, &quot;==&quot;, &quot;!=&quot;]},
        &quot;threshold&quot;: {&quot;type&quot;: &quot;number&quot;},
        &quot;time_window_mins&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1, &quot;maximum&quot;: 10080},
        &quot;route_to&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;tier1&quot;, &quot;tier2&quot;, &quot;sales&quot;, &quot;support&quot;, &quot;vip&quot;]},
        &quot;effective_at&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;format&quot;: &quot;date-time&quot;}
      },
      &quot;additionalProperties&quot;: false
    }
  },
  &quot;additionalProperties&quot;: false
}

Routing thresholds — example pack

{
  &quot;version&quot;: 3,
  &quot;rules&quot;: [
    {&quot;id&quot;: &quot;vip_by_arr&quot;, &quot;metric&quot;: &quot;arr_usd&quot;, &quot;op&quot;: &quot;&gt;=&quot;, &quot;threshold&quot;: 50000, &quot;route_to&quot;: &quot;vip&quot;},
    {&quot;id&quot;: &quot;old_tickets&quot;, &quot;metric&quot;: &quot;ticket_age_mins&quot;, &quot;op&quot;: &quot;&gt;&quot;, &quot;threshold&quot;: 240, &quot;route_to&quot;: &quot;tier2&quot;}
  ]
}

SLA windows — schema

{
  &quot;$schema&quot;: &quot;https://json-schema.org/draft/2020-12/schema&quot;,
  &quot;title&quot;: &quot;SLA windows by priority&quot;,
  &quot;type&quot;: &quot;object&quot;,
  &quot;required&quot;: [&quot;version&quot;, &quot;priorities&quot;],
  &quot;properties&quot;: {
    &quot;version&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1},
    &quot;priorities&quot;: {
      &quot;type&quot;: &quot;array&quot;,
      &quot;minItems&quot;: 1,
      &quot;items&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;required&quot;: [&quot;name&quot;, &quot;respond_within_mins&quot;],
        &quot;properties&quot;: {
          &quot;name&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;p0&quot;, &quot;p1&quot;, &quot;p2&quot;, &quot;p3&quot;]},
          &quot;respond_within_mins&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 5, &quot;maximum&quot;: 10080},
          &quot;resolve_within_mins&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 5, &quot;maximum&quot;: 10080},
          &quot;business_hours&quot;: {&quot;type&quot;: &quot;boolean&quot;},
          &quot;timezone&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^[A-Za-z_]+\\/[A-Za-z_]+$&quot;}
        },
        &quot;additionalProperties&quot;: false
      }
    }
  },
  &quot;additionalProperties&quot;: false
}

SLA windows — example pack

{
  &quot;version&quot;: 1,
  &quot;priorities&quot;: [
    {&quot;name&quot;: &quot;p0&quot;, &quot;respond_within_mins&quot;: 15, &quot;resolve_within_mins&quot;: 240, &quot;business_hours&quot;: false, &quot;timezone&quot;: &quot;America/Chicago&quot;},
    {&quot;name&quot;: &quot;p2&quot;, &quot;respond_within_mins&quot;: 240, &quot;resolve_within_mins&quot;: 2880, &quot;business_hours&quot;: true, &quot;timezone&quot;: &quot;America/Chicago&quot;}
  ]
}

PII masking — schema

{
  &quot;$schema&quot;: &quot;https://json-schema.org/draft/2020-12/schema&quot;,
  &quot;title&quot;: &quot;PII masking rules&quot;,
  &quot;type&quot;: &quot;object&quot;,
  &quot;required&quot;: [&quot;version&quot;, &quot;fields&quot;],
  &quot;properties&quot;: {
    &quot;version&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1},
    &quot;fields&quot;: {
      &quot;type&quot;: &quot;array&quot;,
      &quot;items&quot;: {
        &quot;type&quot;: &quot;object&quot;,
        &quot;required&quot;: [&quot;field&quot;, &quot;mask&quot;],
        &quot;properties&quot;: {
          &quot;field&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^[a-zA-Z0-9_]{2,64}$&quot;},
          &quot;mask&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;full&quot;, &quot;partial&quot;, &quot;hash&quot;]},
          &quot;reveal_for_roles&quot;: {&quot;type&quot;: &quot;array&quot;, &quot;items&quot;: {&quot;type&quot;: &quot;string&quot;, &quot;enum&quot;: [&quot;admin&quot;, &quot;ops&quot;, &quot;auditor&quot;]}},
          &quot;partial_keep_last&quot;: {&quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1, &quot;maximum&quot;: 10}
        },
        &quot;allOf&quot;: [
          {&quot;if&quot;: {&quot;properties&quot;: {&quot;mask&quot;: {&quot;const&quot;: &quot;partial&quot;}}},
           &quot;then&quot;: {&quot;required&quot;: [&quot;partial_keep_last&quot;]}}
        ],
        &quot;additionalProperties&quot;: false
      }
    }
  },
  &quot;additionalProperties&quot;: false
}

PII masking — example pack

{
  &quot;version&quot;: 2,
  &quot;fields&quot;: [
    {&quot;field&quot;: &quot;email&quot;, &quot;mask&quot;: &quot;partial&quot;, &quot;partial_keep_last&quot;: 3, &quot;reveal_for_roles&quot;: [&quot;admin&quot;, &quot;auditor&quot;]},
    {&quot;field&quot;: &quot;ssn&quot;, &quot;mask&quot;: &quot;hash&quot;}
  ]
}

Retool variant (forms, validation, writes)

Use Retool’s JSON‑Schema‑driven form (or standard Form with mirrored constraints) and a Workflow/API call that re‑validates with the same schema before any write. Below is a minimal, copy‑paste starting point.

Postgres DDL — drafts and live rulesets

create table if not exists rule_drafts (
  id uuid primary key default gen_random_uuid(),
  client_id text not null,
  type text not null check (type in (&#39;routing&#39;,&#39;sla&#39;,&#39;pii&#39;)),
  proposed_json jsonb not null,
  status text not null check (status in (&#39;draft&#39;,&#39;pending&#39;,&#39;approved&#39;,&#39;rejected&#39;,&#39;superseded&#39;)) default &#39;draft&#39;,
  submitted_by text,
  submitted_at timestamptz,
  reviewed_by text,
  reviewed_at timestamptz,
  rejection_reason text,
  created_at timestamptz not null default now()
);

create table if not exists config_rulesets (
  id uuid primary key default gen_random_uuid(),
  client_id text not null,
  type text not null check (type in (&#39;routing&#39;,&#39;sla&#39;,&#39;pii&#39;)),
  version int not null,
  rules jsonb not null,
  status text not null check (status in (&#39;active&#39;,&#39;superseded&#39;)) default &#39;active&#39;,
  created_by text not null,
  created_at timestamptz not null default now()
);

create unique index on config_rulesets (client_id, type, status) where status=&#39;active&#39;;

Retool app — minimal JSON (component + events)

{
  &quot;version&quot;: 1,
  &quot;components&quot;: [
    {
      &quot;type&quot;: &quot;JSONSchemaForm&quot;,
      &quot;name&quot;: &quot;rulesForm&quot;,
      &quot;schema&quot;: &quot;{{ resources.schemas.value[tabType.value] }}&quot;,
      &quot;uiSchema&quot;: {&quot;ui:submitButtonOptions&quot;: {&quot;norender&quot;: false, &quot;submitText&quot;: &quot;Submit for approval&quot;}},
      &quot;onSubmit&quot;: [
        {&quot;type&quot;: &quot;run&quot;, &quot;query&quot;: &quot;validateAndStage&quot;}
      ]
    },
    {&quot;type&quot;: &quot;Table&quot;, &quot;name&quot;: &quot;approvalQueue&quot;, &quot;data&quot;: &quot;{{ getPendingDrafts.data }}&quot;}
  ],
  &quot;queries&quot;: [
    {&quot;name&quot;: &quot;getPendingDrafts&quot;, &quot;resource&quot;: &quot;postgres&quot;, &quot;type&quot;: &quot;sql&quot;, &quot;text&quot;: &quot;select * from rule_drafts where status=&#39;pending&#39; order by submitted_at desc&quot;},
    {&quot;name&quot;: &quot;validateAndStage&quot;, &quot;type&quot;: &quot;restapi&quot;, &quot;resource&quot;: &quot;validator_api&quot;, &quot;method&quot;: &quot;POST&quot;, &quot;url&quot;: &quot;/validate&quot;,
     &quot;body&quot;: {&quot;type&quot;: &quot;{{ tabType.value }}&quot;, &quot;payload&quot;: &quot;{{ rulesForm.data }}&quot;, &quot;client_id&quot;: &quot;{{ currentUser.client_id }}&quot;},
     &quot;onSuccess&quot;: [
       {&quot;type&quot;: &quot;run&quot;, &quot;query&quot;: &quot;upsertDraft&quot;},
       {&quot;type&quot;: &quot;notification&quot;, &quot;message&quot;: &quot;Draft submitted for approval&quot;}
     ],
     &quot;onFailure&quot;: [
       {&quot;type&quot;: &quot;notification&quot;, &quot;message&quot;: &quot;{{ formatErrors.data }}&quot;, &quot;notificationType&quot;: &quot;error&quot;}
     ]
    },
    {&quot;name&quot;: &quot;upsertDraft&quot;, &quot;resource&quot;: &quot;postgres&quot;, &quot;type&quot;: &quot;sql&quot;,
     &quot;text&quot;: &quot;insert into rule_drafts (client_id, type, proposed_json, status, submitted_by, submitted_at) values ({{ currentUser.client_id }}, {{ tabType.value }}, {{ validateAndStage.data.sanitized_json }}, &#39;pending&#39;, {{ currentUser.email }}, now()) returning id&quot;}
  ]
}

Approver action (Retool Table row action → Workflow/API)

  • approveDraft(draft_id, comment?) → validates again (belt‑and‑suspenders), writes to config_rulesets with version = last_version+1, flips previous active to superseded, marks draft approved, appends to audit_log.
  • rejectDraft(draft_id, reason) → marks rejected, appends to audit_log.

Security notes

  • Use Retool External Users or SSO; assign roles “client_editor” (create/submit) and “client_approver” (approve). Keep “approver” internal if your contract requires.

Softr variant (workflow, field map, sync UX)

Pattern: Softr page writes the client’s JSON into a long‑text field on rule_drafts only after a synchronous Workflow → Webhook to your validator returns ok. If validation fails, you show the error toast and never persist.

Page structure

  • Rules Editor page (visible to role: client_editor): segmented by type (routing | sla | pii) with guided controls (no free JSON textarea).
  • Approval Queue page (role: client_approver): list of pending drafts with Approve/Reject actions.

Field map (Airtable or Softr DB)

  • rule_drafts
    • id (UUID or record id)
    • client_id (text)
    • type (single select: routing | sla | pii)
    • proposed_json (long text)
    • status (single select: draft | pending | approved | rejected | superseded)
    • submitted_by (email)
    • submitted_at (datetime)
    • reviewed_by (email)
    • reviewed_at (datetime)
    • rejection_reason (long text)

Synchronous Workflow (submit button → Workflow → Webhook)

  1. Build JSON from form widgets into a clean object.
  2. Call Webhook: POST /validate with { type, payload, client_id }.
  3. If 200: create/update rule_drafts row with status=pending and proposed_json from sanitized response. Show success toast.
  4. If 4xx: show first 3 error messages; keep user on page.

Example webhook request/response

// Request
{
  &quot;type&quot;: &quot;routing&quot;,
  &quot;client_id&quot;: &quot;acme&quot;,
  &quot;payload&quot;: {&quot;version&quot;: 3, &quot;rules&quot;: [{&quot;id&quot;:&quot;vip_by_arr&quot;,&quot;metric&quot;:&quot;arr_usd&quot;,&quot;op&quot;:&quot;&gt;=&quot;,&quot;threshold&quot;:50000,&quot;route_to&quot;:&quot;vip&quot;}]}
}

// 200 OK response (sanitized)
{
  &quot;ok&quot;: true,
  &quot;sanitized_json&quot;: {&quot;version&quot;:3, &quot;rules&quot;:[{&quot;id&quot;:&quot;vip_by_arr&quot;,&quot;metric&quot;:&quot;arr_usd&quot;,&quot;op&quot;:&quot;&gt;=&quot;,&quot;threshold&quot;:50000,&quot;route_to&quot;:&quot;vip&quot;}]}
}

// 422 Unprocessable Entity
{
  &quot;ok&quot;: false,
  &quot;errors&quot;: [&quot;rules[0].op must be one of: &gt;, &gt;=, &lt;, &lt;=, ==, !=&quot;],
  &quot;path&quot;: [&quot;rules&quot;,0,&quot;op&quot;]
}

Throughput tips

  • Airtable: watch ~5 req/s/base ceilings; batch reads/writes and cache reference lists client‑side. If you hit limits, move the tables to Softr DB.
  • Keep synchronous validations under ~2s; kick off heavy cross‑system checks async after the draft is created.

Server‑side validation subworkflow (n8n + AJV)

Drop this in n8n as a subworkflow called validate_rules_payload. It validates against one of the three schemas, returns sanitized JSON on success, or a 422 with error messages on failure.

n8n workflow export (trimmed)

{
  &quot;name&quot;: &quot;validate_rules_payload&quot;,
  &quot;nodes&quot;: [
    {&quot;parameters&quot;: {&quot;path&quot;: &quot;validate&quot;, &quot;options&quot;: {&quot;responseCode&quot;: 200}}, &quot;id&quot;: &quot;Webhook&quot;, &quot;name&quot;: &quot;Webhook&quot;, &quot;type&quot;: &quot;n8n-nodes-base.webhook&quot;, &quot;typeVersion&quot;: 1, &quot;position&quot;: [200, 200] },
    {&quot;parameters&quot;: {&quot;functionCode&quot;: &quot;const Ajv = require(&#39;ajv&#39;);\nconst ajv = new Ajv({allErrors:true, strict:true});\nconst body = items[0].json;\nconst type = body.type;\nconst payload = body.payload;\nconst schemas = {\n  routing: $json.schemas.routing,\n  sla: $json.schemas.sla,\n  pii: $json.schemas.pii\n};\nif(!schemas[type]) {throw new Error(&#39;Unknown schema type&#39;);}\nconst validate = ajv.compile(schemas[type]);\nconst ok = validate(payload);\nif(!ok){\n  return [{json:{status:422, ok:false, errors:validate.errors.map(e=&gt;`${e.instancePath||&#39;(root)&#39;} ${e.message}`)}}];\n}\nreturn [{json:{status:200, ok:true, sanitized_json:payload}}];&quot;}, &quot;id&quot;: &quot;Code&quot;, &quot;name&quot;: &quot;Code (AJV)&quot;, &quot;type&quot;: &quot;n8n-nodes-base.code&quot;, &quot;typeVersion&quot;: 2, &quot;position&quot;: [460, 200] },
    {&quot;parameters&quot;: {&quot;responseBody&quot;: &quot;={{$json}}&quot;, &quot;responseCode&quot;: &quot;={{$json.status}}&quot;}, &quot;id&quot;: &quot;Respond&quot;, &quot;name&quot;: &quot;Respond&quot;, &quot;type&quot;: &quot;n8n-nodes-base.respondToWebhook&quot;, &quot;typeVersion&quot;: 1, &quot;position&quot;: [720, 200] }
  ],
  &quot;connections&quot;: {&quot;Webhook&quot;: {&quot;main&quot;: [[{&quot;node&quot;: &quot;Code (AJV)&quot;, &quot;type&quot;: &quot;main&quot;, &quot;index&quot;: 0}]]}, &quot;Code (AJV)&quot;: {&quot;main&quot;: [[{&quot;node&quot;: &quot;Respond&quot;, &quot;type&quot;: &quot;main&quot;, &quot;index&quot;: 0}]]}},
  &quot;meta&quot;: {&quot;schemas&quot;: {&quot;routing&quot;: {/* paste routing schema */}, &quot;sla&quot;: {/* paste SLA schema */}, &quot;pii&quot;: {/* paste PII schema */}}}
}

Usage from Retool/Softr

  • POST /validate with { type, payload, client_id }
  • Treat any 4xx as blocking; never persist until you get ok:true.
  • Optionally add a second step that normalizes keys, sorts arrays, or injects defaults before returning sanitized_json.

Immutable audit log (append‑only + hash chain)

This pattern makes tampering obvious and prevents UPDATE/DELETE on the ledger.

Enable crypto and table

create extension if not exists pgcrypto;

create table if not exists audit_log (
  id bigserial primary key,
  occurred_at timestamptz not null default now(),
  actor text not null,
  action text not null, -- e.g., &#39;draft_submitted&#39;,&#39;draft_approved&#39;,&#39;ruleset_activated&#39;
  target_table text not null,
  target_id text not null,
  diff jsonb, -- patch or summary
  prev_hash bytea,
  row_hash bytea not null
);

Hash function + trigger

create or replace function audit_row_hash(ts timestamptz, actor text, action text, target_table text, target_id text, diff jsonb, prev bytea)
returns bytea language sql immutable as $$
  select digest(coalesce(to_jsonb($1)::text,&#39;&#39;) || &#39;|&#39; || $2 || &#39;|&#39; || $3 || &#39;|&#39; || $4 || &#39;|&#39; || $5 || &#39;|&#39; || coalesce($6::text,&#39;&#39;) || &#39;|&#39; || encode(coalesce($7,&#39;&#39;), &#39;hex&#39;), &#39;sha256&#39;);
$$;

create or replace function audit_log_before_insert()
returns trigger language plpgsql as $$
declare last_hash bytea;
begin
  select row_hash into last_hash from audit_log order by id desc limit 1;
  new.prev_hash := last_hash;
  new.row_hash := audit_row_hash(new.occurred_at, new.actor, new.action, new.target_table, new.target_id, new.diff, new.prev_hash);
  return new;
end; $$;

create trigger audit_log_bi before insert on audit_log
for each row execute function audit_log_before_insert();

-- Hard block updates/deletes
create or replace function audit_log_no_change()
returns trigger language plpgsql as $$ begin raise exception &#39;audit_log is append-only&#39;; end; $$;
create trigger audit_log_bu before update on audit_log for each row execute function audit_log_no_change();
create trigger audit_log_bd before delete on audit_log for each row execute function audit_log_no_change();

Sample writes

-- on submit
insert into audit_log (actor, action, target_table, target_id, diff)
values (&#39;user:alice@client.com&#39;,&#39;draft_submitted&#39;,&#39;rule_drafts&#39;,&#39;&lt;draft_id&gt;&#39;, jsonb_build_object(&#39;type&#39;,&#39;routing&#39;));

-- on approve + activate
insert into audit_log (actor, action, target_table, target_id, diff)
values (&#39;user:ops@you.com&#39;,&#39;ruleset_activated&#39;,&#39;config_rulesets&#39;,&#39;&lt;ruleset_id&gt;&#39;, jsonb_build_object(&#39;version&#39;, 4, &#39;client_id&#39;,&#39;acme&#39;,&#39;type&#39;,&#39;routing&#39;));

Chain verification (periodic job)

with chained as (
  select id, row_hash, prev_hash,
         lag(row_hash) over (order by id) as should_equal
  from audit_log
)
select * from chained where coalesce(prev_hash,&#39;&#39;) &lt;&gt; coalesce(should_equal,&#39;&#39;);

Tip: Also enable pgAudit (SQL‑level evidence) if your platform allows extensions.

Latency budgets, limits, and rollback pattern

Keep the sync path snappy and shift heavy checks to async.

Target budgets (good UX on typical SMB portals)

  • Client submit → server‑side validate → toast: under 2s
  • Approve → write to Config + audit append: under 1s
  • Async enrichment (e.g., cross‑system dry‑run): any length, but update a “check_status” field and notify on completion

Timeouts and limits to design around

  • Retool synchronous response blocks: hard cap around 120s per block; keep validate/write in the fast path, move enrichment to async.
  • Airtable API: roughly 5 req/s per base; batch operations and leverage Softr DB if you outgrow Airtable.

Rollback and safety rails

  • Version every approved ruleset; never mutate in place.
  • Keep a “dry‑run_config_id” toggle in your app so clients can preview routing/SLA effects before promotion.
  • Always re‑validate on Approve even if you validated on Submit (defense in depth).