From 47bbf43f85d23e8db40ce99dffc475ec12cb95fb Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 6 Apr 2026 14:33:39 -0700 Subject: [PATCH 1/3] docs: add indexer-contract.md for member service (LFXV2-1405) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Documents all data types and messages that lfx-v2-member-service sends to the indexer service. Covers 4 resource types — MembershipTier, ProjectMembership, KeyContact, and B2BOrg — each with data schema, tags, access control (IndexingConfig), search behavior, and parent references. Follows the pattern established by lfx-v2-committee-service in LFXV2-1377. Generated with [Claude Code](https://claude.com/claude-code) Signed-off-by: Andres Tobon --- docs/indexer-contract.md | 286 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 286 insertions(+) create mode 100644 docs/indexer-contract.md diff --git a/docs/indexer-contract.md b/docs/indexer-contract.md new file mode 100644 index 0000000..f31b8bb --- /dev/null +++ b/docs/indexer-contract.md @@ -0,0 +1,286 @@ +# Indexer Contract — Member Service + +This document is the authoritative reference for all data the member service sends to the indexer service, which makes resources searchable via the [query service](https://github.com/linuxfoundation/lfx-v2-query-service). + +**Update this document in the same PR as any change to indexer message construction.** + +> **Note:** The `EventPublisher` interface (`internal/domain/port/event_publisher.go`) exists with placeholder `any` types. Concrete `indexertypes.IndexingConfig` and `fgasync.GenericFGAMessage` types will be wired in a later ticket. The schemas below reflect the intended contract. + +--- + +## Resource Types + +- [Membership Tier](#membership-tier) +- [Project Membership](#project-membership) +- [Key Contact](#key-contact) +- [B2B Org](#b2b-org) + +--- + +## Membership Tier + +**Source struct:** `internal/domain/model/member.go` — `MembershipTier` + +**Indexed on:** create, update, delete of a membership tier (Salesforce `Product2`). + +### Data Schema + +| Field | Type | Description | +|---|---|---| +| `uid` | string | Tier unique identifier (invertible UUID v8 from `Product2.Id`) | +| `project_uid` | string | v2 UUID of the project this tier belongs to | +| `name` | string | Product name (e.g., `"Gold Corporate Membership"`) | +| `family` | string (optional) | Product family (e.g., `"Membership"`); omitted when empty | +| `product_type` | string (optional) | Product type (`Type__c` on `Product2`); omitted when empty | +| `created_at` | timestamp | Creation time (RFC3339) | +| `updated_at` | timestamp | Last update time (RFC3339) | + +> `project_slug` is used internally for resolver round-trips and is **not** included in indexed data. + +### Tags + +| Tag Format | Example | Purpose | +|---|---|---| +| `{uid}` | `a1b2c3d4-...` | Direct lookup by UID | +| `membership_tier_uid:{uid}` | `membership_tier_uid:a1b2c3d4-...` | Namespaced lookup by UID | +| `project_uid:{value}` | `project_uid:cbef1ed5-...` | Find tiers for a project | + +### Access Control (IndexingConfig) + +| Field | Value | +|---|---| +| `access_check_object` | `project:{project_uid}` | +| `access_check_relation` | `auditor` | +| `history_check_object` | `project:{project_uid}` | +| `history_check_relation` | `auditor` | + +### Search Behavior + +| Field | Value | +|---|---| +| `fulltext` | `name`, `family` | +| `name_and_aliases` | `name` | +| `sort_name` | `name` | +| `public` | `false` (access-controlled by project membership) | + +### Parent References + +| Ref | Condition | +|---|---| +| `project:{project_uid}` | Always set | + +--- + +## Project Membership + +**Source struct:** `internal/domain/model/membership.go` — `ProjectMembership` + +**Indexed on:** create, update, delete of a project membership (Salesforce `Asset`). + +### Data Schema + +| Field | Type | Description | +|---|---|---| +| `uid` | string | Membership unique identifier (invertible UUID v8 from `Asset.Id`) | +| `tier_uid` | string | UID of the associated `MembershipTier` (`Product2`) | +| `project_uid` | string | v2 UUID of the project this membership belongs to | +| `project_slug` | string (optional) | URL slug of the associated project; omitted when empty | +| `b2b_org_uid` | string (optional) | UID of the member company (`B2BOrg`, derived from `Account.Id`); omitted when empty | +| `status` | string | Membership status (e.g., `"Active"`, `"Expired"`) | +| `year` | string (optional) | Membership year (e.g., `"2025"`); omitted when empty | +| `tier` | string (optional) | Membership tier label (e.g., `"Gold"`); omitted when empty | +| `auto_renew` | bool | Whether automatic renewal is enabled | +| `renewal_type` | string (optional) | Renewal cadence (e.g., `"Annual"`); omitted when empty | +| `price` | float64 (optional) | Current membership price; omitted when zero | +| `annual_full_price` | float64 (optional) | Full annual list price before discounts; omitted when zero | +| `payment_frequency` | string (optional) | Payment frequency; omitted when empty | +| `payment_terms` | string (optional) | Payment terms (e.g., `"Net 30"`); omitted when empty | +| `agreement_date` | string (optional) | Date agreement was signed; omitted when empty | +| `purchase_date` | string (optional) | Effective purchase date (`COALESCE` of `PurchaseDate`, `InstallDate`, `CreatedDate`); omitted when empty | +| `start_date` | string (optional) | Membership start date (`InstallDate`); omitted when empty | +| `end_date` | string (optional) | Membership end date (`UsageEndDate`); omitted when empty | +| `company_name` | string | Member company name (denormalized from `Account`) | +| `company_logo_url` | string (optional) | Member company logo URL (denormalized from `Account`); omitted when empty | +| `company_domain` | string (optional) | Member company website/domain (denormalized from `Account.Website`); omitted when empty | +| `tier_name` | string (optional) | Product name (denormalized from `Product2`); omitted when empty | +| `tier_family` | string (optional) | Product family (denormalized from `Product2`); omitted when empty | +| `tier_product_type` | string (optional) | Product type (denormalized from `Product2`); omitted when empty | +| `created_at` | timestamp | Creation time (RFC3339) | +| `updated_at` | timestamp | Last update time (RFC3339) | + +> `AccountSFID` is used internally for write-path operations and is **not** included in indexed data. + +### Tags + +| Tag Format | Example | Purpose | +|---|---|---| +| `{uid}` | `d4e5f6a7-...` | Direct lookup by UID | +| `project_membership_uid:{uid}` | `project_membership_uid:d4e5f6a7-...` | Namespaced lookup by UID | +| `project_uid:{value}` | `project_uid:cbef1ed5-...` | Find memberships for a project | +| `tier_uid:{value}` | `tier_uid:a1b2c3d4-...` | Find memberships by tier | +| `b2b_org_uid:{value}` | `b2b_org_uid:e8f9a0b1-...` | Find memberships for a company | +| `status:{value}` | `status:Active` | Find memberships by status | +| `year:{value}` | `year:2025` | Find memberships by year | +| `company_name:{value}` | `company_name:The Linux Foundation` | Find memberships by company name | +| `company_domain:{value}` | `company_domain:linuxfoundation.org` | Find memberships by company domain | + +> Tags for `b2b_org_uid`, `status`, `year`, `company_name`, and `company_domain` are only emitted when the value is non-empty. + +### Access Control (IndexingConfig) + +| Field | Value | +|---|---| +| `access_check_object` | `project:{project_uid}` | +| `access_check_relation` | `auditor` | +| `history_check_object` | `project:{project_uid}` | +| `history_check_relation` | `auditor` | + +### Search Behavior + +| Field | Value | +|---|---| +| `fulltext` | `company_name`, `tier_name`, `tier` | +| `name_and_aliases` | `company_name`, `tier_name` (non-empty values only) | +| `sort_name` | `company_name` | +| `public` | `false` (access-controlled by project membership) | + +### Parent References + +| Ref | Condition | +|---|---| +| `project:{project_uid}` | Always set | +| `membership_tier:{tier_uid}` | Always set | +| `b2b_org:{b2b_org_uid}` | Only when `b2b_org_uid` is non-empty | + +--- + +## Key Contact + +**Source struct:** `internal/domain/model/key_contact.go` — `KeyContact` + +**Indexed on:** create, update, delete of a key contact (Salesforce `Project_Role__c`). + +### Data Schema + +| Field | Type | Description | +|---|---|---| +| `uid` | string | Key contact unique identifier (invertible UUID v8 from `Project_Role__c.Id`) | +| `membership_uid` | string | UID of the associated `ProjectMembership` (`Asset`) | +| `tier_uid` | string | UID of the associated `MembershipTier` (`Product2`) | +| `project_uid` | string | v2 UUID of the project this key contact belongs to | +| `b2b_org_uid` | string (optional) | UID of the member company (`B2BOrg`, derived from `Account.Id`); omitted when empty | +| `role` | string | Contact role designation (e.g., `"Voting Representative"`) | +| `status` | string | Role record status (e.g., `"Active"`) | +| `board_member` | bool | Whether this contact holds a board member role | +| `primary_contact` | bool | Whether this is the primary contact for the membership | +| `first_name` | string | Contact first name (denormalized from `Contact`) | +| `last_name` | string | Contact last name (denormalized from `Contact`) | +| `title` | string (optional) | Job title (denormalized from `Contact`); omitted when empty | +| `email` | string (optional) | Primary email from `Alternate_Email__c` where `Primary_Email__c = true`; omitted when empty | +| `company_name` | string | Member company name (denormalized from `Account` via membership `Asset`) | +| `company_logo_url` | string (optional) | Member company logo URL (denormalized from `Account`); omitted when empty | +| `company_domain` | string (optional) | Member company website/domain (denormalized from `Account.Website`); omitted when empty | +| `created_at` | timestamp | Creation time (RFC3339) | +| `updated_at` | timestamp | Last update time (RFC3339) | + +> `project_slug` is used internally for resolver round-trips and is **not** included in indexed data. + +### Tags + +| Tag Format | Example | Purpose | +|---|---|---| +| `{uid}` | `b2c3d4e5-...` | Direct lookup by UID | +| `key_contact_uid:{uid}` | `key_contact_uid:b2c3d4e5-...` | Namespaced lookup by UID | +| `membership_uid:{value}` | `membership_uid:d4e5f6a7-...` | Find key contacts for a membership | +| `project_uid:{value}` | `project_uid:cbef1ed5-...` | Find key contacts for a project | +| `b2b_org_uid:{value}` | `b2b_org_uid:e8f9a0b1-...` | Find key contacts for a company | +| `role:{value}` | `role:Voting Representative` | Find key contacts by role | +| `status:{value}` | `status:Active` | Find key contacts by status | +| `email:{value}` | `email:user@example.com` | Find key contacts by email | +| `company_name:{value}` | `company_name:The Linux Foundation` | Find key contacts by company name | +| `company_domain:{value}` | `company_domain:linuxfoundation.org` | Find key contacts by company domain | + +> Tags for `b2b_org_uid`, `role`, `status`, `email`, `company_name`, and `company_domain` are only emitted when the value is non-empty. + +### Access Control (IndexingConfig) + +| Field | Value | +|---|---| +| `access_check_object` | `project:{project_uid}` | +| `access_check_relation` | `auditor` | +| `history_check_object` | `project:{project_uid}` | +| `history_check_relation` | `auditor` | + +### Search Behavior + +| Field | Value | +|---|---| +| `fulltext` | `first_name`, `last_name`, `email`, `company_name`, `role` | +| `name_and_aliases` | `first_name`, `last_name`, `email` (non-empty values only) | +| `sort_name` | `last_name` | +| `public` | `false` (access-controlled by project membership) | + +### Parent References + +| Ref | Condition | +|---|---| +| `project:{project_uid}` | Always set | +| `project_membership:{membership_uid}` | Always set | +| `b2b_org:{b2b_org_uid}` | Only when `b2b_org_uid` is non-empty | + +--- + +## B2B Org + +**Source struct:** `internal/domain/model/b2b_org.go` — `B2BOrg` + +**Indexed on:** create, update, delete of a B2B organization (Salesforce `Account`). + +### Data Schema + +| Field | Type | Description | +|---|---|---| +| `uid` | string | Organization unique identifier (invertible UUID v8 from `Account.Id`) | +| `name` | string | Organization display name | +| `website` | string (optional) | Website URL — always includes scheme (`http`/`https`); omitted when empty or unparseable | +| `primary_domain` | string (optional) | Normalized primary domain (bare host, e.g., `"example.com"`); omitted when empty or invalid | +| `domain_aliases` | array[string] (optional) | Additional normalized domains; each item normalized with the same rules as `primary_domain`; invalid items are dropped | +| `logo_url` | string (optional) | Organization logo image URL; omitted when empty | +| `created_at` | timestamp | Creation time (RFC3339) | +| `updated_at` | timestamp | Last update time (RFC3339) | + +> `SFID` (raw Salesforce `Account.Id`) is used internally and is **not** included in indexed data. + +### Tags + +| Tag Format | Example | Purpose | +|---|---|---| +| `{uid}` | `e8f9a0b1-...` | Direct lookup by UID | +| `b2b_org_uid:{uid}` | `b2b_org_uid:e8f9a0b1-...` | Namespaced lookup by UID | +| `primary_domain:{value}` | `primary_domain:linuxfoundation.org` | Find org by primary domain | +| `domain_alias:{value}` | `domain_alias:lf.org` | Find org by an alias domain | +| `name:{value}` | `name:The Linux Foundation` | Find org by name | + +> Tags for `primary_domain`, `domain_alias`, and `name` are only emitted when the value is non-empty. One `domain_alias:{value}` tag is emitted per entry in `domain_aliases`. + +### Access Control (IndexingConfig) + +| Field | Value | +|---|---| +| `access_check_object` | `b2b_org:{uid}` | +| `access_check_relation` | `viewer` | +| `history_check_object` | `b2b_org:{uid}` | +| `history_check_relation` | `auditor` | + +### Search Behavior + +| Field | Value | +|---|---| +| `fulltext` | `name`, `primary_domain`, `website` | +| `name_and_aliases` | `name`, `primary_domain` (non-empty values only) | +| `sort_name` | `name` | +| `public` | `false` (access-controlled) | + +### Parent References + +_(none — B2BOrg is a top-level entity)_ From fa3a41a0c7cdb09d87bcc147db6fb6c7a9366edb Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Mon, 6 Apr 2026 15:05:09 -0700 Subject: [PATCH 2/3] [LFXV2-1405] docs: fix indexer contract review comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Mark tier_uid as optional in ProjectMembership and KeyContact data schemas; it may be empty when the Asset/tier relationship is absent - Update ProjectMembership parent ref: membership_tier:{tier_uid} is only emitted when tier_uid is non-empty - Update company_domain tag examples and notes for ProjectMembership and KeyContact to reflect that Account.Website may be a full URL, not necessarily a bare domain 🤖 Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- docs/indexer-contract.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/docs/indexer-contract.md b/docs/indexer-contract.md index f31b8bb..582dc98 100644 --- a/docs/indexer-contract.md +++ b/docs/indexer-contract.md @@ -82,7 +82,7 @@ This document is the authoritative reference for all data the member service sen | Field | Type | Description | |---|---|---| | `uid` | string | Membership unique identifier (invertible UUID v8 from `Asset.Id`) | -| `tier_uid` | string | UID of the associated `MembershipTier` (`Product2`) | +| `tier_uid` | string (may be empty) | UID of the associated `MembershipTier` (`Product2`); may be empty when the related `Asset` / tier relationship is unavailable or cannot be decoded | | `project_uid` | string | v2 UUID of the project this membership belongs to | | `project_slug` | string (optional) | URL slug of the associated project; omitted when empty | | `b2b_org_uid` | string (optional) | UID of the member company (`B2BOrg`, derived from `Account.Id`); omitted when empty | @@ -122,9 +122,9 @@ This document is the authoritative reference for all data the member service sen | `status:{value}` | `status:Active` | Find memberships by status | | `year:{value}` | `year:2025` | Find memberships by year | | `company_name:{value}` | `company_name:The Linux Foundation` | Find memberships by company name | -| `company_domain:{value}` | `company_domain:linuxfoundation.org` | Find memberships by company domain | +| `company_domain:{value}` | `company_domain:https://linuxfoundation.org/about/` | Find memberships by company website value (may be a bare domain or full URL) | -> Tags for `b2b_org_uid`, `status`, `year`, `company_name`, and `company_domain` are only emitted when the value is non-empty. +> Tags for `b2b_org_uid`, `status`, `year`, `company_name`, and `company_domain` are only emitted when the source value is non-empty. For `company_domain`, the source value comes from the company website field and may be a bare domain or a full URL. ### Access Control (IndexingConfig) @@ -149,7 +149,7 @@ This document is the authoritative reference for all data the member service sen | Ref | Condition | |---|---| | `project:{project_uid}` | Always set | -| `membership_tier:{tier_uid}` | Always set | +| `membership_tier:{tier_uid}` | Only when `tier_uid` is non-empty | | `b2b_org:{b2b_org_uid}` | Only when `b2b_org_uid` is non-empty | --- @@ -166,7 +166,7 @@ This document is the authoritative reference for all data the member service sen |---|---|---| | `uid` | string | Key contact unique identifier (invertible UUID v8 from `Project_Role__c.Id`) | | `membership_uid` | string | UID of the associated `ProjectMembership` (`Asset`) | -| `tier_uid` | string | UID of the associated `MembershipTier` (`Product2`) | +| `tier_uid` | string (may be empty) | UID of the associated `MembershipTier` (`Product2`); may be empty when the related `Asset` / tier relationship is unavailable or cannot be decoded | | `project_uid` | string | v2 UUID of the project this key contact belongs to | | `b2b_org_uid` | string (optional) | UID of the member company (`B2BOrg`, derived from `Account.Id`); omitted when empty | | `role` | string | Contact role designation (e.g., `"Voting Representative"`) | @@ -198,9 +198,9 @@ This document is the authoritative reference for all data the member service sen | `status:{value}` | `status:Active` | Find key contacts by status | | `email:{value}` | `email:user@example.com` | Find key contacts by email | | `company_name:{value}` | `company_name:The Linux Foundation` | Find key contacts by company name | -| `company_domain:{value}` | `company_domain:linuxfoundation.org` | Find key contacts by company domain | +| `company_domain:{value}` | `company_domain:https://linuxfoundation.org` | Find key contacts by company website/domain (`Account.Website` value) | -> Tags for `b2b_org_uid`, `role`, `status`, `email`, `company_name`, and `company_domain` are only emitted when the value is non-empty. +> Tags for `b2b_org_uid`, `role`, `status`, `email`, `company_name`, and `company_domain` are only emitted when the value is non-empty. `company_domain` is sourced from `Account.Website` and may be a full URL. ### Access Control (IndexingConfig) From d4ff2a20be6ae371bf0fb955c8f8792b32cd8175 Mon Sep 17 00:00:00 2001 From: Andres Tobon Date: Wed, 8 Apr 2026 09:22:48 -0700 Subject: [PATCH 3/3] [LFXV2-1405] docs: add object_type and NATS subject to each resource section Generated with [Claude Code](https://claude.ai/code) Signed-off-by: Andres Tobon --- docs/indexer-contract.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/docs/indexer-contract.md b/docs/indexer-contract.md index 582dc98..2f7de88 100644 --- a/docs/indexer-contract.md +++ b/docs/indexer-contract.md @@ -19,6 +19,10 @@ This document is the authoritative reference for all data the member service sen ## Membership Tier +**Object type:** `membership_tier` + +**NATS subject:** `lfx.index.membership_tier` + **Source struct:** `internal/domain/model/member.go` — `MembershipTier` **Indexed on:** create, update, delete of a membership tier (Salesforce `Product2`). @@ -73,6 +77,10 @@ This document is the authoritative reference for all data the member service sen ## Project Membership +**Object type:** `project_membership` + +**NATS subject:** `lfx.index.project_membership` + **Source struct:** `internal/domain/model/membership.go` — `ProjectMembership` **Indexed on:** create, update, delete of a project membership (Salesforce `Asset`). @@ -156,6 +164,10 @@ This document is the authoritative reference for all data the member service sen ## Key Contact +**Object type:** `key_contact` + +**NATS subject:** `lfx.index.key_contact` + **Source struct:** `internal/domain/model/key_contact.go` — `KeyContact` **Indexed on:** create, update, delete of a key contact (Salesforce `Project_Role__c`). @@ -232,6 +244,10 @@ This document is the authoritative reference for all data the member service sen ## B2B Org +**Object type:** `b2b_org` + +**NATS subject:** `lfx.index.b2b_org` + **Source struct:** `internal/domain/model/b2b_org.go` — `B2BOrg` **Indexed on:** create, update, delete of a B2B organization (Salesforce `Account`).