Skip to content

FEAT-021: Segments — reinstated with business-event triggers and four membership events - #105

Merged
agreenspan merged 33 commits into
mainfrom
FEAT-021-segment-events
Sep 20, 2026
Merged

agreenspan merged 33 commits into
mainfrom
FEAT-021-segment-events

Conversation

@agreenspan

@agreenspan agreenspan commented Sep 13, 2026 •

Copy link
Copy Markdown
Contributor

FEAT-021 segments, reworked as one reviewable PR (the first cut, #100, was reverted on main in 4404a1e), plus the email-lens redesign the segment work forced.

Segments

  • Every segment is a rule; type says when it runs. Static is computed at create and on rule change, dynamic continuously. A hand-picked audience is a static id in [...] rule. Members are written only by reconcile.
  • Two reconcile rails, one AST. reconcileSegment (set rail, toPrisma over the resolved lens) and reconcileCustomerRefSegments (entity rail, fetchLens + check per segment in dependency order). sweepSegments is the nightly backstop over sound dynamic segments.
  • Inbound triggers are business events handled by jobs, never in-transaction hooks. RECONCILE_TRIGGERS maps every model the segment lens reads to its events and a test asserts coverage; customerRef.created, user.updated, tag.deleted, tagAttachment.created/deleted were added to close the map and have handlers but no emitters yet (no write paths in template).
  • Outbound is four events, two perspectives, emitted from the diff by publishMembershipChanges whichever rail produced it.
  • Degraded, not paused. Rule health is never stored: RuleReference edges (INFRA-030) plus a read-time closure over the owner's graph. Reads carry ruleIssues derived from the row's own included edges; the owner-wide closure runs only when a rule names a live segment.
  • One customer reference lens, tenancy by bind. polymorphicIs(model, axis) (packages/db/src/registries/polymorphicIs.ts) reads any false-polymorphic axis — owner, provider, resource, sender — into one condition with an optional bind per kind; customerRefLens (modules/customerRef/lib) is the provider's view of a customer that segments, reach and health consume; tag scoping sits on the relation, not only on the picker source; live, platformOrBound and boundAndLive (packages/db/src/lens/scopes.ts) back both lenses.

The email lens

Built on the segment work because the old email "rule lens" could not be hydrated and render never applied a lens:

  • Four lenses, one per scope root (sender, recipient, data, system), no synthetic root, no bridges; every path, token, loop and rule dispatches on its root. emailLens({ sender, recipient, data, narrowing }) takes lenses; registry entries declare their data lens (fieldsLens for ad-hoc fields); the narrowing is read from the template row, falling back to the slug's default-tier row.
  • The lens is the row owner's. scopeEmailLens scopes Tag (platform or owned), Segment (owned), Organization and Space (self and, for a Space, its organization) on the model and on the picker source alike. The picker lists every source the lens admits.
  • One admission predicate. admitRuleReferences decides for the save-time edge writer, for preflight and for the segment save gate; degraded segments leave the render live set; recipients reach delivery hydrated through the recipient lens; message targeting compiles through the platform recipient lens.
  • The variables a template renders with are what the lens exposes. prune applies the lens's data narrowing as well as its picks (a hidden list element is dropped, a hidden to-one becomes null, and the root row itself must be admitted: a foreign sender row is null, so its tokens print nothing) and interpolate projects every slot's variables through its lens before any token or rule reads them, so an unfiltered loop cannot print a foreign tag's name from supplied variables; the planner's pruned recipient carries the same boundary into delivery.
  • A loop reads only its lens. A rule under {{#each}} is the array rule the lens judges and evaluates (scopedRule): binding leaves element-relative, root leaves climbing with $$ (json-rules 2.24 scope refs), an element-relative path as $.. A leaf naming another lens's root, the element itself, or an index used as a path is an issue at save, render and reference extraction, never a raw check; a loop iterates only what the narrowing's where admits; a loop over the opaque data bag stays as written.
  • Hooks rely on the polymorphism machinery (immutable axis and keys, rules hook per polymorphic model registered first); no service takes a db parameter; RuleDegradedError/RuleEvaluationError (shared), validateRuleForLens and ruleHealthFromEdges (db) are generic; segment validators live in modules/segment/validations; admin body schemas are picks off the template input schema; makeAppEvent runs its tasks through db.parallel.

Known gaps (tracked in tickets/FEAT-021-segments.md)

The five reconcile events (customerRef.created, user.updated, tag.deleted, tagAttachment.created/deleted) have handlers and tests but no emitters: template has no tag module, tag attachment write path, user update route or customer reference provisioning yet, so these are integration obligations for those features, not broken current behaviour; the rules hook still skips AuditLog until COMM-014 removes the audit hook's dead hard-delete branch.

Verification

Tested at 6a7395f4: bun run check (lint, typecheck, every suite, CI rules), the isolated segmentRoutes.test.ts run, and the adversarial probes on polymorphicIs, scopedRule and admission.

🤖 Generated with Claude Code

agreenspan and others added 2 commits September 13, 2026 19:56
…rship publishes four events

Inbound: the write paths emit segment.created / segment.updated, contact.created / updated / deleted,
organization.updated / deleted, space.updated / deleted, user.redacted and communication.settled; the
app-event handlers enqueue reconcileSegment / reconcileCustomerRefSegments. The after-write DB hook that
resolved rows to customer references inside the transaction is gone, with its lens-derived resolver table.
reconcileCustomerRefSegments takes a customer ref id or a customer model + id and maps to references in the job.

Outbound: publishMembershipChanges emits segment.membersAdded / membersRemoved (owner side) and
customerRef.segmentsAdded / segmentsRemoved (member side) from the diff, whichever rail produced it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uyo2oHpjDz4zjRyqRWH6bP
agreenspan added a commit that referenced this pull request Sep 17, 2026
* chore(rules): rule-reference cleanups from review — registry-owned referenceable set, db-side writer, lodash

- RULE_REFERENCEABLE_MODELS and ruleReferenceNarrowingDefaults live in packages/db next to the
  registry; the referenced-side hook and emailRuleNarrowing both derive from them (the hook no
  longer imports email)
- ruleReferences(lens, rule) moves to packages/db; syncRuleReferenceEdges, lockedLiveReferences
  and RuleReferenceError land in packages/db as on FEAT-021 #105; email keeps the content-shaped
  fronts (contentRuleReferences, contentVocabularyIssues, syncRuleReferences)
- ruleVocabularyIssues lives in @template/shared/rules and withRule uses it; ruleReferenceKey
  folded into shared referenceKey
- buildPreviousById replaced by lodash keyBy in the audit-log, cache and webhook hooks; the
  referenced hook groups flipped rows with groupBy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(sdk): untrack the leftover src/client barrel that references the pre-generated layout

Identical to generated/client/index.ts and imports sibling .gen files that no longer exist outside stale local checkouts, so @template/sdk typecheck fails on a clean clone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(rules): replace derived narrowing defaults with omitForeignKeys and surface-owned id sources

A rule-tracked lens wraps itself in omitForeignKeys (the redactLens shape: every FK column prismaMap knows, omitted on every model) and declares its own id sources. emailRuleNarrowing declares sources: { id: { label: 'name' } } per RULE_REFERENCEABLE_MODELS. ruleReferenceNarrowingDefaults and its prismaMap relation walk are gone.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* chore(email): one rule lens per template — save, settle and the builder share emailLens

emailRuleLens.ts (a second EmailRuleContext map over bare prismaMap) is gone. emailLens wraps in omitForeignKeys, declares id/name sources per referenceable model, and the default recipient lens reaches tagAttachments.tag, spaceUsers.space and organizationUsers.organization. The api's emailTemplateRuleLens builds the template's narrowing; the client gets exposedSurface of it (sources stripped), save and settle keep the narrowing. syncRuleReferences is extract-only: the save's condition gate already refuses undeclared paths when a lens is handed in.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
agreenspan and others added 16 commits September 17, 2026 16:46
… and take the primitives for segments

segmentLensFor wraps in omitForeignKeys with the owner binding declared inside; segment reference extraction is ruleReferences(lens, rule) from packages/db (Segment-only view kept for the graph); the segmentRuleReferences hook keys previous rows with keyBy.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…me, organization and space

- sweepableSegments selects live, continuous, sound dynamic segments and orders them by dependency; a degraded segment never reaches the queue, from the sweep or from a recomputed segment's dependents fan-out
- reach: POST { conditions } -> { count } on /me/segments/reach, /organization/:id/segments/reach, /space/:id/segments/reach; validated exactly as a save would be (lens, empty arms, owned live references), nothing persisted
- assertSegmentReferencesOwned is the owned-and-live reference check the save-gate hook and reach share; invalidSegmentConditions is the one 422 shape
- actionRoute supports a submodel (/:id/segments/reach) through buildRoutePath's action operation
- rule-reference edge grouping uses lodash groupBy

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…tory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erence.stale is emitted per stamped edge (no-op handler)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…ates closes over the owner's edges for reads and both rails

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…erence factory

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…natural key

A User, OrganizationUser or SpaceUser save built its owner scope without the
user id, so the same slug under two users updated one row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…m — not one synthetic root

Supersedes COMM-010 §5. emailLens() returns one lens per scope root (a User
narrowing for the recipient, the sender model's narrowing, a model or declared
data lens or the opaque bag, and the system token lens). Every rule operation
dispatches on the path root: walkEmailLensPath, emailRuleViolations,
emailRuleReferences, applyEmailLens, emailSurface for the builder. Settle now
evaluates every rule and each-filter through the lens (applyEmailLens + check),
and token and path walks read the narrowing rather than the base map, so a
slot that drops a relation drops its tokens too. withRule accepts a vocabulary
provider beside a lens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… owned, through save, render, preflight and the picker

composeTemplate returns the owner tuple of the row that won the cascade;
settle builds the scoped email lens from it, not from the sender. The save
gate admits a newly named reference only when the lens's source query for
that model admits it (sourceQueries → unadmittedRuleReferences), so an
Organization template cannot name another organization's tag or segment.
Segments whose own rule is degraded leave the live set at render. The rule
surface takes the owner and materializes Tag and Segment source values for
the picker; preflight takes the owner, shapes its sample recipient from the
lens and judges references through it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…pient lens

The planner resolves the template row for the sender's scope, builds the
owner-scoped email lens from that row, and fetches recipients through its
recipient lens with the entry's targeting where — the registry entry now
declares only the target, the lens is the shape. prune keeps the columns a
visit's where reads, so the pruned recipient can still be judged at settle.
Message targeting compiles its rule through the platform recipient lens.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…COMMUNICATIONS, INFRA-030 tenancy, FEAT-021 shipped/open

Also: Biome formatting on three test files, the db barrel import order, and
the picker tests judge an unregistered slug instead of the narrowed welcome row.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…lidations/, one reconcile payload shape

The polymorphism rules hook registers per polymorphic model and first, so it gates before the
segment hooks run; the segment conditions hook drops its owner re-derivation (axis and FKs are
immutable, the rules hook requires the key at create), its reference and cycle assertions move to
modules/segment/validations, the member-owner hook states the ownership in one where, and
reconcileCustomerRefSegments takes { customerModel, customerId } only.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…s; one admission predicate

emailLens takes sender, recipient and data as lenses plus the row's narrowing (the projection
kinds are gone; fieldsLens declares ad-hoc data); templateLens/lookupLens read the narrowing from
the row, falling back to the slug's default-tier row; registry entries declare data directly.
admitRuleReferences is the one predicate the edge writer and preflight share, degraded segments
are dropped over references rather than key strings, source values run in parallel, settle lives
in lib/messaging, and the two admin body schemas are picks off the template input schema.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…w's edges, trigger set = reach set

ownedBy(model, axis) in db reads any false-polymorphic axis from the registry into one condition
with an optional bind per owner kind; segmentLens becomes a single constant and scopeEmailLens
uses the same helper. Segment reads include their rule-reference edges (resource context and
list queries) so ruleIssues derive from the row, with the owner closure fetched only when a live
segment is named. The segment edge writer passes the resolved lens's sources. RECONCILE_TRIGGERS
maps every model the lens reads to its events and a test asserts coverage; customerRef.created,
user.updated, tag.deleted and tagAttachment.created/deleted close the graph (no emitters yet).
Membership routes carry the segmentMembership tag.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… picker lists every source

An Organization row references itself and its own spaces, a Space row itself and its
organization, a User row neither, and platform rows everything; the Tag/Segment-only picker
filter goes. The optional-bind trick cannot apply here: the sources key on a required id column,
where Prisma refuses `equals null`, so these scopes are built from the owner directly.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan force-pushed the FEAT-021-segment-events branch from 6b7d4db to c0371cc Compare September 20, 2026 18:30
agreenspan and others added 8 commits September 20, 2026 15:42
…urce

A rule on tag.name matched another provider's tag of the same name because the owner predicate
lived only in the Tag.id source; the lens now scopes Tag wherever it is reached.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…scope

The observe write and the cb handlers ran under one Promise.allSettled in the caller's async
scope. Where a handler's job runs inline (test mode), its db.txn nested into the observe adapter's
open transaction and wrote to it after it committed — the isolated segmentRoutes failure.
db.parallel is the tool for exactly this: one scope per branch.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…2026-09-20, not built)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…e customer reference lens

customerRefLens (modules/customerRef/lib) is the provider's view of a customer — segments,
reach and flags consume it; segments alias nothing, they import it.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
scopedRule turns a rule under {{#each}} bindings into one any per enclosing loop: binding leaves
element-relative, root leaves climbing with $$ (json-rules 2.24 scope refs), field, path, bind,
value and the iterator all attributable. Vocabulary, references and evaluation read that one
form; evaluation runs slot-relative against the iterated collections pinned to the element in
scope (narrowToElements, evaluateScopedRule). A foreign-owner tag in the element list no longer
passes a loop filter. Leaves naming another lens's root from inside a loop stay unjudged (ticket).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…and rule health are generic

No service takes a db parameter — the client resolves the transaction from async-local storage
and the json-rules plan executor is the only place a client is handed over. RuleDegradedError and
RuleEvaluationError live in shared/rules; validateRuleForLens (grammar, vocabulary, open arms,
compile probe) and ruleHealthFromEdges live in db, with the segment validator adding only the
self-reference check. segmentReferences is gone: the before-hook admits references through the
resolved lens's sources, the same predicate as the edge writer and email.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
… the structural name

lensFieldResolver is gone: a hop the narrowing exposes is read off projectByPath, a hop a bare lens
reaches by construction off the base map, in one loop shared by walkLensPath and tokenPathKind.
ownedBy → polymorphicIs: the axis may be an owner, a provider, a resource or a sender; the helper
is about the false-polymorphic shape, not ownership.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
agreenspan and others added 7 commits September 20, 2026 17:32
…issues, paths climb like fields, iterated elements are what the lens admits

Astra's reproduction (a compound filter mixing a binding leaf with a sender leaf evaluated raw
and matched a foreign tag) is closed: scopedRule returns an issue for a leaf on another lens
root, the element itself, or an index used as a path, and save, render and reference extraction
all refuse it; a loop index is a counter and folds to its verdict. An element-relative path is
'$.' (a bare path is the root context in json-rules); a loop iterates only the elements the
narrowing's where admits, so tokens and rules in the body agree; a loop over the opaque data bag
stays as written. Organization and Space are scoped on the model, not only in the picker;
polymorphicIs refuses a kind no single key binds; violations inside a nested condition carry the
array chain.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…idator folder, honest tests

live / platformOrBound / boundAndLive in db back both lenses; polymorphicTarget and
polymorphicKeyColumn replace three hand-rolled key reads; validateRuleForLens sits in
packages/db/src/validations; the rules hook test proves the after-write validation and cleans its
registry seam; segment tests share their fixtures; the picker fallback test cleans its own slug.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…k; an owner's segments are read through the lens

lensPathFields and walkLensPath sit on json-rules' resolveLensPath, the walk the gate uses.
ownedSegments runs the Segment source the resolved customer reference lens declares, so the cycle
check, the health closure and the customer rail no longer spell the owner predicate by hand.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…poses

prune applies the lens's data narrowing as well as its picks: a list element a visit's where
hides is dropped, a to-one it hides becomes null. interpolate projects every slot's variables
through its lens before a token or rule reads them, so an unfiltered loop can no longer print a
foreign tag's name (Astra's reproduction), and the planner's pruned recipient carries the same
boundary into delivery. The loop's separate admission step is gone — one boundary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…es what may be read

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A supplied sender row outside the owner's scope printed its name while the equivalent rule
refused it: child visits were admitted, the root never was. A hidden single row is null, hidden
array rows are dropped; the email render tests pin the sender case.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…cide, through prune and through render

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
@agreenspan
agreenspan force-pushed the FEAT-021-segment-events branch from 33fac6e to 6a7395f Compare September 20, 2026 22:09
@agreenspan
agreenspan merged commit 464674c into main Sep 20, 2026
@agreenspan
agreenspan deleted the FEAT-021-segment-events branch September 20, 2026 22:14
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