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 000001–000039 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-in | Means |
|---|---|
custom_field_id: 10 | city |
custom_field_id: 11 | pain_level |
custom_field_id: 12 | blood_type |
custom_field_id: 13 | age |
custom_field_id: 14 | registration_date |
custom_field_id: 15 | email |
custom_field_id: 16 | notes |
custom_field_id: 17 | symptoms |
custom_field_id: 18 | satisfaction |
custom_field_id: 19 | intake_complete |
template_id: 5 | intake survey |
template_id: 10 | follow-up appointment type |
template_id: 20 | feedback form |
template_id: 30 | post-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.
{
"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):
-- 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.valuesis keyed by the custom field'skey, 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.
{
"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):
-- `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.
{
"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):
-- 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;
appointmentscarries bothpatient_profile_id(NOT NULL, set at booking) andpatient_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.appointmentshas nodeleted_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.
{
"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):
-- 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"
{
"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):
-- 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.
{
"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):
{
"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".
{
"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:
{
"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).
{
"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):
-- 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).
{
"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.
{
"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.
{
"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):
{
"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.
{
"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) andpatient_profiles.created_at(first registration anywhere on the platform) both exist. Aregistration_datecustom 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.
{
"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).
{
"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.
{
"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.
{
"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.
{
"name": "Diabetes Patients",
"match_mode": "all",
"rules": [
{
"source": "profile",
"custom_field_id": 16,
"op": "contains",
"value": "diabetes"
}
]
}Case-insensitive: Matches "diabetes", "Diabetes", "DIABETES".
Example 18: Symptom Search
Use case: Patients reporting headaches in their symptoms form.
{
"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).
{
"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.
{
"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.
{
"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).
{
"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.
{
"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.
{
"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.
{
"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:
{
"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:
{
"source": "appointments",
"metric": "last_date",
"op": "gte",
"value": "2025-01-01"
}Problem: Segment becomes outdated. Needs manual updates.
Solution: Use relative dates:
{
"source": "appointments",
"metric": "last_date",
"op": "gte",
"value": "now-30d"
}Anti-Pattern 3: Deep Nesting
Bad:
{
"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.
# 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.
- Start simple, iterate: Begin with 1-2 rules, add complexity as needed
- Use
infor lists: More efficient than multipleeqrules with OR - Limit
containsusage: Slower than exact matches - Leverage indexes: Aspirational. There are no profile-field or form-value indexes, because there are no
custom_field_valuesorformstables. When F3 ships them, the JSONBforms.valuescolumn is GIN-indexed per data-model.md → Area 7; the EAV lookup needs its own index on(custom_field_id, entity_type, entity_id). Acontainsrule additionally needs a GIN trigram index andunaccent()folding, or Romanian diacritics break matching ("Stefan" must match "Ștefan"). - Monitor eval times: Aspirational. There is no
/metricsendpoint on the API service today. - 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 onpatient_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 +
unaccentto 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.