diff --git a/Zeta.sln b/Zeta.sln
index fe653c14..64df853e 100644
--- a/Zeta.sln
+++ b/Zeta.sln
@@ -20,6 +20,8 @@ Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Bayesian.Tests", "tests\Bay
EndProject
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "Feldera.Bench", "bench\Feldera.Bench\Feldera.Bench.fsproj", "{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}"
EndProject
+Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "FactoryDemo.Api.FSharp", "samples\FactoryDemo.Api.FSharp\FactoryDemo.Api.FSharp.fsproj", "{40534D09-439E-4E5F-9A69-A73844DB674D}"
+EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -66,5 +68,9 @@ Global
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AAAAAAAA-AAAA-AAAA-AAAA-AAAAAAAAAAAA}.Release|Any CPU.Build.0 = Release|Any CPU
+ {40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
+ {40534D09-439E-4E5F-9A69-A73844DB674D}.Debug|Any CPU.Build.0 = Debug|Any CPU
+ {40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.ActiveCfg = Release|Any CPU
+ {40534D09-439E-4E5F-9A69-A73844DB674D}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal
diff --git a/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj b/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj
new file mode 100644
index 00000000..a825afaf
--- /dev/null
+++ b/samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj
@@ -0,0 +1,16 @@
+
+
+ Exe
+ Zeta.Samples.FactoryDemo.Api
+ true
+
+
+
+
+
+
+
+
+
+
+
diff --git a/samples/FactoryDemo.Api.FSharp/Program.fs b/samples/FactoryDemo.Api.FSharp/Program.fs
new file mode 100644
index 00000000..035c9a9f
--- /dev/null
+++ b/samples/FactoryDemo.Api.FSharp/Program.fs
@@ -0,0 +1,90 @@
+module Zeta.Samples.FactoryDemo.Api.Program
+
+open System
+open Microsoft.AspNetCore.Builder
+open Microsoft.AspNetCore.Http
+open Microsoft.Extensions.DependencyInjection
+
+// Minimal F# ASP.NET Core Web API serving the seed data for the
+// factory-demo. Stack-independent — any frontend choice (Blazor /
+// React / Vue / Svelte / curl) consumes the same JSON.
+//
+// V0 scope: in-memory seed only, no DB wiring. A Postgres backing
+// with schema.sql + seed-data.sql is a planned follow-up PR; until
+// then this API carries its own in-memory seed so the sample runs
+// with zero external dependencies.
+
+let private pipelineFunnel () =
+ Seed.opportunities
+ |> List.groupBy (fun o -> o.Stage)
+ |> List.map (fun (stage, opps) ->
+ {| Stage = stage
+ Count = opps |> List.length
+ TotalCents = opps |> List.sumBy (fun o -> o.AmountCents) |})
+
+let private duplicateEmails () =
+ Seed.customers
+ |> List.groupBy (fun c -> c.Email)
+ |> List.filter (fun (_, xs) -> xs |> List.length > 1)
+ |> List.map (fun (email, xs) ->
+ {| Email = email
+ CustomerIds = xs |> List.map (fun c -> c.Id) |})
+
+[]
+let main args =
+ let builder = WebApplication.CreateBuilder(args)
+ // Emit consistent JSON for an external consumer — default
+ // System.Text.Json options are fine; documented for future tuning.
+ builder.Services.AddEndpointsApiExplorer() |> ignore
+ let app = builder.Build()
+
+ // Trivial root — lets a browser confirm the API is up. The
+ // `endpoints` list is the full contract surface (all 9 paths
+ // listed, including parameterised routes).
+ app.MapGet("/", Func<_>(fun () ->
+ {| name = "Factory-demo API (F#)"
+ version = "0.0.1"
+ endpoints =
+ [ "/"
+ "/api/customers"
+ "/api/customers/{id}"
+ "/api/customers/{id}/activities"
+ "/api/opportunities"
+ "/api/opportunities/{id}"
+ "/api/activities"
+ "/api/pipeline/funnel"
+ "/api/pipeline/duplicates" ] |} :> obj))
+ |> ignore
+
+ app.MapGet("/api/customers", Func<_>(fun () -> Seed.customers :> obj)) |> ignore
+ app.MapGet("/api/customers/{id:long}", Func(fun id ->
+ Seed.customers
+ |> List.tryFind (fun c -> c.Id = id)
+ |> function
+ | Some c -> Results.Ok c
+ | None -> Results.NotFound()))
+ |> ignore
+
+ app.MapGet("/api/opportunities", Func<_>(fun () -> Seed.opportunities :> obj)) |> ignore
+ app.MapGet("/api/opportunities/{id:long}", Func(fun id ->
+ Seed.opportunities
+ |> List.tryFind (fun o -> o.Id = id)
+ |> function
+ | Some o -> Results.Ok o
+ | None -> Results.NotFound()))
+ |> ignore
+
+ app.MapGet("/api/activities", Func<_>(fun () -> Seed.activities :> obj)) |> ignore
+ app.MapGet("/api/customers/{id:long}/activities", Func(fun id ->
+ Seed.activities
+ |> List.filter (fun a -> a.CustomerId = id)
+ :> obj))
+ |> ignore
+
+ // Derived views that a frontend would otherwise compute client-
+ // side. Landing them server-side keeps the API contract tight.
+ app.MapGet("/api/pipeline/funnel", Func<_>(fun () -> pipelineFunnel () :> obj)) |> ignore
+ app.MapGet("/api/pipeline/duplicates", Func<_>(fun () -> duplicateEmails () :> obj)) |> ignore
+
+ app.Run()
+ 0
diff --git a/samples/FactoryDemo.Api.FSharp/README.md b/samples/FactoryDemo.Api.FSharp/README.md
new file mode 100644
index 00000000..6cb04a96
--- /dev/null
+++ b/samples/FactoryDemo.Api.FSharp/README.md
@@ -0,0 +1,101 @@
+# Factory-demo — JSON API (F#)
+
+**What this is:** A minimal F# ASP.NET Core Web API that serves
+the demo's seed data as JSON. Stack-independent — any frontend
+(Blazor / React / Vue / curl) consumes the same endpoints.
+
+**What this is NOT:** A pitch for Zeta as a data store. The
+demo sells the **software factory**, not the database layer.
+Backend is in-memory (v0) and will be swapped to Postgres (v1)
+without changing the public API contract.
+
+## Why this sample exists
+
+The factory-demo needs a JSON API that any frontend choice can
+consume. This API ships now so the backend is not on the
+critical path when the frontend stack is chosen.
+
+This is the F# reference implementation. A C# companion sample
+(`samples/FactoryDemo.Api.CSharp/`, sibling PR #147) is planned
+with the same 9 endpoints, matching JSON shapes, and identical
+seed. C# is the more popular .NET language, so it is the natural
+primary demo path; F# stays the reference because F# looks
+closer to math, which makes theorems over the algebra easier
+to express.
+
+## How to run
+
+```bash
+dotnet run --project samples/FactoryDemo.Api.FSharp/FactoryDemo.Api.FSharp.fsproj
+# API is up on http://localhost:5000 (or whatever ASP.NET picks).
+# curl it:
+curl http://localhost:5000/api/customers
+curl http://localhost:5000/api/pipeline/funnel
+curl http://localhost:5000/api/pipeline/duplicates
+```
+
+## Endpoints (v0)
+
+| Method | Path | Returns |
+|---|---|---|
+| GET | `/` | API metadata + endpoint list |
+| GET | `/api/customers` | All customers |
+| GET | `/api/customers/{id}` | Single customer, 404 if missing |
+| GET | `/api/customers/{id}/activities` | Activities for one customer |
+| GET | `/api/opportunities` | All opportunities |
+| GET | `/api/opportunities/{id}` | Single opportunity, 404 if missing |
+| GET | `/api/activities` | All activities |
+| GET | `/api/pipeline/funnel` | Per-stage count + $ total |
+| GET | `/api/pipeline/duplicates` | Customers sharing an email |
+
+All responses are JSON.
+
+## V0 seed data
+
+`Seed.fs` carries the in-memory seed (v1 will mirror a Postgres
+seed-data.sql under `samples/FactoryDemo.Db/`, not yet in repo):
+
+- 20 customers (trades contractors — plumbing, HVAC, electric, roofing, etc.)
+- 30 opportunities across 5 stages (Lead, Qualified, Proposal, Won, Lost)
+- 33 activities (calls, emails, SMS, notes)
+- 2 intentional email collisions to drive the duplicate-review scenario
+
+Seed is deterministic — restarting the server replays the same data.
+
+## What v1 adds
+
+- Postgres backing (Npgsql) wired against a planned
+ `samples/FactoryDemo.Db/` schema + seed (not yet in repo)
+- CRUD endpoints (POST / PUT / DELETE) — v0 is read-only
+- docker-compose for one-command Postgres + API
+- Environment-variable configuration for connection string
+
+Each of those is a follow-up PR.
+
+## Design notes
+
+- **`Microsoft.NET.Sdk.Web` SDK.** Pulls in ASP.NET Core via
+ framework reference — no package version edit in
+ `Directory.Packages.props` needed. Only `FSharp.Core` is an
+ explicit package reference.
+- **Minimal APIs over MVC.** F# + minimal APIs is a clean
+ combination: one file, no controllers, no heavy routing ceremony.
+- **Anonymous records for derived views.** `/api/pipeline/funnel`
+ returns an anonymous record with `Stage`, `Count`, and
+ `TotalCents` fields using F#'s `{| Stage = ...; Count = ...;
+ TotalCents = ... |}` syntax. Keeps the output shape local to
+ the endpoint handler.
+- **`System.Text.Json` defaults.** No converter customisation in v0.
+ If a frontend needs camelCase / different date shapes, add it as
+ a targeted follow-up rather than reshape everything.
+- **No OpenAPI / Swagger yet.** v0 intentionally minimal; Swagger
+ UI lands when endpoint count grows or frontend needs it.
+
+## What this does NOT do
+
+- Does not persist writes — v0 is read-only
+- Does not authenticate or authorise — no auth for the demo v0
+- Does not wire to Postgres — in-memory for v0
+- Does not expose algebraic-delta / retraction-native internals
+ to the frontend (that's the internal kernel sample's job; the
+ factory-demo audience gets standard CRUD shape)
diff --git a/samples/FactoryDemo.Api.FSharp/Seed.fs b/samples/FactoryDemo.Api.FSharp/Seed.fs
new file mode 100644
index 00000000..1f7439d6
--- /dev/null
+++ b/samples/FactoryDemo.Api.FSharp/Seed.fs
@@ -0,0 +1,151 @@
+module Zeta.Samples.FactoryDemo.Api.Seed
+
+open System
+
+// Deterministic in-memory seed data for the API sample. Keep these
+// values stable so the same demo scenarios work whether the backing
+// store is Postgres (production-shape, planned follow-up) or this
+// in-memory fallback (zero-dependency, current).
+
+type Customer =
+ { Id: int64
+ Name: string
+ Email: string
+ Phone: string
+ Address: string
+ CreatedAt: DateTimeOffset
+ UpdatedAt: DateTimeOffset }
+
+type Opportunity =
+ { Id: int64
+ CustomerId: int64
+ Stage: string
+ AmountCents: int64
+ CreatedAt: DateTimeOffset
+ UpdatedAt: DateTimeOffset }
+
+type Activity =
+ { Id: int64
+ CustomerId: int64
+ OpportunityId: int64 option
+ Kind: string
+ Notes: string
+ OccurredAt: DateTimeOffset }
+
+// Fixed clock so the seed is deterministic. Any timestamp computed
+// from this is reproducible across restarts.
+let private now = DateTimeOffset(2026, 4, 23, 0, 0, 0, TimeSpan.Zero)
+let private ago (days: int) = now.AddDays(float -days)
+
+let customers : Customer list =
+ let mk id name email phone address =
+ { Id = id
+ Name = name
+ Email = email
+ Phone = phone
+ Address = address
+ CreatedAt = ago 120
+ UpdatedAt = ago 1 }
+ // Email collision #1: Alice Plumbing (1) and Aaron Smith (13) share alice@acme.example.
+ // Email collision #2: Bob HVAC (5) and Quincy Assistant (19) share bob@trades.example.
+ [ mk 1L "Alice Plumbing LLC" "alice@acme.example" "555-0101" "123 Elm St, Portland OR"
+ mk 2L "Benson Roofing" "benson@roof.example" "555-0102" "45 Oak Ave, Seattle WA"
+ mk 3L "Crystal Electric" "crystal@sparks.example" "555-0103" "9 Pine Rd, Boise ID"
+ mk 4L "Delta HVAC & Mechanical" "delta@hvac.example" "555-0104" "700 Main St, Spokane WA"
+ mk 5L "Bob HVAC Services" "bob@trades.example" "555-0105" "12 Bay Blvd, Tacoma WA"
+ mk 6L "Evergreen Landscaping" "info@evergreen.example" "555-0106" "88 Forest Ln, Eugene OR"
+ mk 7L "Fairbanks Plumbing" "contact@fairbanks.example" "555-0107" "5 River Rd, Anchorage AK"
+ mk 8L "Granite Pest Control" "hello@granite.example" "555-0108" "301 Stone Way, Boise ID"
+ mk 9L "Highland Roofing Co" "highland@roof.example" "555-0109" "22 Hill Dr, Bend OR"
+ mk 10L "Iron Tree Electric" "iron@tree.example" "555-0110" "17 Spruce St, Salem OR"
+ mk 11L "Jackson Pool Services" "jackson@pools.example" "555-0111" "600 Lake Rd, Reno NV"
+ mk 12L "Klein Garage Doors" "klein@doors.example" "555-0112" "44 4th Ave, Medford OR"
+ mk 13L "Aaron Smith (new contact)" "alice@acme.example" "555-0113" "123 Elm St, Portland OR"
+ mk 14L "Lakeview Solar" "lakeview@solar.example" "555-0114" "250 Shore Dr, Bellevue WA"
+ mk 15L "Mountain Well Drilling" "mountain@wells.example" "555-0115" "12 Ridge Rd, Coeur dAlene ID"
+ mk 16L "Nightingale Security" "ngale@secure.example" "555-0116" "88 Watch Way, Vancouver WA"
+ mk 17L "Oak Hill Septic" "oak@septic.example" "555-0117" "14 Rural Rt 3, Gresham OR"
+ mk 18L "Prairie Window Cleaning" "prairie@windows.example" "555-0118" "66 Glass Rd, Kennewick WA"
+ mk 19L "Quincy Assistant (Bob HVAC)" "bob@trades.example" "555-0119" "12 Bay Blvd, Tacoma WA"
+ mk 20L "Redwood Tree Service" "redwood@trees.example" "555-0120" "3 Canopy Ct, Hillsboro OR" ]
+
+let opportunities : Opportunity list =
+ let mk id custId stage amount =
+ { Id = id
+ CustomerId = custId
+ Stage = stage
+ AmountCents = amount
+ CreatedAt = ago 30
+ UpdatedAt = ago 2 }
+ [ mk 1L 1L "Lead" 250000L
+ mk 2L 1L "Qualified" 800000L
+ mk 3L 2L "Lead" 180000L
+ mk 4L 3L "Proposal" 450000L
+ mk 5L 3L "Won" 120000L
+ mk 6L 4L "Lead" 2200000L
+ mk 7L 4L "Qualified" 600000L
+ mk 8L 5L "Proposal" 350000L
+ mk 9L 5L "Won" 900000L
+ mk 10L 6L "Lead" 150000L
+ mk 11L 7L "Qualified" 500000L
+ mk 12L 7L "Proposal" 700000L
+ mk 13L 8L "Won" 220000L
+ mk 14L 9L "Lead" 300000L
+ mk 15L 9L "Lead" 1800000L
+ mk 16L 10L "Qualified" 950000L
+ mk 17L 11L "Proposal" 1400000L
+ mk 18L 12L "Won" 380000L
+ mk 19L 13L "Lead" 50000L
+ mk 20L 14L "Proposal" 2500000L
+ mk 21L 14L "Qualified" 1100000L
+ mk 22L 15L "Won" 600000L
+ mk 23L 16L "Lead" 180000L
+ mk 24L 17L "Qualified" 270000L
+ mk 25L 18L "Lead" 80000L
+ mk 26L 19L "Proposal" 320000L
+ mk 27L 20L "Won" 450000L
+ mk 28L 20L "Lead" 210000L
+ mk 29L 2L "Lost" 90000L
+ mk 30L 6L "Lost" 400000L ]
+
+let activities : Activity list =
+ let mk id custId oppId kind notes daysAgo =
+ { Id = id
+ CustomerId = custId
+ OpportunityId = oppId
+ Kind = kind
+ Notes = notes
+ OccurredAt = ago daysAgo }
+ [ mk 1L 1L (Some 1L) "Call" "Initial intake call — 3 units, basement finish" 14
+ mk 2L 1L (Some 1L) "Email" "Sent follow-up with rough estimate" 13
+ mk 3L 1L (Some 2L) "Call" "Scope expanded to full house repipe" 6
+ mk 4L 2L (Some 3L) "Email" "Insurance paperwork sent for roof claim" 10
+ mk 5L 3L (Some 4L) "Call" "Walkthrough scheduled for Tuesday" 8
+ mk 6L 3L (Some 5L) "Note" "Payment received — closed won" 3
+ mk 7L 4L (Some 6L) "Call" "Commercial HVAC replacement — 6 rooftop units" 20
+ mk 8L 4L (Some 6L) "Email" "Technical specs and load calcs sent" 18
+ mk 9L 4L (Some 7L) "Call" "Second opportunity — server-room cooling" 5
+ mk 10L 5L (Some 8L) "SMS" "Confirmed 10am arrival window" 2
+ mk 11L 5L (Some 9L) "Note" "Deposit received; scheduled for next week" 7
+ mk 12L 6L (Some 10L) "Email" "Initial inquiry from website" 4
+ mk 13L 7L (Some 11L) "Call" "Alaska project — remote site, flew tools in" 30
+ mk 14L 7L (Some 12L) "Email" "Proposal sent with permitting schedule" 15
+ mk 15L 8L (Some 13L) "Note" "Quarterly service contract signed" 45
+ mk 16L 9L (Some 14L) "Call" "Storm damage — needs quick turnaround" 1
+ mk 17L 9L (Some 15L) "Email" "Large hotel roof — sent credentials package" 2
+ mk 18L 10L (Some 16L) "Call" "Panel upgrade consult" 11
+ mk 19L 11L (Some 17L) "SMS" "Pool opening scheduled for May 1" 5
+ mk 20L 12L (Some 18L) "Note" "Installed — 3yr warranty registered" 60
+ mk 21L 13L (Some 19L) "Email" "Intro call tomorrow 2pm" 1
+ mk 22L 14L (Some 20L) "Call" "Roof assessment + solar compatibility check" 12
+ mk 23L 14L (Some 21L) "Email" "Federal tax credit paperwork sent" 9
+ mk 24L 15L (Some 22L) "Note" "Test-well results clean; contract signed" 25
+ mk 25L 16L (Some 23L) "Call" "Camera system walkthrough" 6
+ mk 26L 17L (Some 24L) "SMS" "Septic pump appointment confirmed" 3
+ mk 27L 18L (Some 25L) "Email" "Storefront window quote" 7
+ mk 28L 19L (Some 26L) "Call" "Coordinating with Bob HVAC on combined job" 4
+ mk 29L 20L (Some 27L) "Note" "Repeat customer — 2nd tree removal this year" 40
+ mk 30L 20L (Some 28L) "Email" "Quarterly pruning proposal" 2
+ mk 31L 2L (Some 29L) "Note" "Customer went with competitor on price" 22
+ mk 32L 6L (Some 30L) "Note" "Lost deal — decided to self-install" 18
+ mk 33L 1L None "Email" "General follow-up — hope repipe went well" 90 ]
diff --git a/samples/FactoryDemo.Api.FSharp/smoke-test.sh b/samples/FactoryDemo.Api.FSharp/smoke-test.sh
new file mode 100755
index 00000000..09bd911b
--- /dev/null
+++ b/samples/FactoryDemo.Api.FSharp/smoke-test.sh
@@ -0,0 +1,125 @@
+#!/usr/bin/env bash
+# Factory-demo F# API smoke test — exercises all 9 endpoints and validates
+# the JSON-shape contract. Exits 0 on pass, 1 on any failure.
+#
+# Usage:
+# bash samples/FactoryDemo.Api.FSharp/smoke-test.sh
+#
+# Starts the API on a random free port, waits for /, hits each endpoint,
+# verifies response shape + key invariants (row counts, duplicate-pair
+# identity, funnel totals). Stops the API cleanly on exit.
+#
+# Dependencies on host: dotnet, curl, jq. A C# sibling API (sibling
+# PR #147) will land its own parallel smoke-test so parity between
+# the two APIs is ground-truth-testable.
+
+set -euo pipefail
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+PROJECT="$SCRIPT_DIR/FactoryDemo.Api.FSharp.fsproj"
+
+for cmd in dotnet curl jq; do
+ if ! command -v "$cmd" >/dev/null; then
+ echo "Missing required tool: $cmd" >&2
+ exit 2
+ fi
+done
+
+# Pick a high random port to avoid clashes with other dev services.
+PORT=$(( 5100 + RANDOM % 400 ))
+URL="http://localhost:${PORT}"
+
+echo "Building API..."
+dotnet build "$PROJECT" -c Release --nologo -v quiet >/dev/null
+echo "Starting API on ${URL}..."
+
+dotnet run --project "$PROJECT" -c Release --no-build --urls "$URL" \
+ > /tmp/factory-demo-api-fsharp.log 2>&1 &
+API_PID=$!
+
+cleanup() {
+ kill "$API_PID" 2>/dev/null || true
+ wait "$API_PID" 2>/dev/null || true
+}
+trap cleanup EXIT
+
+for _ in {1..20}; do
+ if curl -sf "${URL}/" >/dev/null 2>&1; then
+ break
+ fi
+ sleep 0.5
+done
+
+if ! curl -sf "${URL}/" >/dev/null 2>&1; then
+ echo "API did not come up within budget. Log:" >&2
+ cat /tmp/factory-demo-api-fsharp.log >&2
+ exit 1
+fi
+
+fail=0
+check() {
+ local label="$1"
+ local path="$2"
+ local jq_expr="$3"
+ local expected="$4"
+ local actual
+ actual=$(curl -sf "${URL}${path}" | jq -r "$jq_expr" 2>/dev/null || echo "ERROR")
+ if [ "$actual" = "$expected" ]; then
+ printf " OK %-50s (%s)\n" "$label" "$actual"
+ else
+ printf " FAIL %-50s expected=%s got=%s\n" "$label" "$expected" "$actual"
+ fail=1
+ fi
+}
+
+echo ""
+echo "Factory-demo F# API smoke test"
+echo "=============================="
+
+# Root metadata — F# anonymous-record fields declared lowercase emit
+# lowercase JSON property names. Same as the C# sibling after
+# System.Text.Json default camelCasing. The `(.Name // .name | ...)`
+# jq pattern below is tolerant either way, which keeps the parity
+# test resilient if field-casing conventions drift between the two
+# APIs in the future.
+check "root.name contains 'Factory-demo'" "/" "(.Name // .name | test(\"Factory-demo\"))" "true"
+
+check "/api/customers length" "/api/customers" ". | length" "20"
+check "/api/opportunities length" "/api/opportunities" ". | length" "30"
+check "/api/activities length" "/api/activities" ". | length" "33"
+
+check "customer #1 name" "/api/customers/1" "(.Name // .name)" "Alice Plumbing LLC"
+check "opportunity #1 stage" "/api/opportunities/1" "(.Stage // .stage)" "Lead"
+
+check "customer #1 activities count" "/api/customers/1/activities" ". | length" "4"
+
+# Pipeline funnel — per-stage counts. F# emits PascalCase; jq handles both.
+check "funnel Lead count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lead\")].[0] | (.Count // .count)" "10"
+check "funnel Qualified count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Qualified\")].[0] | (.Count // .count)" "6"
+check "funnel Won count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Won\")].[0] | (.Count // .count)" "6"
+check "funnel Lost count" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lost\")].[0] | (.Count // .count)" "2"
+
+check "funnel Lead totalCents" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Lead\")].[0] | (.TotalCents // .totalCents)" "5400000"
+check "funnel Won totalCents" "/api/pipeline/funnel" "[.[] | select((.Stage // .stage)==\"Won\")].[0] | (.TotalCents // .totalCents)" "2670000"
+
+check "duplicate pairs count" "/api/pipeline/duplicates" ". | length" "2"
+check "alice@acme.example pair members" "/api/pipeline/duplicates" "[.[] | select((.Email // .email)==\"alice@acme.example\")].[0] | (.CustomerIds // .customerIds) | join(\",\")" "1,13"
+check "bob@trades.example pair members" "/api/pipeline/duplicates" "[.[] | select((.Email // .email)==\"bob@trades.example\")].[0] | (.CustomerIds // .customerIds) | join(\",\")" "5,19"
+
+# 404 behavior
+status=$(curl -o /dev/null -s -w "%{http_code}" "${URL}/api/customers/999")
+if [ "$status" = "404" ]; then
+ printf " OK %-50s (%s)\n" "missing customer HTTP status" "404"
+else
+ printf " FAIL %-50s expected=404 got=%s\n" "missing customer HTTP status" "$status"
+ fail=1
+fi
+
+echo ""
+if [ "$fail" -eq 0 ]; then
+ echo "All checks passed."
+ exit 0
+else
+ echo "One or more checks failed — see /tmp/factory-demo-api-fsharp.log for server output."
+ exit 1
+fi