Skip to content

FEAT-021: Segments — Zealot Groups ported onto CustomerRef - #100

Merged
agreenspan merged 5 commits into
mainfrom
FEAT-021-segments
Sep 10, 2026
Merged

agreenspan merged 5 commits into
mainfrom
FEAT-021-segments

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

FEAT-021: Segments

Zealot's Groups / FanUsersGroup ported onto the template's CustomerRef. Ticket: tickets/FEAT-021-segments.md (decisions, what shipped, open items).

Shape (after the 2026-09-10 rulings)

  • Segment — owner false-polymorphic over ProviderModel (the provider whose customers are segmented, so the owner enum is CustomerRef.providerModel); conditions (required) json-rules tree over a lens rooted at CustomerRef; type static | dynamic says when the rule runs — static is computed once when created or its conditions change, dynamic continuously; reconcile-pause triple (cycle | danglingReference | dynamicReference | evaluationError); soft-delete; audit-enabled.
  • SegmentMembersegmentId × customerRefId, written only by reconcile. A hand-picked audience is a static segment whose rule is id in [...]; there is no source column and no member add/remove endpoint.
  • ProviderModel grew to User | Organization | SpaceCustomerRef.providerUserId / providerOrganizationId, relations, every customer × provider partial unique, registry provider axis, factory deps, includeProvider / provider schema carry all three.
  • LenssegmentLensFor(ownerModel): tenancy bind on the provider FK, customer branches (User / Organization / Space → contacts, tag attachments, received communications), segmentMembers → segment.id with a Segment.id source so "members of segment X" is a picker. A boot probe asserts the membership source stays reachable for every provider branch.
  • HookssegmentConditions (required on every write, validate against the lens, normalize, refuse self-reference / path-read references / references to segments the owner does not have), segmentMemberOwner (member's customer ref must belong to the owner as provider), segmentReconcile (post-commit enqueue: a segment's own row → reconcileSegment for either type; writes on every other model the lens reaches → per-customer-ref job, dynamic segments only; the model→customer-ref reverse map refuses to boot if the lens reaches a model it cannot map).
  • JobsreconcileSegment (set-based via toPrisma, superseding per segment, fans out to dynamic dependents), reconcileCustomerRefSegments (hydrate one ref via fetchLens, check() per dynamic segment in dependency order), sweepSegments (cron 04:00 UTC: pause verdicts + dependency-ordered enqueue of dynamic segments).
  • App eventsegment.membershipChanged → websocket refetch of the member list + per-user meReadManySegmentMemberships refetch.
  • Routes/segment/:id read/update/delete, /segment/:id/segmentMembers list; segments + segmentMemberships read-many on /me, /user/:id, /organization/:id, /space/:id (/v1/user is mounted for the first time; db:user read = self or admin); segments create on /me, /organization/:id, /space/:id. The customer-facing membership shape carries id / name / owner / type / createdAt only — the rule is opaque to members.
  • Web — Segments page (owned + member-of) in user, organization and space contexts.

One call to review

Inbound triggers are DB hooks, outbound is the app event. Zealot triggers off app events; the template's row-level event log is AuditLog, and every write goes through the hooked client, so the webhook hook's shape was used. Flipping to app events is a small change (hook body → emits in controllers; handler → job stays).

Validation

  • apps/api/src/modules/segment/tests/ — 35 tests: conditions hook (required on both types; user / organization owners validate against their own lens), reference graph, pause verdicts, reconcile (set-based, provider scoping, static computed at save and inert afterwards, hand-picked ids scoped to the owner's customers, user and organization as providers, user-write trigger, contact-write trigger through the owner, cross-provider communications excluded, segment-of-segment in one pass and in dependency order, evaluation-error retry, flip to static keeps members / flip back catches up), routes end to end (customer-facing shape, user-context reads self-only, hand-picked static segment through the API).
  • An adversarial review pass ran before the first push; its confirmed findings (sender bind on communicationsReceived, in-memory segment-of-segment row, permanent evaluation-error pause, provider rule leaking through /me/segmentMemberships, unowned references accepted at save) are fixed and pinned by tests.
  • bun run typecheck: clean for every workspace except @template/sdk, whose tracked src/client/index.ts imports untracked ../core/*.gen files that exist only in the main checkout as stale artifacts (pre-existing; not touched here). bun run typecheck:fe: clean.
  • bun test (api): 1065 pass, 2 pre-existing failures in lensWhere.test.ts (atLeast rules written without a condition, untouched here). db 293, permissions 81, ui 244 pass.
  • biome check on every file this branch touches: clean. Repo-wide lint has 134 pre-existing errors in files this branch does not touch.
  • CI rules: no-raw-global-assign-in-tests fails on two pre-existing files not touched here.

🤖 Generated with Claude Code

https://claude.ai/code/session_01VjP2vYXsVd8AELiHnRvQzK

agreenspan and others added 4 commits September 10, 2026 13:33
…-driven reconcile, hooks, jobs, routes, web page

Port of Zealot's Groups onto the template's CustomerRef. Segment is false-polymorphically owned
(User|Organization|Space = the provider), static or dynamic; SegmentMember carries source rule|manual.
segmentLensFor(owner) is the single source for validation, evaluation, hydration and reference
extraction. Inbound triggers are a post-commit DB hook on every model the lens reaches; recalculation
lives in reconcileSegment (toPrisma, set-based) and reconcileCustomerRefSegments (fetchLens + check),
with sweepSegments as the cron backstop and segment.membershipChanged as the outbound app event.
Routes under /segment, /segmentMember, /me, /organization/:id, /space/:id; Segments page in all three
web contexts. Adversarial-review fixes folded in: sender bind on received communications, in-memory
segment-of-segment row, retryable evaluation-error pause, customer-facing membership pick, unowned
references refused at save, freeze on flip to static.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjP2vYXsVd8AELiHnRvQzK
- ProviderModel grows to User | Organization | Space and becomes Segment.ownerModel; CustomerRef gains providerUserId / providerOrganizationId with relations, partial uniques, indexes, registry + factory + include coverage
- static = computed at create / conditions change, dynamic = continuous; a hand-picked audience is a static `id in [...]` rule, so SegmentMember.source, the freeze hook, and the manual add / delete member routes go
- /v1/user mounted with segments + segmentMemberships read-many (self or admin), completing the me / user / organization / space pair
- tests, docs, ticket, kanban follow

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01VjP2vYXsVd8AELiHnRvQzK
Segment registers on both RuleReference axes; the edge writer moves to
packages/db as syncRuleReferenceEdges so email and segments share it. The
pause triple and its enum are gone: a segment's edges are written by the
segmentRuleReferences after-write hook, both reconcile rails go through
withRule (degraded evaluates nothing new, never a match), and the owner's
reads carry ruleIssues. A membership loop is refused at save. Ruling
2026-09-10 on INFRA-030.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
…on narrowed updates, held dead references stay editable, owner id required, batched edges on the entity rail

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
@agreenspan
agreenspan changed the base branch from INFRA-030-reference-registry to main September 10, 2026 16:36
… additions

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant