Skip to content

Segment Examples

OUT OF SCOPE — nothing on this page is built (reconciled 2026-08-02)

F8 Segments was settled out of scope on 2026-08-02 — it belongs to a later patient-data-segmentation feature, not to the current platform-completion effort. See platform-completion.md → Scope.

Verified against migrations 000001000039 on 2026-08-02: no segments, segment_members, or segment_versions table exists. Neither do the tables every example on this page queries — no forms, form_templates, custom_fields, custom_field_values, or appointments. There is no segments Go domain, no /v1/segments route, and zero segments.* permission rows are seeded in any migration — they exist only as prose in rbac-permissions.md. If F8 ever ships, its migration seeds them or every policy denies.

The SQL blocks are illustrative, not runnable. They sketch what a rule might compile to. They are not the queries an engine emits, and they will not execute against any database that exists today.

This page was corrected — not redesigned — on 2026-08-02. It was written against a superseded design generation (patient_persons, patient_person_id, user_id, integer PKs) that never shipped. Dead names are fixed below so the page is not actively misleading; the segment model itself is deliberately left alone. When F8 is picked up, data-model.md → Area 8 is authoritative for the schema and the rule DSL is re-derived from scratch.

Reading these examples

Four things to hold in mind, because the examples predate the shipped schema:

IDs are UUIDv7, never integers (P26). Every template_id: 5 / custom_field_id: 11 below is a readability stand-in for a UUID. No table in this platform has an integer or BIGSERIAL primary key. The SQL blocks use named parameters (:intake_template_id) instead of integer literals for the same reason.

The placeholder legend, so the numbers below are readable:

Stand-inMeans
custom_field_id: 10city
custom_field_id: 11pain_level
custom_field_id: 12blood_type
custom_field_id: 13age
custom_field_id: 14registration_date
custom_field_id: 15email
custom_field_id: 16notes
custom_field_id: 17symptoms
custom_field_id: 18satisfaction
custom_field_id: 19intake_complete
template_id: 5intake survey
template_id: 10follow-up appointment type
template_id: 20feedback form
template_id: 30post-surgery form

Several examples model as custom fields things that are first-class columns. patient_profiles already ships blood_type, date_of_birth, residence, phone, allergies, and chronic_conditions as real columns — so blood type (ex. 5, 16), city (ex. 2, 4, 5, 6, 8, 9), and registration date (ex. 12, 24 — patients.created_at and patient_profiles.created_at both exist, and they mean different things) do not belong in a generic value store. Age (ex. 5, 13, 22) is not stored at all — it is derived from date_of_birth, so an age >= 65 rule is really a date predicate, and comparing it as a TEXT EAV value sorts lexically ('9' > '50'). A real F8 rule DSL must address profile columns as profile fields and reserve custom_field_values for genuinely org-defined fields. Related hard rule, settled 2026-08-02: a national identifier (CNP) may never land in custom_field_values.value — see data-model.md → Area 6.

There is no hand-passed :org_id. Tenant scoping is enforced in the database by RLS against current_app_org_id(), not by an application-supplied predicate (P1). The original SQL on this page filtered WHERE p.organization_id = :org_id, which reads as app-layer tenancy; the corrected blocks drop it and note where RLS does the work.


Simple Examples

Example 1: Form Response Filter

Use case: All patients who reported "Big pain" in intake survey.

json
{
  "name": "High Pain Patients",
  "description": "Patients reporting big pain in intake survey",
  "match_mode": "all",
  "rules": [
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "eq",
      "value": "Big pain"
    }
  ]
}

SQL equivalent (illustrative — forms does not exist):

sql
-- No organization_id predicate: RLS scopes every row to
-- current_app_org_id() (P1). Soft-deleted rows are excluded (P13).
SELECT DISTINCT p.id, pf.name
FROM patients p
JOIN patient_profiles pf ON pf.id = p.patient_profile_id
JOIN forms f ON f.patient_profile_id = p.patient_profile_id
WHERE p.deleted_at IS NULL
  AND f.deleted_at IS NULL
  AND f.form_template_id = :intake_template_id
  AND f.status IN ('completed', 'signed')
  AND f.values->>'pain_level' = 'Big pain';

forms.values is keyed by the custom field's key, not by its id — hence 'pain_level' rather than 'field_11'. That is the shape implied by data-model.md → Area 7; the exact addressing is not settled until F3 ships the table.


Example 2: Profile Filter

Use case: All patients located in Bucharest.

json
{
  "name": "Bucharest Patients",
  "description": "All patients located in Bucharest",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Bucharest"
    }
  ]
}

SQL equivalent (illustrative — custom_field_values does not exist):

sql
-- `patients` has no name column; the name lives on patient_profiles.
-- RLS scopes to current_app_org_id() — no :org_id predicate (P1).
SELECT p.id, pf.name
FROM patients p
JOIN patient_profiles pf ON pf.id = p.patient_profile_id
JOIN custom_field_values cfv
  ON cfv.entity_type = 'patient' AND cfv.entity_id = p.id
WHERE p.deleted_at IS NULL
  AND cfv.custom_field_id = :city_field_id
  AND cfv.value = 'Bucharest';

On the shipped schema, "city" is closer to patient_profiles.residence (a real column) than to an EAV row. See Reading these examples.


Example 3: Appointment Count Filter

Use case: Patients with at least 2 completed appointments.

json
{
  "name": "Active Patients",
  "description": "Patients with 2+ completed appointments",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 2,
      "filters": {
        "status": "done"
      }
    }
  ]
}

SQL equivalent (illustrative — appointments does not exist):

sql
-- RLS scopes both tables to current_app_org_id() (P1).
SELECT p.id, pf.name
FROM patients p
JOIN patient_profiles pf ON pf.id = p.patient_profile_id
WHERE p.deleted_at IS NULL
  AND (
    SELECT COUNT(*)
    FROM appointments a
    WHERE a.patient_profile_id = p.patient_profile_id
      AND a.status = 'done'
  ) >= 2;

appointments carries both patient_profile_id (NOT NULL, set at booking) and patient_id (nullable, linked at onboarding) — the two-phase identity model in appointments-substrate.md. A cohort count joins on the profile so pre-onboarding bookings are not silently dropped. appointments has no deleted_at: the nine-value status enum already covers every did-not-happen case.


Multi-Source Examples

Example 4: Combined Form + Profile

Use case: High pain patients in Bucharest.

json
{
  "name": "Bucharest High Pain",
  "description": "Bucharest patients reporting big pain",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Bucharest"
    },
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "eq",
      "value": "Big pain"
    }
  ]
}

SQL equivalent (illustrative — none of these tables exist):

sql
-- RLS scopes every table here to current_app_org_id() (P1);
-- no :org_id predicate is passed from the application.
SELECT p.id, pf.name
FROM patients p
JOIN patient_profiles pf ON pf.id = p.patient_profile_id
WHERE p.deleted_at IS NULL

  -- Profile rule: city
  AND EXISTS (
    SELECT 1 FROM custom_field_values cfv
    WHERE cfv.entity_type = 'patient' AND cfv.entity_id = p.id
      AND cfv.custom_field_id = :city_field_id AND cfv.value = 'Bucharest'
  )

  -- Form rule: pain_level, most recent completed instance
  AND EXISTS (
    SELECT 1 FROM forms f
    WHERE f.patient_profile_id = p.patient_profile_id
      AND f.deleted_at IS NULL
      AND f.form_template_id = :intake_template_id
      AND f.status IN ('completed', 'signed')
      AND f.values->>'pain_level' = 'Big pain'
    ORDER BY f.updated_at DESC
    LIMIT 1
  );

Example 5: The Complex Query

Use case: "Patients with blood type A, from Bucharest, with big pain, 50 years old, and at least 2 completed appointments"

json
{
  "name": "Target Group - Bucharest A-type High Pain Seniors",
  "description": "Complex target group for outreach campaign",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 12,
      "op": "eq",
      "value": "A+"
    },
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Bucharest"
    },
    {
      "source": "profile",
      "custom_field_id": 13,
      "op": "eq",
      "value": "50"
    },
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "eq",
      "value": "Big pain"
    },
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 2,
      "filters": {
        "status": "done"
      }
    }
  ]
}

SQL equivalent (illustrative — none of these tables exist):

sql
-- RLS scopes every table here to current_app_org_id() (P1).
SELECT p.id, pf.name
FROM patients p
JOIN patient_profiles pf ON pf.id = p.patient_profile_id
WHERE p.deleted_at IS NULL

  -- Rule 1: blood_type
  -- NOTE: patient_profiles.blood_type is a real shipped column with a
  -- CHECK constraint on the eight ABO/Rh values. A real rule reads
  -- `pf.blood_type = 'A+'` directly and never round-trips through EAV.
  AND EXISTS (
    SELECT 1 FROM custom_field_values cfv
    WHERE cfv.entity_type = 'patient' AND cfv.entity_id = p.id
      AND cfv.custom_field_id = :blood_type_field_id AND cfv.value = 'A+'
  )

  -- Rule 2: city  (closest shipped column: patient_profiles.residence)
  AND EXISTS (
    SELECT 1 FROM custom_field_values cfv
    WHERE cfv.entity_type = 'patient' AND cfv.entity_id = p.id
      AND cfv.custom_field_id = :city_field_id AND cfv.value = 'Bucharest'
  )

  -- Rule 3: age
  -- NOTE: age is never stored. It is derived from the shipped
  -- patient_profiles.date_of_birth, so the real predicate is a date
  -- comparison, e.g.
  --   pf.date_of_birth <= (CURRENT_DATE - INTERVAL '50 years')
  -- Comparing a TEXT EAV value with >= would also compare lexically:
  -- '9' > '50'. Two separate bugs in one rule.
  AND EXISTS (
    SELECT 1 FROM custom_field_values cfv
    WHERE cfv.entity_type = 'patient' AND cfv.entity_id = p.id
      AND cfv.custom_field_id = :age_field_id AND cfv.value = '50'
  )

  -- Rule 4: pain_level, most recent completed instance of the intake form
  AND EXISTS (
    SELECT 1 FROM forms f
    WHERE f.patient_profile_id = p.patient_profile_id
      AND f.deleted_at IS NULL
      AND f.form_template_id = :intake_template_id
      AND f.status IN ('completed', 'signed')
      AND f.values->>'pain_level' = 'Big pain'
    ORDER BY f.updated_at DESC
    LIMIT 1
  )

  -- Rule 5: at least 2 completed appointments
  AND (
    SELECT COUNT(*) FROM appointments a
    WHERE a.patient_profile_id = p.patient_profile_id
      AND a.status = 'done'
  ) >= 2;

OR Logic Examples

Example 6: Any of Multiple Cities

Use case: Patients from Bucharest OR Cluj-Napoca.

json
{
  "name": "Major Cities",
  "description": "Patients from Bucharest or Cluj-Napoca",
  "match_mode": "any",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Bucharest"
    },
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Cluj-Napoca"
    }
  ]
}

Alternative using in operator (simpler):

json
{
  "name": "Major Cities",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "in",
      "value": ["Bucharest", "Cluj-Napoca"]
    }
  ]
}

Example 7: Pain Levels (Any High Pain)

Use case: Patients reporting "Big pain" OR "Extreme pain".

json
{
  "name": "High Pain Levels",
  "match_mode": "any",
  "rules": [
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "eq",
      "value": "Big pain"
    },
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "eq",
      "value": "Extreme pain"
    }
  ]
}

Alternative using in:

json
{
  "name": "High Pain Levels",
  "match_mode": "all",
  "rules": [
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "in",
      "value": ["Big pain", "Extreme pain"]
    }
  ]
}

Nested Group Examples

Example 8: AND with Nested OR

Use case: Bucharest patients with (Big pain OR Extreme pain).

json
{
  "name": "Bucharest High Pain",
  "description": "Bucharest patients with big or extreme pain",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 10,
      "op": "eq",
      "value": "Bucharest"
    },
    {
      "group": true,
      "match_mode": "any",
      "rules": [
        {
          "source": "form",
          "template_id": 5,
          "custom_field_id": 11,
          "op": "eq",
          "value": "Big pain"
        },
        {
          "source": "form",
          "template_id": 5,
          "custom_field_id": 11,
          "op": "eq",
          "value": "Extreme pain"
        }
      ]
    }
  ]
}

Logical expression: city = "Bucharest" AND (pain_level = "Big pain" OR pain_level = "Extreme pain")

SQL equivalent (illustrative — none of these tables exist):

sql
-- RLS scopes every table here to current_app_org_id() (P1).
SELECT p.id
FROM patients p
WHERE p.deleted_at IS NULL
  AND EXISTS (
    SELECT 1 FROM custom_field_values cfv
    WHERE cfv.entity_type = 'patient' AND cfv.entity_id = p.id
      AND cfv.custom_field_id = :city_field_id AND cfv.value = 'Bucharest'
  )
  AND (
    EXISTS (
      SELECT 1 FROM forms f
      WHERE f.patient_profile_id = p.patient_profile_id
        AND f.deleted_at IS NULL
        AND f.form_template_id = :intake_template_id
        AND f.values->>'pain_level' = 'Big pain'
      ORDER BY f.updated_at DESC LIMIT 1
    )
    OR
    EXISTS (
      SELECT 1 FROM forms f
      WHERE f.patient_profile_id = p.patient_profile_id
        AND f.deleted_at IS NULL
        AND f.form_template_id = :intake_template_id
        AND f.values->>'pain_level' = 'Extreme pain'
      ORDER BY f.updated_at DESC LIMIT 1
    )
  );

Example 9: Complex Nested Groups

Use case: (Bucharest OR Cluj) AND (High pain OR Recent appointment).

json
{
  "name": "Active High-Value Patients",
  "match_mode": "all",
  "rules": [
    {
      "group": true,
      "match_mode": "any",
      "rules": [
        {"source": "profile", "custom_field_id": 10, "op": "eq", "value": "Bucharest"},
        {"source": "profile", "custom_field_id": 10, "op": "eq", "value": "Cluj-Napoca"}
      ]
    },
    {
      "group": true,
      "match_mode": "any",
      "rules": [
        {"source": "form", "template_id": 5, "custom_field_id": 11, "op": "eq", "value": "Big pain"},
        {"source": "appointments", "metric": "last_date", "op": "gte", "value": "now-30d"}
      ]
    }
  ]
}

Logical expression:

(city = "Bucharest" OR city = "Cluj-Napoca")
AND
(pain_level = "Big pain" OR last_appointment >= now-30d)

Relative Date Examples

Example 10: Recent Appointments

Use case: Patients with an appointment in the last 30 days.

json
{
  "name": "Recently Active",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "last_date",
      "op": "gte",
      "value": "now-30d"
    }
  ]
}

Auto-updating: This segment automatically includes patients whose last appointment was within the last 30 days from today (not from when the segment was created).


Example 11: Inactive Patients

Use case: Patients with no appointments in the last 6 months.

json
{
  "name": "Inactive Patients",
  "description": "No appointments in last 6 months",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "last_date",
      "op": "lt",
      "value": "now-6M"
    }
  ]
}

Alternative (count-based):

json
{
  "name": "Inactive Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "eq",
      "value": 0,
      "filters": {
        "after": "now-6M"
      }
    }
  ]
}

Example 12: New Patient Registrations

Use case: Patients who registered in the last 90 days.

json
{
  "name": "New Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 14,
      "op": "gte",
      "value": "now-90d"
    }
  ]
}

Assumption: Organization has a custom field with ID 14 (e.g., registration_date) with field_type = "date".

The assumption is unnecessary on the shipped schema: patients.created_at (registration at this clinic) and patient_profiles.created_at (first registration anywhere on the platform) both exist. A registration_date custom field would duplicate them and drift. Note that the two dates differ, and the clinic-scoped one is almost always the intended meaning.


Range Examples

Example 13: Age Range

Use case: Patients between 18 and 65 years old.

json
{
  "name": "Working Age Adults",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 13,
      "op": "gte",
      "value": 18
    },
    {
      "source": "profile",
      "custom_field_id": 13,
      "op": "lte",
      "value": 65
    }
  ]
}

Future enhancement: Add between operator for cleaner syntax.


Example 14: Appointment Count Range

Use case: Patients with 2-5 appointments (not too new, not too frequent).

json
{
  "name": "Moderate Frequency",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 2
    },
    {
      "source": "appointments",
      "metric": "count",
      "op": "lte",
      "value": 5
    }
  ]
}

Existence Examples

Example 15: Patients with Email

Use case: Patients who have an email address on file.

json
{
  "name": "Emailable Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 15,
      "op": "exists"
    }
  ]
}

Example 16: Patients Missing Critical Data

Use case: Patients without a blood type on file.

json
{
  "name": "Missing Blood Type",
  "description": "Patients needing profile completion",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 12,
      "op": "empty"
    }
  ]
}

String Search Examples

Example 17: Notes Containing Keyword

Use case: Patients with "diabetes" mentioned in their notes.

json
{
  "name": "Diabetes Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 16,
      "op": "contains",
      "value": "diabetes"
    }
  ]
}

Case-insensitive: Matches "diabetes", "Diabetes", "DIABETES".


Use case: Patients reporting headaches in their symptoms form.

json
{
  "name": "Headache Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 17,
      "op": "contains",
      "value": "headache"
    }
  ]
}

Appointment Filter Examples

Example 19: Specific Appointment Type

Use case: Patients with at least 1 follow-up appointment (template 10).

json
{
  "name": "Follow-Up Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 1,
      "filters": {
        "template_id": 10
      }
    }
  ]
}

Example 20: Upcoming Appointments

Use case: Patients with at least 1 upcoming appointment.

json
{
  "name": "Scheduled Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 1,
      "filters": {
        "status": "upcoming"
      }
    }
  ]
}

Example 21: Date Range Filter

Use case: Patients with appointments between Jan 1 and Mar 31, 2025.

json
{
  "name": "Q1 2025 Patients",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 1,
      "filters": {
        "after": "2025-01-01",
        "before": "2025-03-31"
      }
    }
  ]
}

Real-World Use Cases

Example 22: At-Risk Patient Alerts

Use case: Seniors (65+) with chronic pain and no recent appointments (potential churn risk).

json
{
  "name": "At-Risk Seniors",
  "description": "Seniors with chronic pain, no appointments in 90 days",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 13,
      "op": "gte",
      "value": 65
    },
    {
      "source": "form",
      "template_id": 5,
      "custom_field_id": 11,
      "op": "in",
      "value": ["Big pain", "Extreme pain"]
    },
    {
      "source": "appointments",
      "metric": "last_date",
      "op": "lt",
      "value": "now-90d"
    }
  ]
}

Action: Automated outreach email to schedule follow-up.


Example 23: VIP Patients

Use case: Patients with 10+ appointments who gave positive feedback.

json
{
  "name": "VIP Patients",
  "description": "Highly engaged patients with positive feedback",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "count",
      "op": "gte",
      "value": 10
    },
    {
      "source": "form",
      "template_id": 20,
      "custom_field_id": 18,
      "op": "in",
      "value": ["Very satisfied", "Extremely satisfied"]
    }
  ]
}

Action: Send exclusive offers, priority scheduling.


Example 24: New Patient Onboarding

Use case: Patients registered in last 30 days who haven't completed their intake form.

json
{
  "name": "Incomplete Onboarding",
  "match_mode": "all",
  "rules": [
    {
      "source": "profile",
      "custom_field_id": 14,
      "op": "gte",
      "value": "now-30d"
    },
    {
      "group": true,
      "match_mode": "any",
      "rules": [
        {"source": "form", "template_id": 5, "custom_field_id": 19, "op": "empty"},
        {"source": "form", "template_id": 5, "custom_field_id": 19, "op": "eq", "value": "false"}
      ]
    }
  ]
}

Action: Send reminder email to complete intake form.


Example 25: Post-Treatment Follow-Up

Use case: Patients who had surgery (form template 30) in the last 2 weeks.

json
{
  "name": "Post-Surgery Follow-Up",
  "match_mode": "all",
  "rules": [
    {
      "source": "appointments",
      "metric": "last_date",
      "op": "gte",
      "value": "now-14d",
      "filters": {
        "template_id": 30
      }
    }
  ]
}

Action: Schedule follow-up call, send recovery instructions.


Anti-Patterns (What NOT to Do)

Anti-Pattern 1: Too Many Rules

Bad:

json
{
  "name": "Overly Specific",
  "match_mode": "all",
  "rules": [
    /* 20 rules here */
  ]
}

Problem: Performance degrades with > 10 rules. Difficult to maintain.

Solution: Split into multiple segments or use nested groups to organize.


Anti-Pattern 2: Hardcoded Dates

Bad:

json
{
  "source": "appointments",
  "metric": "last_date",
  "op": "gte",
  "value": "2025-01-01"
}

Problem: Segment becomes outdated. Needs manual updates.

Solution: Use relative dates:

json
{
  "source": "appointments",
  "metric": "last_date",
  "op": "gte",
  "value": "now-30d"
}

Anti-Pattern 3: Deep Nesting

Bad:

json
{
  "group": true,
  "match_mode": "all",
  "rules": [
    {
      "group": true,
      "rules": [
        {
          "group": true,
          "rules": [
            {
              "group": true,
              "rules": [/* ... */]
            }
          ]
        }
      ]
    }
  ]
}

Problem: Exceeds max nesting depth (3 levels). Unreadable, slow.

Solution: Flatten logic or split into multiple segments.


Testing Segments

Test Segment with Known Patient

Only step 1 and the cleanup step describe routes that exist. Everything else is unbuilt.

bash
# 1. Link an existing portable profile to the org as a patient.
#    SHIPPED. Org-scoped route, gated by `patients.manage`.
#    The patient's name lives on patient_profiles, not on patients —
#    the request body takes a profile UUID, not a name.
POST /v1/organizations/{orgId}/patients
{
  "patient_profile_id": "<uuid of an existing patient_profiles row>"
}

# 2. Set custom field values.  UNBUILT — no custom_field_values table,
#    no route. Note that city / age / blood_type are all first-class
#    patient_profiles columns on the shipped schema.

# 3. Fill a test form.  UNBUILT — no forms table, no route.
#    The subject of a form is `patient_profile_id` (UUID). There is no
#    `user_id`: there is no users table, and every actor is a principal.
POST /v1/forms
{
  "form_template_id": "<uuid>",
  "patient_profile_id": "<uuid>",
  "values": { "pain_level": "Big pain" }
}

# 4. Evaluate the segment.  UNBUILT — no /v1/segments routes.
POST /v1/segments/{id}/evaluate
GET  /v1/segments/{id}/members

# 5. Cleanup — SOFT DELETE ONLY. Patient records are never hard-deleted
#    (CLAUDE.md → Patient Data); GDPR erasure anonymises the profile and
#    keeps the structure. The shipped route archives (sets deleted_at).
DELETE /v1/organizations/{orgId}/patients/{patientId}

Performance Tips

These describe intended behaviour of an engine that does not exist. Nothing below is measured.

  1. Start simple, iterate: Begin with 1-2 rules, add complexity as needed
  2. Use in for lists: More efficient than multiple eq rules with OR
  3. Limit contains usage: Slower than exact matches
  4. Leverage indexes: Aspirational. There are no profile-field or form-value indexes, because there are no custom_field_values or forms tables. When F3 ships them, the JSONB forms.values column is GIN-indexed per data-model.md → Area 7; the EAV lookup needs its own index on (custom_field_id, entity_type, entity_id). A contains rule additionally needs a GIN trigram index and unaccent() folding, or Romanian diacritics break matching ("Stefan" must match "Ștefan").
  5. Monitor eval times: Aspirational. There is no /metrics endpoint on the API service today.
  6. Prefer Tier 1 for critical segments: Keep high-priority segments simple (≤3 rules, single source)

Summary

Nothing in this list is implemented. Segments are out of scope; the checkmarks below record what the design contemplated, not what the platform does. Read them as a requirements sketch for whoever picks F8 up.

The design contemplated:

  • Form responses (JSONB queries) — needs F3 forms
  • Profile data (custom field values) — needs F3 custom_fields; profile columns are already on patient_profiles
  • Appointment metrics (count, dates) — needs F5 appointments
  • Multi-source rules (forms + profile + appointments)
  • Nested groups (AND/OR logic)
  • Relative dates (auto-updating)
  • String search (contains) — needs trigram + unaccent to work in Romanian
  • Range filters (gt, lt, between-equivalent) — needs typed comparison, not TEXT EAV values
  • Existence checks (exists, empty)

Three dependencies are load-bearing and none of them exists yet: F3 Forms, F5 Appointments, and the per-org segments.* permission rows that today live only in rbac-permissions.md prose. F8 cannot start before F3 and F5 land.

Do not use these examples as templates against the current platform — the tables they read are not there.