diff --git a/api/openapi.yaml b/api/openapi.yaml index 113063d6e..868de4319 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -1234,6 +1234,24 @@ components: full: type: boolean type: object + Catalog: + additionalProperties: true + properties: + fingerprint: + type: string + targets: + items: + $ref: "#/components/schemas/TargetDescriptor" + type: + - array + - "null" + version: + type: string + required: + - version + - fingerprint + - targets + type: object ChangedMessageJSON: additionalProperties: true properties: @@ -1321,6 +1339,17 @@ components: - server_time - complete_through type: object + ChoiceDescriptor: + additionalProperties: true + properties: + label: + type: string + value: + type: string + required: + - value + - label + type: object CliAccountResponse: additionalProperties: true properties: @@ -1831,6 +1860,15 @@ components: required: - services type: object + ConfidenceInputs: + additionalProperties: true + properties: + reported_score: + format: int64 + type: integer + required: + - reported_score + type: object ContactState: additionalProperties: true properties: @@ -3646,6 +3684,23 @@ components: - action - reason type: object + FieldDescriptor: + additionalProperties: true + properties: + cardinality: + type: string + name: + type: string + required: + type: boolean + value_type: + type: string + required: + - name + - value_type + - cardinality + - required + type: object FileGroupsHTTPRequest: additionalProperties: false properties: @@ -6750,188 +6805,555 @@ components: - days - total_count type: object - PersonFileProvenance: + PersonFactClaim: additionalProperties: true properties: - directions: - items: - enum: - - from_person - - to_person - - group - type: string - type: - - array - - "null" - participant_ids: + claim_key: + type: string + confidence: + $ref: "#/components/schemas/ConfidenceInputs" + created_at: + format: date-time + type: string + evidence_ids: items: format: int64 type: integer type: - array - "null" - roles: - items: - enum: - - from - - to - - cc - - bcc - - conversation_member - type: string + generation_id: + format: int64 + type: integer + id: + format: int64 + type: integer + normalized_value: type: - - array + - string - "null" - required: - - participant_ids - - roles - - directions - type: object - PersonFileSearchHTTPRequest: - additionalProperties: false - properties: - cursor: + origin: type: string - directions: - items: - enum: - - from_person - - to_person - - group - type: string + program_fingerprint: + type: string + program_id: + type: string + program_version: + type: string + relation: + type: string + submitted_value: + type: string + target: + $ref: "#/components/schemas/TargetRef" + valid_from: + format: date-time type: - - array + - string - "null" - filename_query: - type: string - limit: - format: int64 - maximum: 500 - minimum: 0 - type: integer - mime_families: - items: - type: string + valid_until: + format: date-time type: - - array + - string + - "null" + value_fingerprint: + type: + - string - "null" - predicate: - $ref: "#/components/schemas/ExploreHTTPRequest" - sort: - $ref: "#/components/schemas/FileSearchSort" - visual_image_base64: - type: string - visual_query: - type: string required: - - predicate - - sort + - id + - generation_id + - claim_key + - program_id + - program_version + - program_fingerprint + - target + - relation + - submitted_value + - evidence_ids + - origin + - confidence + - created_at type: object - PersonFileSearchHTTPResponse: + PersonFactClaimsResponse: additionalProperties: true properties: - cache_revision: + claims: + items: + $ref: "#/components/schemas/PersonFactClaim" + type: array + required: + - claims + type: object + PersonFactDecision: + additionalProperties: true + properties: + action: type: string - candidate_snapshot_id: + claim_key: type: string - files: - items: - $ref: "#/components/schemas/PersonFileSearchRow" - type: - - array - - "null" - next_cursor: + competing_claim_key: type: string - search_provenance: - $ref: "#/components/schemas/SearchProvenance" - total_count: + created_at: + format: date-time + type: string + decision_key: + type: string + id: + format: int64 + type: integer + projection: + $ref: "#/components/schemas/ProjectionRef" + reason: + type: string + resolution_id: format: int64 type: integer + score: + $ref: "#/components/schemas/PersonFactScoreBreakdown" required: - - files - - total_count - - cache_revision - - search_provenance + - id + - resolution_id + - decision_key + - claim_key + - action + - reason + - score + - created_at type: object - PersonFileSearchRow: + PersonFactDecisionsResponse: additionalProperties: true properties: - containing_title: + decisions: + items: + $ref: "#/components/schemas/PersonFactDecision" + type: array + required: + - decisions + type: object + PersonFactEvidence: + additionalProperties: true + properties: + authority: type: string - content_available: - type: boolean - content_state: - enum: - - metadata_only - - url_only - - missing_blob - - local_content + content_sha256: + type: + - string + - "null" + created_at: + format: date-time type: string - conversation_id: - format: int64 - type: integer - entry_key: + directness: type: string - filename: + event_time: + format: date-time type: string + evidence_key: + type: string + excerpt: + type: + - string + - "null" id: format: int64 type: integer - key: - type: string - message_id: + identity_score: format: int64 type: integer - mime_family: - type: string - mime_type: - type: string - occurred_at: + latest_status: + $ref: "#/components/schemas/PersonFactEvidenceStatusEvent" + recorded_time: format: date-time type: string - participant_domains: - items: - type: string + source_class: + type: string + source_ref: type: - - array + - string - "null" - participant_ids: - items: - format: int64 - type: integer + source_url: type: - - array + - string - "null" - participant_labels: - items: - type: string + source_version: type: - - array + - string - "null" - person_provenance: - $ref: "#/components/schemas/PersonFileProvenance" - search_explain: - $ref: "#/components/schemas/FileSearchExplain" - size_bytes: + span_end: format: int64 - type: integer - source_id: + type: + - integer + - "null" + span_start: format: int64 - type: integer - source_identifier: - type: string - source_type: - type: string - required: - - person_provenance - - id - - key - - entry_key - - message_id - - conversation_id - - occurred_at - - source_id - - source_type + type: + - integer + - "null" + subject_person_id: + format: int64 + type: + - integer + - "null" + subject_ref: + type: + - string + - "null" + supported: + type: boolean + required: + - id + - evidence_key + - source_class + - directness + - authority + - source_ref + - source_url + - subject_ref + - excerpt + - content_sha256 + - source_version + - event_time + - recorded_time + - identity_score + - supported + - created_at + type: object + PersonFactEvidenceResponse: + additionalProperties: true + properties: + evidence: + items: + $ref: "#/components/schemas/PersonFactEvidence" + type: array + required: + - evidence + type: object + PersonFactEvidenceStatusEvent: + additionalProperties: true + properties: + created_at: + format: date-time + type: string + evidence_key: + type: string + generation_id: + format: int64 + type: integer + id: + format: int64 + type: integer + reason: + type: string + source_version: + type: + - string + - "null" + supported: + type: boolean + required: + - id + - generation_id + - evidence_key + - source_version + - supported + - reason + - created_at + type: object + PersonFactEvidenceStatusEventsResponse: + additionalProperties: true + properties: + events: + items: + $ref: "#/components/schemas/PersonFactEvidenceStatusEvent" + type: array + required: + - events + type: object + PersonFactPinWrite: + additionalProperties: true + properties: + projections: + items: + $ref: "#/components/schemas/ProjectionRef" + type: + - array + - "null" + resolutions: + items: + $ref: "#/components/schemas/PersonFactResolutionResult" + type: + - array + - "null" + state: + $ref: "#/components/schemas/PinState" + required: + - state + - resolutions + - projections + type: object + PersonFactPinsResponse: + additionalProperties: true + properties: + pins: + items: + $ref: "#/components/schemas/PinState" + type: + - array + - "null" + required: + - pins + type: object + PersonFactResolutionResult: + additionalProperties: true + properties: + decisions: + items: + $ref: "#/components/schemas/PersonFactDecision" + type: + - array + - "null" + id: + format: int64 + type: integer + input_fingerprint: + type: string + projections: + items: + $ref: "#/components/schemas/ProjectionRef" + type: + - array + - "null" + resolved_at: + format: date-time + type: string + resolver_version: + type: string + target: + $ref: "#/components/schemas/TargetRef" + required: + - id + - target + - resolver_version + - input_fingerprint + - resolved_at + - decisions + - projections + type: object + PersonFactScoreBreakdown: + additionalProperties: true + properties: + authority: + format: int64 + type: integer + confidence: + format: int64 + type: integer + corroboration: + format: int64 + type: integer + directness: + format: int64 + type: integer + freshness: + format: int64 + type: integer + source_class: + format: int64 + type: integer + total: + format: int64 + type: integer + required: + - source_class + - directness + - authority + - confidence + - freshness + - corroboration + - total + type: object + PersonFileProvenance: + additionalProperties: true + properties: + directions: + items: + enum: + - from_person + - to_person + - group + type: string + type: + - array + - "null" + participant_ids: + items: + format: int64 + type: integer + type: + - array + - "null" + roles: + items: + enum: + - from + - to + - cc + - bcc + - conversation_member + type: string + type: + - array + - "null" + required: + - participant_ids + - roles + - directions + type: object + PersonFileSearchHTTPRequest: + additionalProperties: false + properties: + cursor: + type: string + directions: + items: + enum: + - from_person + - to_person + - group + type: string + type: + - array + - "null" + filename_query: + type: string + limit: + format: int64 + maximum: 500 + minimum: 0 + type: integer + mime_families: + items: + type: string + type: + - array + - "null" + predicate: + $ref: "#/components/schemas/ExploreHTTPRequest" + sort: + $ref: "#/components/schemas/FileSearchSort" + visual_image_base64: + type: string + visual_query: + type: string + required: + - predicate + - sort + type: object + PersonFileSearchHTTPResponse: + additionalProperties: true + properties: + cache_revision: + type: string + candidate_snapshot_id: + type: string + files: + items: + $ref: "#/components/schemas/PersonFileSearchRow" + type: + - array + - "null" + next_cursor: + type: string + search_provenance: + $ref: "#/components/schemas/SearchProvenance" + total_count: + format: int64 + type: integer + required: + - files + - total_count + - cache_revision + - search_provenance + type: object + PersonFileSearchRow: + additionalProperties: true + properties: + containing_title: + type: string + content_available: + type: boolean + content_state: + enum: + - metadata_only + - url_only + - missing_blob + - local_content + type: string + conversation_id: + format: int64 + type: integer + entry_key: + type: string + filename: + type: string + id: + format: int64 + type: integer + key: + type: string + message_id: + format: int64 + type: integer + mime_family: + type: string + mime_type: + type: string + occurred_at: + format: date-time + type: string + participant_domains: + items: + type: string + type: + - array + - "null" + participant_ids: + items: + format: int64 + type: integer + type: + - array + - "null" + participant_labels: + items: + type: string + type: + - array + - "null" + person_provenance: + $ref: "#/components/schemas/PersonFileProvenance" + search_explain: + $ref: "#/components/schemas/FileSearchExplain" + size_bytes: + format: int64 + type: integer + source_id: + format: int64 + type: integer + source_identifier: + type: string + source_type: + type: string + required: + - person_provenance + - id + - key + - entry_key + - message_id + - conversation_id + - occurred_at + - source_id + - source_type - source_identifier - containing_title - filename @@ -7929,6 +8351,22 @@ components: - tracked - tracked_at type: object + PinState: + additionalProperties: true + properties: + actor: + type: string + event_id: + format: int64 + type: integer + pinned: + type: boolean + target: + $ref: "#/components/schemas/TargetRef" + required: + - target + - pinned + type: object PingInfo: additionalProperties: true properties: @@ -7957,6 +8395,18 @@ components: - done - total type: object + ProjectionRef: + additionalProperties: true + properties: + kind: + type: string + row_id: + format: int64 + type: integer + required: + - kind + - row_id + type: object Provenance: additionalProperties: true properties: @@ -8964,6 +9414,14 @@ components: required: - value type: object + SetPersonFactPinRequest: + additionalProperties: false + properties: + pinned: + type: boolean + required: + - pinned + type: object Setting: additionalProperties: true properties: @@ -9717,6 +10175,71 @@ components: - cursor_before - cursor_after type: object + TargetDescriptor: + additionalProperties: true + properties: + cardinality: + type: string + choices: + items: + $ref: "#/components/schemas/ChoiceDescriptor" + type: + - array + - "null" + description: + type: string + fields: + items: + $ref: "#/components/schemas/FieldDescriptor" + type: + - array + - "null" + key: + type: string + kind: + type: string + max_length: + format: int64 + type: integer + record_target: + type: string + revision: + type: string + sensitive: + type: boolean + slug: + type: string + universal_id: + type: string + value_type: + type: string + required: + - kind + - key + - revision + - universal_id + - slug + - description + - value_type + - cardinality + - choices + - fields + - sensitive + type: object + TargetRef: + additionalProperties: true + properties: + key: + type: string + kind: + type: string + revision: + type: string + required: + - kind + - key + - revision + type: object TaskIntegrationStatusResponse: additionalProperties: true properties: @@ -10432,7 +10955,7 @@ components: type: apiKey info: title: msgvault API - version: 2.10.0 + version: 2.11.0 openapi: 3.1.0 paths: /api/ping: @@ -16197,9 +16720,168 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/OrganizationsResponse" - description: OK - "400": + $ref: "#/components/schemas/OrganizationsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List organizations + tags: + - API + post: + operationId: createOrganization + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationCreateBody" + required: true + responses: + "201": + content: + application/json: + schema: + $ref: "#/components/schemas/Organization" + description: Created + headers: + ETag: + description: Strong organization revision tag for optimistic concurrency + schema: + type: string + Location: + description: Canonical URL of the created organization + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Create an organization + tags: + - API + /api/v1/organizations/{id}: + delete: + operationId: deleteOrganization + parameters: + - description: Organization ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest organization read + in: header + name: If-Match + required: true + schema: + type: string + responses: + "204": + description: No Content + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Delete an organization without employment records + tags: + - API + get: + operationId: getOrganization + parameters: + - description: Organization ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationProfile" + description: OK + headers: + ETag: + description: Strong organization revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": content: application/json: schema: @@ -16219,39 +16901,67 @@ paths: description: Error security: - apiKey: [] - summary: List organizations + summary: Get an organization tags: - API - post: - operationId: createOrganization + patch: + operationId: patchOrganization + parameters: + - description: Organization ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Strong ETag returned by the latest organization read + in: header + name: If-Match + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/OrganizationCreateBody" + $ref: "#/components/schemas/OrganizationBody" required: true responses: - "201": + "200": content: application/json: schema: $ref: "#/components/schemas/Organization" - description: Created + description: OK headers: ETag: description: Strong organization revision tag for optimistic concurrency schema: type: string - Location: - description: Canonical URL of the created organization - schema: - type: string "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -16266,12 +16976,12 @@ paths: description: Error security: - apiKey: [] - summary: Create an organization + summary: Replace an organization's mutable fields tags: - API - /api/v1/organizations/{id}: - delete: - operationId: deleteOrganization + /api/v1/organizations/{id}/attributes: + get: + operationId: listOrganizationAttributes parameters: - description: Organization ID in: path @@ -16280,34 +16990,30 @@ paths: schema: format: int64 type: integer - - description: Strong ETag returned by the latest organization read - in: header - name: If-Match - required: true + - description: Include superseded values + in: query + name: include_superseded + schema: + type: boolean + - description: Restrict to one definition + in: query + name: definition_slug schema: type: string responses: - "204": - description: No Content - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "404": + "200": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "409": + $ref: "#/components/schemas/OrganizationAttributesResponse" + description: OK + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "428": + "404": content: application/json: schema: @@ -16327,11 +17033,11 @@ paths: description: Error security: - apiKey: [] - summary: Delete an organization without employment records + summary: List organization typed attributes tags: - API - get: - operationId: getOrganization + post: + operationId: setOrganizationAttribute parameters: - description: Organization ID in: path @@ -16340,18 +17046,25 @@ paths: schema: format: int64 type: integer + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetOrganizationAttributeBody" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationProfile" + $ref: "#/components/schemas/OrganizationAttributeWrite" description: OK - headers: - ETag: - description: Strong organization revision tag for optimistic concurrency + "201": + content: + application/json: schema: - type: string + $ref: "#/components/schemas/OrganizationAttributeWrite" + description: Created "400": content: application/json: @@ -16364,6 +17077,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -16378,11 +17097,12 @@ paths: description: Error security: - apiKey: [] - summary: Get an organization + summary: Set an organization typed attribute tags: - API - patch: - operationId: patchOrganization + /api/v1/organizations/{id}/attributes/{slug}: + delete: + operationId: clearOrganizationAttribute parameters: - description: Organization ID in: path @@ -16391,30 +17111,36 @@ paths: schema: format: int64 type: integer - - description: Strong ETag returned by the latest organization read - in: header - name: If-Match + - description: Immutable attribute definition slug + in: path + name: slug required: true schema: type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/OrganizationBody" - required: true + - description: Ordinal for a multi-valued definition + in: query + name: ordinal + schema: + format: int64 + type: integer + - description: "Compare-and-swap: the current value ID expected to be superseded" + in: query + name: expected_value_id + schema: + format: int64 + type: integer + - description: Validate and preview without writing + in: query + name: dry_run + schema: + type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Organization" + $ref: "#/components/schemas/OrganizationAttributeWrite" description: OK - headers: - ETag: - description: Strong organization revision tag for optimistic concurrency - schema: - type: string "400": content: application/json: @@ -16433,12 +17159,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "428": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "503": content: application/json: @@ -16453,12 +17173,12 @@ paths: description: Error security: - apiKey: [] - summary: Replace an organization's mutable fields + summary: Supersede an organization typed attribute tags: - API - /api/v1/organizations/{id}/attributes: + /api/v1/organizations/{id}/employments: get: - operationId: listOrganizationAttributes + operationId: listOrganizationEmployments parameters: - description: Organization ID in: path @@ -16467,22 +17187,29 @@ paths: schema: format: int64 type: integer - - description: Include superseded values + - description: Only current employments in: query - name: include_superseded + name: current_only schema: type: boolean - - description: Restrict to one definition + - description: Maximum results in: query - name: definition_slug + name: limit schema: - type: string + format: int64 + type: integer + - description: Results to skip + in: query + name: offset + schema: + format: int64 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationAttributesResponse" + $ref: "#/components/schemas/EmploymentsResponse" description: OK "400": content: @@ -16510,11 +17237,64 @@ paths: description: Error security: - apiKey: [] - summary: List organization typed attributes + summary: List an organization's employment records + tags: + - API + /api/v1/organizations/{id}/history: + get: + operationId: getOrganizationHistory + parameters: + - description: Organization ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationProfile" + description: OK + headers: + ETag: + description: Strong organization revision tag for optimistic concurrency + schema: + type: string + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Get organization profile history tags: - API + /api/v1/organizations/{id}/merge: post: - operationId: setOrganizationAttribute + operationId: mergeOrganization parameters: - description: Organization ID in: path @@ -16523,25 +17303,30 @@ paths: schema: format: int64 type: integer + - description: Strong ETag returned by the latest organization read + in: header + name: If-Match + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/SetOrganizationAttributeBody" + $ref: "#/components/schemas/MergeOrganizationBody" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationAttributeWrite" + $ref: "#/components/schemas/Organization" description: OK - "201": - content: - application/json: + headers: + ETag: + description: Strong organization revision tag for optimistic concurrency schema: - $ref: "#/components/schemas/OrganizationAttributeWrite" - description: Created + type: string "400": content: application/json: @@ -16560,6 +17345,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -16574,12 +17365,13 @@ paths: description: Error security: - apiKey: [] - summary: Set an organization typed attribute + summary: Merge another organization into this organization tags: - API - /api/v1/organizations/{id}/attributes/{slug}: - delete: - operationId: clearOrganizationAttribute + /api/v1/organizations/{id}/profile: + put: + description: Replaces all structured organization profile collections with at most 200 total values. + operationId: putOrganizationProfile parameters: - description: Organization ID in: path @@ -16588,36 +17380,30 @@ paths: schema: format: int64 type: integer - - description: Immutable attribute definition slug - in: path - name: slug + - description: Strong ETag returned by the latest organization read + in: header + name: If-Match required: true schema: type: string - - description: Ordinal for a multi-valued definition - in: query - name: ordinal - schema: - format: int64 - type: integer - - description: "Compare-and-swap: the current value ID expected to be superseded" - in: query - name: expected_value_id - schema: - format: int64 - type: integer - - description: Validate and preview without writing - in: query - name: dry_run - schema: - type: boolean + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/OrganizationProfileBody" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationAttributeWrite" + $ref: "#/components/schemas/OrganizationProfile" description: OK + headers: + ETag: + description: Strong organization revision tag for optimistic concurrency + schema: + type: string "400": content: application/json: @@ -16636,6 +17422,18 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "413": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -16650,12 +17448,13 @@ paths: description: Error security: - apiKey: [] - summary: Supersede an organization typed attribute + summary: Replace organization profile collections tags: - API - /api/v1/organizations/{id}/employments: + /api/v1/organizations/{id}/profile/media/{media_id}/content: get: - operationId: listOrganizationEmployments + description: Returns the exact inline bytes stored for one media value. URI-only values have no local content and return 404. + operationId: getOrganizationProfileMediaContent parameters: - description: Organization ID in: path @@ -16664,29 +17463,21 @@ paths: schema: format: int64 type: integer - - description: Only current employments - in: query - name: current_only - schema: - type: boolean - - description: Maximum results - in: query - name: limit - schema: - format: int64 - type: integer - - description: Results to skip - in: query - name: offset + - description: Structured organization profile media value ID + in: path + name: media_id + required: true schema: format: int64 type: integer responses: "200": content: - application/json: + "*/*": schema: - $ref: "#/components/schemas/EmploymentsResponse" + contentMediaType: application/octet-stream + format: binary + type: string description: OK "400": content: @@ -16694,12 +17485,24 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -16714,39 +17517,33 @@ paths: description: Error security: - apiKey: [] - summary: List an organization's employment records + summary: Download stored inline content for one organization profile media value tags: - API - /api/v1/organizations/{id}/history: - get: - operationId: getOrganizationHistory - parameters: - - description: Organization ID - in: path - name: id - required: true - schema: - format: int64 - type: integer + /api/v1/participants/completions: + post: + description: Returns a bounded, typed completion set from the committed observed-person index and current curated profile primitives. The private query stays in the JSON body. + operationId: completeParticipants + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/ParticipantCompletionHTTPRequest" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationProfile" + $ref: "#/components/schemas/ParticipantCompletionHTTPResponse" description: OK - headers: - ETag: - description: Strong organization revision tag for optimistic concurrency - schema: - type: string "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "404": + "500": content: application/json: schema: @@ -16766,74 +17563,82 @@ paths: description: Error security: - apiKey: [] - summary: Get organization profile history + summary: Complete observed people by typed contact primitives tags: - API - /api/v1/organizations/{id}/merge: + /api/v1/participants/search: post: - operationId: mergeOrganization - parameters: - - description: Organization ID - in: path - name: id - required: true - schema: - format: int64 - type: integer - - description: Strong ETag returned by the latest organization read - in: header - name: If-Match - required: true - schema: - type: string + operationId: searchParticipants requestBody: content: application/json: schema: - $ref: "#/components/schemas/MergeOrganizationBody" + $ref: "#/components/schemas/IdentitySearchHTTPRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Organization" + $ref: "#/components/schemas/ParticipantSearchHTTPResponse" description: OK - headers: - ETag: - description: Strong organization revision tag for optimistic concurrency - schema: - type: string "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "404": + "409": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": + "503": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "428": + anyOf: + - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" + - $ref: "#/components/schemas/ErrorResponse" + description: Service Unavailable + default: content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + security: + - apiKey: [] + summary: Search observed participant clusters + tags: + - Exploration + /api/v1/participants/{id}: + get: + operationId: getParticipant + parameters: + - description: Observed participant cluster member ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonSummary" + description: OK "503": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" + - $ref: "#/components/schemas/ErrorResponse" + description: Service Unavailable default: content: application/json: @@ -16842,45 +17647,33 @@ paths: description: Error security: - apiKey: [] - summary: Merge another organization into this organization + summary: Get one observed participant cluster tags: - API - /api/v1/organizations/{id}/profile: - put: - description: Replaces all structured organization profile collections with at most 200 total values. - operationId: putOrganizationProfile + /api/v1/participants/{id}/files/search: + post: + operationId: searchParticipantFiles parameters: - - description: Organization ID + - description: Observed participant cluster member ID in: path name: id required: true schema: format: int64 type: integer - - description: Strong ETag returned by the latest organization read - in: header - name: If-Match - required: true - schema: - type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/OrganizationProfileBody" + $ref: "#/components/schemas/PersonFileSearchHTTPRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/OrganizationProfile" + $ref: "#/components/schemas/PersonFileSearchHTTPResponse" description: OK - headers: - ETag: - description: Strong organization revision tag for optimistic concurrency - schema: - type: string "400": content: application/json: @@ -16893,19 +17686,13 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "413": + "409": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "428": + "422": content: application/json: schema: @@ -16915,8 +17702,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" + - $ref: "#/components/schemas/ErrorResponse" + description: Service Unavailable default: content: application/json: @@ -16925,67 +17714,35 @@ paths: description: Error security: - apiKey: [] - summary: Replace organization profile collections + summary: Search one participant cluster's analytical files tags: - - API - /api/v1/organizations/{id}/profile/media/{media_id}/content: + - Exploration + /api/v1/participants/{id}/inboxes: get: - description: Returns the exact inline bytes stored for one media value. URI-only values have no local content and return 404. - operationId: getOrganizationProfileMediaContent + operationId: listParticipantInboxes parameters: - - description: Organization ID + - description: Observed participant cluster member ID in: path name: id required: true schema: format: int64 type: integer - - description: Structured organization profile media value ID - in: path - name: media_id - required: true - schema: - format: int64 - type: integer responses: "200": - content: - "*/*": - schema: - contentMediaType: application/octet-stream - format: binary - type: string - description: OK - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "401": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "404": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "500": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + $ref: "#/components/schemas/PersonInboxResponse" + description: OK "503": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" + - $ref: "#/components/schemas/ErrorResponse" + description: Service Unavailable default: content: application/json: @@ -16994,25 +17751,32 @@ paths: description: Error security: - apiKey: [] - summary: Download stored inline content for one organization profile media value + summary: List one participant cluster's messaging inboxes tags: - API - /api/v1/participants/completions: + /api/v1/participants/{id}/summary: post: - description: Returns a bounded, typed completion set from the committed observed-person index and current curated profile primitives. The private query stays in the JSON body. - operationId: completeParticipants + operationId: getParticipantContextSummary + parameters: + - description: Observed participant cluster member ID + in: path + name: id + required: true + schema: + format: int64 + type: integer requestBody: content: application/json: schema: - $ref: "#/components/schemas/ParticipantCompletionHTTPRequest" + $ref: "#/components/schemas/ExploreHTTPRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ParticipantCompletionHTTPResponse" + $ref: "#/components/schemas/ParticipantContextSummaryHTTPResponse" description: OK "400": content: @@ -17020,7 +17784,7 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": + "409": content: application/json: schema: @@ -17030,8 +17794,10 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + anyOf: + - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" + - $ref: "#/components/schemas/ErrorResponse" + description: Service Unavailable default: content: application/json: @@ -17040,24 +17806,32 @@ paths: description: Error security: - apiKey: [] - summary: Complete observed people by typed contact primitives + summary: Get one participant cluster's contextual analytical summary tags: - - API - /api/v1/participants/search: + - Exploration + /api/v1/participants/{id}/timeline: post: - operationId: searchParticipants + operationId: getParticipantTimeline + parameters: + - description: Observed participant cluster member ID + in: path + name: id + required: true + schema: + format: int64 + type: integer requestBody: content: application/json: schema: - $ref: "#/components/schemas/IdentitySearchHTTPRequest" + $ref: "#/components/schemas/ExploreHTTPRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ParticipantSearchHTTPResponse" + $ref: "#/components/schemas/ExploreHTTPResponse" description: OK "400": content: @@ -17087,35 +17861,26 @@ paths: description: Error security: - apiKey: [] - summary: Search observed participant clusters + summary: Get one participant cluster's canonical activity timeline tags: - Exploration - /api/v1/participants/{id}: + /api/v1/people: get: - operationId: getParticipant - parameters: - - description: Observed participant cluster member ID - in: path - name: id - required: true - schema: - format: int64 - type: integer + description: "Durable people are curated profiles; /api/v1/participants exposes observed analytical groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." + operationId: listPeople responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonSummary" + $ref: "#/components/schemas/PeopleResponse" description: OK "503": content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" - - $ref: "#/components/schemas/ErrorResponse" - description: Service Unavailable + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17124,65 +17889,87 @@ paths: description: Error security: - apiKey: [] - summary: Get one observed participant cluster + summary: List durable person profiles tags: - API - /api/v1/participants/{id}/files/search: post: - operationId: searchParticipantFiles - parameters: - - description: Observed participant cluster member ID - in: path - name: id - required: true - schema: - format: int64 - type: integer + description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). + operationId: createPerson requestBody: content: application/json: schema: - $ref: "#/components/schemas/PersonFileSearchHTTPRequest" + $ref: "#/components/schemas/CreatePersonRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonFileSearchHTTPResponse" + $ref: "#/components/schemas/Person" description: OK - "400": + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "201": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "404": + $ref: "#/components/schemas/Person" + description: Created + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "409": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": + "503": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "422": + default: content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + security: + - apiKey: [] + summary: Promote a participant cluster to a durable person + tags: + - API + /api/v1/people/search: + post: + description: Searches only the curated person vector corpus and returns durable person roots in relevance order. + operationId: searchPeople + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/PersonSearchRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonSearchResponse" + description: OK "503": content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" - - $ref: "#/components/schemas/ErrorResponse" - description: Service Unavailable + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17191,35 +17978,72 @@ paths: description: Error security: - apiKey: [] - summary: Search one participant cluster's analytical files + summary: Search durable people semantically tags: - - Exploration - /api/v1/participants/{id}/inboxes: - get: - operationId: listParticipantInboxes + - API + /api/v1/people/{id}: + delete: + description: "Deletion is permanent: the person's participant bindings are removed and its vCard UID is retired forever. Re-promoting the same cluster afterwards creates a new person with a new UID." + operationId: deletePerson parameters: - - description: Observed participant cluster member ID + - description: Durable person ID in: path name: id required: true schema: format: int64 type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string responses: - "200": + "204": + description: No Content + "400": content: application/json: schema: - $ref: "#/components/schemas/PersonInboxResponse" - description: OK + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" - - $ref: "#/components/schemas/ErrorResponse" - description: Service Unavailable + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17228,40 +18052,32 @@ paths: description: Error security: - apiKey: [] - summary: List one participant cluster's messaging inboxes + summary: Delete a durable person profile tags: - API - /api/v1/participants/{id}/summary: - post: - operationId: getParticipantContextSummary + get: + operationId: getPersonProfile parameters: - - description: Observed participant cluster member ID + - description: Durable person ID in: path name: id required: true schema: format: int64 type: integer - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/ExploreHTTPRequest" - required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ParticipantContextSummaryHTTPResponse" + $ref: "#/components/schemas/Person" description: OK - "400": - content: - application/json: + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "409": + type: string + "404": content: application/json: schema: @@ -17271,10 +18087,8 @@ paths: content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" - - $ref: "#/components/schemas/ErrorResponse" - description: Service Unavailable + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17283,34 +18097,44 @@ paths: description: Error security: - apiKey: [] - summary: Get one participant cluster's contextual analytical summary + summary: Get a durable person profile tags: - - Exploration - /api/v1/participants/{id}/timeline: - post: - operationId: getParticipantTimeline + - API + patch: + operationId: patchPerson parameters: - - description: Observed participant cluster member ID + - description: Durable person ID in: path name: id required: true schema: format: int64 type: integer + - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. + in: header + name: If-Match + required: true + schema: + type: string requestBody: content: application/json: schema: - $ref: "#/components/schemas/ExploreHTTPRequest" + $ref: "#/components/schemas/PatchPersonRequest" required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ExploreHTTPResponse" + $ref: "#/components/schemas/Person" description: OK - "400": + headers: + ETag: + description: Strong person profile revision tag for optimistic concurrency + schema: + type: string + "404": content: application/json: schema: @@ -17322,14 +18146,18 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "428": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: schema: - anyOf: - - $ref: "#/components/schemas/ExploreCacheUnavailableResponse" - - $ref: "#/components/schemas/ErrorResponse" - description: Service Unavailable + $ref: "#/components/schemas/ErrorResponse" + description: Error default: content: application/json: @@ -17338,20 +18166,48 @@ paths: description: Error security: - apiKey: [] - summary: Get one participant cluster's canonical activity timeline + summary: Update a durable person's display name tags: - - Exploration - /api/v1/people: + - API + /api/v1/people/{id}/attributes: get: - description: "Durable people are curated profiles; /api/v1/participants exposes observed analytical groupings. The listing is deliberately unpaginated: persons exist only through explicit promotion, so the set stays small." - operationId: listPeople + operationId: listPersonAttributes + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Include superseded values + in: query + name: history + schema: + type: boolean + - description: Restrict the response to one definition slug + in: query + name: slug + schema: + type: string + - description: Restrict the response to one portable definition identifier + in: query + name: universal_id + schema: + type: string responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PeopleResponse" + $ref: "#/components/schemas/PersonAttributesResponse" description: OK + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -17366,81 +18222,68 @@ paths: description: Error security: - apiKey: [] - summary: List durable person profiles + summary: List a person's typed attributes tags: - API - post: - description: Returns 201 when a new person is created, or 200 when the cluster is already represented by a person (idempotent re-promotion, which also binds any unbound cluster members). - operationId: createPerson - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/CreatePersonRequest" - required: true + /api/v1/people/{id}/attributes/{slug}: + delete: + operationId: clearPersonAttribute + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Immutable attribute definition slug + in: path + name: slug + required: true + schema: + type: string + - description: Ordinal for a multi-valued definition + in: query + name: ordinal + schema: + format: int64 + type: integer + - description: "Compare-and-swap: the current value ID expected to be superseded" + in: query + name: expected_value_id + schema: + format: int64 + type: integer + - description: Validate and preview without writing + in: query + name: dry_run + schema: + type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Person" + $ref: "#/components/schemas/PersonAttributeWrite" description: OK - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency - schema: - type: string - "201": - content: - application/json: - schema: - $ref: "#/components/schemas/Person" - description: Created - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency - schema: - type: string - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "503": + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - default: + "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - security: - - apiKey: [] - summary: Promote a participant cluster to a durable person - tags: - - API - /api/v1/people/search: - post: - description: Searches only the curated person vector corpus and returns durable person roots in relevance order. - operationId: searchPeople - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/PersonSearchRequest" - required: true - responses: - "200": + "409": content: application/json: schema: - $ref: "#/components/schemas/PersonSearchResponse" - description: OK + $ref: "#/components/schemas/PersonAttributeConflictResponse" + description: Conflict "503": content: application/json: @@ -17455,13 +18298,11 @@ paths: description: Error security: - apiKey: [] - summary: Search durable people semantically + summary: Supersede a person's attribute value tags: - API - /api/v1/people/{id}: - delete: - description: "Deletion is permanent: the person's participant bindings are removed and its vCard UID is retired forever. Re-promoting the same cluster afterwards creates a new person with a new UID." - operationId: deletePerson + put: + operationId: setPersonAttribute parameters: - description: Durable person ID in: path @@ -17470,51 +18311,48 @@ paths: schema: format: int64 type: integer - - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. - in: header - name: If-Match + - description: Immutable attribute definition slug + in: path + name: slug required: true schema: type: string - responses: - "204": - description: No Content - "400": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "401": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "404": + - description: Validate and preview without writing + in: query + name: dry_run + schema: + type: boolean + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetPersonAttributeRequest" + required: true + responses: + "200": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "409": + $ref: "#/components/schemas/PersonAttributeWrite" + description: OK + "400": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "428": + "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": + "409": content: application/json: schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error + $ref: "#/components/schemas/PersonAttributeConflictResponse" + description: Conflict "503": content: application/json: @@ -17529,37 +18367,58 @@ paths: description: Error security: - apiKey: [] - summary: Delete a durable person profile + summary: Set a person's attribute value tags: - API + /api/v1/people/{id}/contact-state: get: - operationId: getPersonProfile + operationId: getPersonContactState parameters: - - description: Durable person ID + - description: Positive durable identifier in: path name: id required: true schema: format: int64 + minimum: 1 type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Person" + $ref: "#/components/schemas/ContactState" description: OK - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency + "400": + content: + application/json: schema: - type: string + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -17574,56 +18433,82 @@ paths: description: Error security: - apiKey: [] - summary: Get a durable person profile + summary: Get computed contact state for a person tags: - API - patch: - operationId: patchPerson + /api/v1/people/{id}/days: + get: + operationId: listPersonActivityDays parameters: - - description: Durable person ID + - description: Positive durable identifier in: path name: id required: true schema: format: int64 + minimum: 1 type: integer - - description: Strong ETag returned by the latest person profile read. Must be the exact single tag from that read; the RFC 7232 forms `*` and comma-separated tag lists are not supported. - in: header - name: If-Match - required: true + - description: Inclusive first local calendar date + in: query + name: from schema: + pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ type: string - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/PatchPersonRequest" - required: true + - description: Inclusive last local calendar date + in: query + name: to + schema: + pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ + type: string + - description: Maximum primary rows to return + in: query + name: limit + schema: + default: 100 + format: int64 + maximum: 500 + minimum: 1 + type: integer + - description: Zero-based primary row offset + in: query + name: offset + schema: + default: 0 + format: int64 + minimum: 0 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/Person" + $ref: "#/components/schemas/PersonDaysPage" description: OK - headers: - ETag: - description: Strong person profile revision tag for optimistic concurrency + "400": + content: + application/json: schema: - type: string - "404": + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": + "403": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "428": + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "500": content: application/json: schema: @@ -17643,48 +18528,99 @@ paths: description: Error security: - apiKey: [] - summary: Update a durable person's display name + summary: List calendar days intersecting a person tags: - API - /api/v1/people/{id}/attributes: + /api/v1/people/{id}/days/{date}: get: - operationId: listPersonAttributes + operationId: getPersonActivityDay parameters: - - description: Durable person ID + - description: Positive durable identifier in: path name: id required: true schema: format: int64 + minimum: 1 type: integer - - description: Include superseded values + - description: Exact local calendar date + in: path + name: date + required: true + schema: + pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ + type: string + - description: Maximum primary rows to return in: query - name: history + name: limit schema: - type: boolean - - description: Restrict the response to one definition slug + default: 100 + format: int64 + maximum: 500 + minimum: 1 + type: integer + - description: Zero-based primary row offset in: query - name: slug + name: offset schema: - type: string - - description: Restrict the response to one portable definition identifier + default: 0 + format: int64 + minimum: 0 + type: integer + - description: Maximum authored entries to return independently in: query - name: universal_id + name: entry_limit schema: - type: string + default: 100 + format: int64 + maximum: 500 + minimum: 1 + type: integer + - description: Zero-based authored-entry offset + in: query + name: entry_offset + schema: + default: 0 + format: int64 + minimum: 0 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonAttributesResponse" + $ref: "#/components/schemas/PersonDayPage" description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "401": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "403": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "500": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -17699,49 +18635,43 @@ paths: description: Error security: - apiKey: [] - summary: List a person's typed attributes + summary: Get one person's activity and notes for a day tags: - API - /api/v1/people/{id}/attributes/{slug}: - delete: - operationId: clearPersonAttribute + /api/v1/people/{id}/employments: + get: + operationId: listPersonEmployments parameters: - - description: Durable person ID + - description: Person ID in: path name: id required: true schema: format: int64 type: integer - - description: Immutable attribute definition slug - in: path - name: slug - required: true + - description: Only current employments + in: query + name: current_only schema: - type: string - - description: Ordinal for a multi-valued definition + type: boolean + - description: Maximum results in: query - name: ordinal + name: limit schema: format: int64 type: integer - - description: "Compare-and-swap: the current value ID expected to be superseded" + - description: Results to skip in: query - name: expected_value_id + name: offset schema: format: int64 type: integer - - description: Validate and preview without writing - in: query - name: dry_run - schema: - type: boolean responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonAttributeWrite" + $ref: "#/components/schemas/EmploymentsResponse" description: OK "400": content: @@ -17755,12 +18685,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/PersonAttributeConflictResponse" - description: Conflict "503": content: application/json: @@ -17775,11 +18699,12 @@ paths: description: Error security: - apiKey: [] - summary: Supersede a person's attribute value + summary: List a person's employment history tags: - API - put: - operationId: setPersonAttribute + /api/v1/people/{id}/fact-claims: + get: + operationId: listPersonFactClaims parameters: - description: Durable person ID in: path @@ -17788,29 +18713,29 @@ paths: schema: format: int64 type: integer - - description: Immutable attribute definition slug - in: path - name: slug - required: true - schema: - type: string - - description: Validate and preview without writing + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> + in: query + name: target + schema: + type: string + - description: Maximum rows to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based row offset in: query - name: dry_run + name: offset schema: - type: boolean - requestBody: - content: - application/json: - schema: - $ref: "#/components/schemas/SetPersonAttributeRequest" - required: true + format: int64 + type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonAttributeWrite" + $ref: "#/components/schemas/PersonFactClaimsResponse" description: OK "400": content: @@ -17824,12 +18749,6 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "409": - content: - application/json: - schema: - $ref: "#/components/schemas/PersonAttributeConflictResponse" - description: Conflict "503": content: application/json: @@ -17844,27 +18763,43 @@ paths: description: Error security: - apiKey: [] - summary: Set a person's attribute value + summary: List immutable person fact claims tags: - API - /api/v1/people/{id}/contact-state: + /api/v1/people/{id}/fact-decisions: get: - operationId: getPersonContactState + operationId: listPersonFactDecisions parameters: - - description: Positive durable identifier + - description: Durable person ID in: path name: id required: true schema: format: int64 - minimum: 1 + type: integer + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> + in: query + name: target + schema: + type: string + - description: Maximum rows to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based row offset + in: query + name: offset + schema: + format: int64 type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/ContactState" + $ref: "#/components/schemas/PersonFactDecisionsResponse" description: OK "400": content: @@ -17872,30 +18807,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "401": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "403": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "503": content: application/json: @@ -17910,56 +18827,43 @@ paths: description: Error security: - apiKey: [] - summary: Get computed contact state for a person + summary: List immutable person fact decisions tags: - API - /api/v1/people/{id}/days: + /api/v1/people/{id}/fact-evidence: get: - operationId: listPersonActivityDays + operationId: listPersonFactEvidence parameters: - - description: Positive durable identifier + - description: Durable person ID in: path name: id required: true schema: format: int64 - minimum: 1 type: integer - - description: Inclusive first local calendar date - in: query - name: from - schema: - pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ - type: string - - description: Inclusive last local calendar date + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> in: query - name: to + name: target schema: - pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ type: string - - description: Maximum primary rows to return + - description: Maximum rows to return (default 50, max 200) in: query name: limit schema: - default: 100 format: int64 - maximum: 500 - minimum: 1 type: integer - - description: Zero-based primary row offset + - description: Zero-based row offset in: query name: offset schema: - default: 0 format: int64 - minimum: 0 type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonDaysPage" + $ref: "#/components/schemas/PersonFactEvidenceResponse" description: OK "400": content: @@ -17967,30 +18871,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "401": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error - "403": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": - content: - application/json: - schema: - $ref: "#/components/schemas/ErrorResponse" - description: Error "503": content: application/json: @@ -18005,68 +18891,48 @@ paths: description: Error security: - apiKey: [] - summary: List calendar days intersecting a person + summary: List immutable person fact evidence tags: - API - /api/v1/people/{id}/days/{date}: + /api/v1/people/{id}/fact-evidence-status-events: get: - operationId: getPersonActivityDay + operationId: listPersonFactEvidenceStatusEvents parameters: - - description: Positive durable identifier + - description: Durable person ID in: path name: id required: true schema: format: int64 - minimum: 1 type: integer - - description: Exact local calendar date - in: path - name: date - required: true - schema: - pattern: ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ - type: string - - description: Maximum primary rows to return + - description: Restrict to one immutable evidence key in: query - name: limit + name: evidence_key schema: - default: 100 - format: int64 - maximum: 500 - minimum: 1 - type: integer - - description: Zero-based primary row offset + type: string + - description: Restrict to supported or unsupported events in: query - name: offset + name: supported schema: - default: 0 - format: int64 - minimum: 0 - type: integer - - description: Maximum authored entries to return independently + type: boolean + - description: Maximum events to return (default 50, max 200) in: query - name: entry_limit + name: limit schema: - default: 100 format: int64 - maximum: 500 - minimum: 1 type: integer - - description: Zero-based authored-entry offset + - description: Zero-based event offset in: query - name: entry_offset + name: offset schema: - default: 0 format: int64 - minimum: 0 type: integer responses: "200": content: application/json: schema: - $ref: "#/components/schemas/PersonDayPage" + $ref: "#/components/schemas/PersonFactEvidenceStatusEventsResponse" description: OK "400": content: @@ -18074,25 +18940,54 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "401": + "404": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "403": + "503": content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "404": + default: content: application/json: schema: $ref: "#/components/schemas/ErrorResponse" description: Error - "500": + security: + - apiKey: [] + summary: List person fact evidence status history + tags: + - API + /api/v1/people/{id}/fact-pins: + get: + operationId: listPersonFactPins + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactPinsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": content: application/json: schema: @@ -18112,43 +19007,47 @@ paths: description: Error security: - apiKey: [] - summary: Get one person's activity and notes for a day + summary: List effective person fact pins tags: - API - /api/v1/people/{id}/employments: - get: - operationId: listPersonEmployments + /api/v1/people/{id}/fact-pins/{kind}/{key}: + put: + operationId: setPersonFactPin parameters: - - description: Person ID + - description: Durable person ID in: path name: id required: true schema: format: int64 type: integer - - description: Only current employments - in: query - name: current_only - schema: - type: boolean - - description: Maximum results - in: query - name: limit + - description: Closed person fact target kind + in: path + name: kind + required: true schema: - format: int64 - type: integer - - description: Results to skip - in: query - name: offset + enum: + - attribute + - employment + type: string + - description: Exact person fact target key + in: path + name: key + required: true schema: - format: int64 - type: integer + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetPersonFactPinRequest" + required: true responses: "200": content: application/json: schema: - $ref: "#/components/schemas/EmploymentsResponse" + $ref: "#/components/schemas/PersonFactPinWrite" description: OK "400": content: @@ -18162,6 +19061,12 @@ paths: schema: $ref: "#/components/schemas/ErrorResponse" description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error "503": content: application/json: @@ -18176,7 +19081,7 @@ paths: description: Error security: - apiKey: [] - summary: List a person's employment history + summary: Replace an effective person fact pin tags: - API /api/v1/people/{id}/files/search: @@ -18943,6 +19848,45 @@ paths: summary: Replace a person's tracking state tags: - API + /api/v1/person-fact-targets: + get: + operationId: listPersonFactTargets + parameters: + - description: Include sensitive targets + in: query + name: include_sensitive + schema: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Catalog" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List eligible automatic person fact targets + tags: + - API /api/v1/person-merge-candidates/{candidate_id}/decision: post: operationId: decidePersonMergeCandidate diff --git a/cmd/msgvault/cmd/person.go b/cmd/msgvault/cmd/person.go index 085ae8cdb..ee790ca4b 100644 --- a/cmd/msgvault/cmd/person.go +++ b/cmd/msgvault/cmd/person.go @@ -680,7 +680,8 @@ func init() { personSetDisplayNameCmd, personDeleteCmd, personTrackCmd, personUntrackCmd, personMergeCmd, personSplitCmd, personMergeHistoryCmd, personMergeShowCmd, personMergeCandidateCmd, personNotesCmd, - newPersonFilesCommand(defaultPersonFilesCommandDeps()), personSearchCmd) + newPersonFilesCommand(defaultPersonFilesCommandDeps()), personSearchCmd, + newPersonFactsCommand()) for _, command := range []*cobra.Command{ personPromoteCmd, personGetCmd, personListCmd, personSetDisplayNameCmd, personTrackCmd, personUntrackCmd, diff --git a/cmd/msgvault/cmd/person_facts.go b/cmd/msgvault/cmd/person_facts.go new file mode 100644 index 000000000..24332bbbe --- /dev/null +++ b/cmd/msgvault/cmd/person_facts.go @@ -0,0 +1,499 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + "text/tabwriter" + "time" + + "github.com/spf13/cobra" + "go.kenn.io/msgvault/internal/daemonclient" + "go.kenn.io/msgvault/internal/personfacts" + apiclient "go.kenn.io/msgvault/pkg/client" + "go.kenn.io/msgvault/pkg/client/generated" +) + +type personFactsCLIOptions struct { + jsonOutput bool + includeSensitive bool + target string + limit int64 + offset int64 + evidenceKey string + supported bool +} + +func newPersonFactsCommand() *cobra.Command { + options := &personFactsCLIOptions{} + facts := &cobra.Command{ + Use: "facts", + Short: "Inspect person fact diagnostics and control pins", + } + + catalog := &cobra.Command{ + Use: "catalog", + Short: "List eligible automatic person fact targets", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, _ []string) error { + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + query := &generated.ListPersonFactTargetsQuery{} + if options.includeSensitive { + includeSensitive := true + query.IncludeSensitive = &includeSensitive + } + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactTargetsResp, error) { + return api.ListPersonFactTargetsWithResponse(cmd.Context(), + &generated.ListPersonFactTargetsRequestOptions{Query: query}) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact catalog response was empty") + } + if options.jsonOutput { + return writePersonFactCLIRawJSON(cmd, response.Body) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "KIND\tSLUG\tKEY\tREVISION\tTYPE\tCARDINALITY\tSENSITIVE") + for _, target := range response.JSON200.Targets { + _, _ = fmt.Fprintf(writer, "%s\t%s\t%s\t%s\t%s\t%s\t%t\n", + target.Kind, target.Slug, target.Key, target.Revision, + target.ValueType, target.Cardinality, target.Sensitive) + } + return writer.Flush() + }, + } + catalog.Flags().BoolVar(&options.includeSensitive, "include-sensitive", false, + "Include sensitive targets") + + evidence := &cobra.Command{ + Use: "evidence ", + Short: "List immutable person fact evidence", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, query, err := personFactCLIHistoryQuery(cmd, args[0], options) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactEvidenceResp, error) { + return api.ListPersonFactEvidenceWithResponse(cmd.Context(), + &generated.ListPersonFactEvidenceRequestOptions{ + PathParams: &generated.ListPersonFactEvidencePath{ID: personID}, + Query: &generated.ListPersonFactEvidenceQuery{ + Target: query.target, Limit: query.limit, Offset: query.offset, + }, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact evidence response was empty") + } + if options.jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(response.JSON200.Evidence) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tEVIDENCE KEY\tSOURCE\tSOURCE VERSION\tSUPPORTED\tRECORDED") + for _, item := range response.JSON200.Evidence { + _, _ = fmt.Fprintf(writer, "%d\t%s\t%s\t%s\t%t\t%s\n", + item.ID, item.EvidenceKey, item.SourceClass, pointerString(item.SourceVersion), + item.Supported, item.RecordedTime.Format(time.RFC3339)) + } + return writer.Flush() + }, + } + + status := &cobra.Command{ + Use: "evidence-status ", + Short: "List immutable person fact evidence status events", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, err := positivePersonCLIArg(cmd, args[0], personValue) + if err != nil { + return err + } + page, err := personFactCLIPage(cmd, options) + if err != nil { + return err + } + query := &generated.ListPersonFactEvidenceStatusEventsQuery{ + Limit: page.limit, Offset: page.offset, + } + if evidenceKey := strings.TrimSpace(options.evidenceKey); evidenceKey != "" { + query.EvidenceKey = &evidenceKey + } + if cmd.Flags().Changed("supported") { + supported := options.supported + query.Supported = &supported + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactEvidenceStatusEventsResp, error) { + return api.ListPersonFactEvidenceStatusEventsWithResponse(cmd.Context(), + &generated.ListPersonFactEvidenceStatusEventsRequestOptions{ + PathParams: &generated.ListPersonFactEvidenceStatusEventsPath{ID: personID}, + Query: query, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact evidence status response was empty") + } + if options.jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(response.JSON200.Events) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tGENERATION\tEVIDENCE KEY\tSOURCE VERSION\tSUPPORTED\tREASON\tTIMESTAMP") + for _, item := range response.JSON200.Events { + _, _ = fmt.Fprintf(writer, "%d\t%d\t%s\t%s\t%t\t%s\t%s\n", + item.ID, item.GenerationID, item.EvidenceKey, pointerString(item.SourceVersion), + item.Supported, item.Reason, item.CreatedAt.Format(time.RFC3339)) + } + return writer.Flush() + }, + } + status.Flags().StringVar(&options.evidenceKey, "evidence-key", "", "Restrict to one evidence key") + status.Flags().BoolVar(&options.supported, "supported", false, + "Restrict to supported events; use --supported=false for unsupported events") + + claims := &cobra.Command{ + Use: "claims ", + Short: "List immutable person fact claims", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, query, err := personFactCLIHistoryQuery(cmd, args[0], options) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactClaimsResp, error) { + return api.ListPersonFactClaimsWithResponse(cmd.Context(), + &generated.ListPersonFactClaimsRequestOptions{ + PathParams: &generated.ListPersonFactClaimsPath{ID: personID}, + Query: &generated.ListPersonFactClaimsQuery{ + Target: query.target, Limit: query.limit, Offset: query.offset, + }, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact claims response was empty") + } + if options.jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(response.JSON200.Claims) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tGENERATION\tTARGET\tRELATION\tORIGIN\tCLAIM KEY\tCREATED") + for _, item := range response.JSON200.Claims { + _, _ = fmt.Fprintf(writer, "%d\t%d\t%s\t%s\t%s\t%s\t%s\n", + item.ID, item.GenerationID, personFactCLIStoredTarget(item.Target), item.Relation, + item.Origin, item.ClaimKey, item.CreatedAt.Format(time.RFC3339)) + } + return writer.Flush() + }, + } + + decisions := &cobra.Command{ + Use: "decisions ", + Short: "List immutable person fact decisions", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, query, err := personFactCLIHistoryQuery(cmd, args[0], options) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactDecisionsResp, error) { + return api.ListPersonFactDecisionsWithResponse(cmd.Context(), + &generated.ListPersonFactDecisionsRequestOptions{ + PathParams: &generated.ListPersonFactDecisionsPath{ID: personID}, + Query: &generated.ListPersonFactDecisionsQuery{ + Target: query.target, Limit: query.limit, Offset: query.offset, + }, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact decisions response was empty") + } + if options.jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(response.JSON200.Decisions) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "ID\tRESOLUTION\tCLAIM KEY\tACTION\tREASON\tPROJECTION\tCREATED") + for _, item := range response.JSON200.Decisions { + _, _ = fmt.Fprintf(writer, "%d\t%d\t%s\t%s\t%s\t%s\t%s\n", + item.ID, item.ResolutionID, item.ClaimKey, item.Action, item.Reason, + personFactCLIProjection(item.Projection), item.CreatedAt.Format(time.RFC3339)) + } + return writer.Flush() + }, + } + + pins := &cobra.Command{ + Use: "pins ", + Short: "List effective person fact pins", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + personID, err := positivePersonCLIArg(cmd, args[0], personValue) + if err != nil { + return err + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.ListPersonFactPinsResp, error) { + return api.ListPersonFactPinsWithResponse(cmd.Context(), + &generated.ListPersonFactPinsRequestOptions{ + PathParams: &generated.ListPersonFactPinsPath{ID: personID}, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact pins response was empty") + } + if options.jsonOutput { + return json.NewEncoder(cmd.OutOrStdout()).Encode(response.JSON200.Pins) + } + writer := tabwriter.NewWriter(cmd.OutOrStdout(), 0, 0, 2, ' ', 0) + _, _ = fmt.Fprintln(writer, "TARGET\tPINNED\tACTOR\tEVENT") + for _, item := range response.JSON200.Pins { + target, err := personFactCLITarget(item.Target) + if err != nil { + return err + } + _, _ = fmt.Fprintf(writer, "%s\t%t\t%s\t%s\n", + target, item.Pinned, + personFactCLIOptionalString(item.Actor), personFactCLIOptionalInt64(item.EventID)) + } + return writer.Flush() + }, + } + + pin := newPersonFactPinCommand(options, true) + unpin := newPersonFactPinCommand(options, false) + + for _, command := range []*cobra.Command{catalog, evidence, status, claims, decisions, pins, pin, unpin} { + command.Flags().BoolVar(&options.jsonOutput, flagJSON, false, "Output as JSON") + } + for _, command := range []*cobra.Command{evidence, claims, decisions} { + command.Flags().StringVar(&options.target, "target", "", + "Exact target as kind:key:sha256:<64 lowercase hex characters>") + addPersonFactCLIPageFlags(command, options) + } + addPersonFactCLIPageFlags(status, options) + facts.AddCommand(catalog, evidence, status, claims, decisions, pins, pin, unpin) + return facts +} + +func newPersonFactPinCommand(options *personFactsCLIOptions, pinned bool) *cobra.Command { + action := "pin" + if !pinned { + action = "unpin" + } + return &cobra.Command{ + Use: action + " ", + Short: action + " an automatic person fact target", + Args: cobra.ExactArgs(3), + RunE: func(cmd *cobra.Command, args []string) error { + personID, err := positivePersonCLIArg(cmd, args[0], personValue) + if err != nil { + return err + } + kind := strings.TrimSpace(args[1]) + if kind != string(generated.Attribute) && + kind != string(generated.SetPersonFactPinPathKindEmployment) { + return usageErr(cmd, fmt.Errorf("unknown person fact target kind %q", args[1])) + } + key := strings.TrimSpace(args[2]) + if key == "" { + return usageErr(cmd, errors.New("person fact target key must not be empty")) + } + client, _, err := OpenHTTPStore(cmd.Context()) + if err != nil { + return err + } + defer func() { _ = client.Close() }() + body := generated.SetPersonFactPinBody{Pinned: pinned} + response, err := daemonclient.APIResponse(client, + func(api *apiclient.Client) (*generated.SetPersonFactPinResp, error) { + return api.SetPersonFactPinWithResponse(cmd.Context(), + &generated.SetPersonFactPinRequestOptions{ + PathParams: &generated.SetPersonFactPinPath{ + ID: personID, Kind: generated.SetPersonFactPinPathKind(kind), Key: key, + }, + Body: &body, + }) + }) + if err != nil { + return err + } + if response.JSON200 == nil { + return errors.New("person fact pin response was empty") + } + if options.jsonOutput { + return writePersonFactCLIRawJSON(cmd, response.Body) + } + state := "unpinned" + if response.JSON200.State.Pinned { + state = "pinned" + } + target, err := personFactCLITarget(response.JSON200.State.Target) + if err != nil { + return err + } + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "%s: %s\n", + target, state) + for _, projection := range response.JSON200.Projections { + _, _ = fmt.Fprintf(cmd.OutOrStdout(), "Projection: %s:%d\n", + projection.Kind, projection.RowID) + } + return nil + }, + } +} + +type personFactCLIPageQuery struct { + target *string + limit *int64 + offset *int64 +} + +func personFactCLIHistoryQuery( + cmd *cobra.Command, personIDRaw string, options *personFactsCLIOptions, +) (int64, personFactCLIPageQuery, error) { + personID, err := positivePersonCLIArg(cmd, personIDRaw, personValue) + if err != nil { + return 0, personFactCLIPageQuery{}, err + } + query, err := personFactCLIPage(cmd, options) + if err != nil { + return 0, query, err + } + if options.target != "" { + target, err := personfacts.DecodeTargetRef(options.target) + if err != nil { + return 0, query, usageErr(cmd, fmt.Errorf("--target: %w", err)) + } + encoded, err := personfacts.EncodeTargetRef(target) + if err != nil { + return 0, query, usageErr(cmd, fmt.Errorf("--target: %w", err)) + } + query.target = &encoded + } + return personID, query, nil +} + +func personFactCLIPage( + cmd *cobra.Command, options *personFactsCLIOptions, +) (personFactCLIPageQuery, error) { + query := personFactCLIPageQuery{} + if cmd.Flags().Changed("limit") { + if options.limit < 1 || options.limit > 200 { + return query, usageErr(cmd, errors.New("--limit must be between 1 and 200")) + } + limit := options.limit + query.limit = &limit + } + if cmd.Flags().Changed("offset") { + if options.offset < 0 { + return query, usageErr(cmd, errors.New("--offset must not be negative")) + } + offset := options.offset + query.offset = &offset + } + return query, nil +} + +func addPersonFactCLIPageFlags(command *cobra.Command, options *personFactsCLIOptions) { + command.Flags().Int64Var(&options.limit, "limit", 0, "Maximum rows to return (1-200)") + command.Flags().Int64Var(&options.offset, "offset", 0, "Zero-based row offset") +} + +func personFactCLITarget(target generated.TargetRef) (string, error) { + return personfacts.EncodeTargetRef(personfacts.TargetRef{ + Kind: personfacts.TargetKind(target.Kind), Key: target.Key, Revision: target.Revision, + }) +} + +func personFactCLIStoredTarget(target generated.TargetRef) string { + encoded, err := personFactCLITarget(target) + if err == nil { + return encoded + } + return target.Kind + ":" + target.Key + ":" + target.Revision +} + +func personFactCLIProjection(projection *generated.ProjectionRef) string { + if projection == nil { + return "-" + } + return projection.Kind + ":" + strconv.FormatInt(projection.RowID, 10) +} + +func personFactCLIOptionalString(value *string) string { + if value == nil || *value == "" { + return "-" + } + return *value +} + +func personFactCLIOptionalInt64(value *int64) string { + if value == nil { + return "-" + } + return strconv.FormatInt(*value, 10) +} + +func writePersonFactCLIRawJSON(cmd *cobra.Command, body []byte) error { + if _, err := cmd.OutOrStdout().Write(body); err != nil { + return err + } + if !bytes.HasSuffix(body, []byte("\n")) { + _, err := cmd.OutOrStdout().Write([]byte("\n")) + return err + } + return nil +} diff --git a/cmd/msgvault/cmd/person_facts_test.go b/cmd/msgvault/cmd/person_facts_test.go new file mode 100644 index 000000000..fb2af7aff --- /dev/null +++ b/cmd/msgvault/cmd/person_facts_test.go @@ -0,0 +1,370 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "fmt" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/api" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/testutil" + "go.kenn.io/msgvault/pkg/client/generated" +) + +const personFactCatalogResponse = `{ + "version":"v1","fingerprint":"catalog-fingerprint","targets":[{ + "kind":"attribute","key":"target-key","revision":"revision-1", + "universal_id":"target-key","slug":"primary_channel", + "description":"Preferred communication channel","value_type":"text", + "cardinality":"single","choices":[],"fields":[],"sensitive":false + }]}` + +const personFactTestRevision = "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +const personFactEmploymentTarget = "employment:system:employment:" + personFactTestRevision + +func TestStoreAPIAdapterServesPersonFactRoutes(t *testing.T) { + requirements := require.New(t) + st := testutil.NewTestStore(t) + participantID, err := st.EnsureParticipantByIdentifier( + "email", "person-facts-adapter@example.test", "Person Facts Adapter") + requirements.NoError(err) + person, _, err := st.CreatePersonFromParticipant(participantID) + requirements.NoError(err) + + srv := api.NewServerWithOptions(api.ServerOptions{ + Config: &config.Config{}, Store: &storeAPIAdapter{store: st}, + Logger: slog.New(slog.DiscardHandler), + }) + for _, path := range []string{ + "/api/v1/person-fact-targets", + fmt.Sprintf("/api/v1/people/%d/fact-pins", person.ID), + } { + request := httptest.NewRequest(http.MethodGet, path, nil) + response := httptest.NewRecorder() + srv.Router().ServeHTTP(response, request) + + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + requirements.Equal("no-store", response.Header().Get("Cache-Control")) + } +} + +func TestPersonFactsGeneratedPinsResponseDecodesBadPersonID(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal("/api/v1/people/7/fact-pins", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusBadRequest) + _, err := w.Write([]byte(`{"error":"bad_request","message":"invalid person ID"}`)) + assertions.NoError(err) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + client, _, err := OpenHTTPStore(t.Context()) + requirements.NoError(err) + t.Cleanup(func() { _ = client.Close() }) + generatedClient, err := client.GeneratedClient() + requirements.NoError(err) + response, err := generatedClient.ListPersonFactPinsWithResponse(t.Context(), + &generated.ListPersonFactPinsRequestOptions{ + PathParams: &generated.ListPersonFactPinsPath{ID: 7}, + }) + requirements.Error(err) + requirements.NotNil(response) + requirements.NotNil(response.JSON400) + requirements.Equal("bad_request", response.JSON400.ErrorData) +} + +func TestPersonFactsCatalogUsesGeneratedClientPathAndRendersJSONAndTable(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + var queries []url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal(http.MethodGet, r.Method) + assertions.Equal("/api/v1/person-fact-targets", r.URL.Path) + queries = append(queries, r.URL.Query()) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(personFactCatalogResponse)) + assertions.NoError(err) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + table, err := runPersonFactsCommand(t, "catalog") + requirements.NoError(err) + assertions.Contains(table, "KIND") + assertions.Contains(table, "primary_channel") + + jsonOutput, err := runPersonFactsCommand(t, "catalog", "--include-sensitive", "--json") + requirements.NoError(err) + assertions.JSONEq(personFactCatalogResponse, jsonOutput) + requirements.Len(queries, 2) + assertions.False(queries[0].Has("include_sensitive"), "catalog defaults to non-sensitive") + assertions.Equal("true", queries[1].Get("include_sensitive")) +} + +func TestPersonFactsHistoryCommandsUseExactGeneratedPathsQueriesAndTables(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + requests := make(map[string]url.Values) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal(http.MethodGet, r.Method) + requests[r.URL.Path] = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/api/v1/people/7/fact-evidence": + _, _ = w.Write([]byte(`{"evidence":[{ + "id":9,"evidence_key":"evidence-key","source_class":"public", + "directness":"direct-self","authority":"authoritative", + "source_ref":"message:42","source_url":"https://example.test/evidence", + "subject_person_id":7,"subject_ref":"synthetic-person", + "excerpt":"Synthetic evidence","content_sha256":"digest", + "source_version":"source-v1","event_time":"2026-08-22T10:00:00Z", + "recorded_time":"2026-08-22T11:00:00Z","identity_score":990, + "supported":true,"created_at":"2026-08-22T12:00:00Z" + }]}`)) + case "/api/v1/people/7/fact-claims": + _, _ = w.Write([]byte(`{"claims":[{ + "id":8,"generation_id":6,"claim_key":"claim-key", + "program_id":"fixture-program","program_version":"v1", + "program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target":{"kind":"attribute","key":"target-key","revision":"` + personFactTestRevision + `"}, + "relation":"support","submitted_value":"chat", + "evidence_ids":[9],"origin":"extraction", + "confidence":{"reported_score":900},"created_at":"2026-08-22T12:00:00Z" + }]}`)) + case "/api/v1/people/7/fact-decisions": + _, _ = w.Write([]byte(`{"decisions":[{ + "id":7,"resolution_id":5,"decision_key":"decision-key", + "claim_key":"claim-key","action":"applied","reason":"applied-projection", + "score":{"source_class":1,"directness":2,"authority":3,"confidence":4, + "freshness":5,"corroboration":6,"total":21}, + "projection":{"kind":"person_attribute_value","row_id":11}, + "created_at":"2026-08-22T12:00:00Z" + }]}`)) + case "/api/v1/people/7/fact-pins": + _, _ = w.Write([]byte(`{"pins":[{ + "target":{"kind":"employment","key":"system:employment","revision":"` + personFactTestRevision + `"}, + "pinned":true,"actor":"api","event_id":4 + }]}`)) + default: + w.WriteHeader(http.StatusNotFound) + } + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + for _, test := range []struct { + command string + header string + path string + }{ + {command: "evidence", header: "EVIDENCE KEY", path: "/api/v1/people/7/fact-evidence"}, + {command: "claims", header: "CLAIM KEY", path: "/api/v1/people/7/fact-claims"}, + {command: "decisions", header: "ACTION", path: "/api/v1/people/7/fact-decisions"}, + } { + output, err := runPersonFactsCommand(t, test.command, "7", + "--target", personFactEmploymentTarget, "--limit", "4", "--offset", "2") + requirements.NoError(err) + assertions.Contains(output, test.header) + query := requests[test.path] + assertions.Equal(personFactEmploymentTarget, query.Get("target")) + assertions.Equal("4", query.Get("limit")) + assertions.Equal("2", query.Get("offset")) + } + + pins, err := runPersonFactsCommand(t, "pins", "7") + requirements.NoError(err) + assertions.Contains(pins, "PINNED") + assertions.Contains(pins, "true") + assertions.Contains(pins, personFactEmploymentTarget) + + jsonOutput, err := runPersonFactsCommand(t, "claims", "7", "--json") + requirements.NoError(err) + assertions.JSONEq(`[{ + "id":8,"generation_id":6,"claim_key":"claim-key", + "program_id":"fixture-program","program_version":"v1", + "program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target":{"kind":"attribute","key":"target-key","revision":"`+personFactTestRevision+`"}, + "relation":"support","submitted_value":"chat","evidence_ids":[9], + "origin":"extraction","confidence":{"reported_score":900}, + "created_at":"2026-08-22T12:00:00Z" + }]`, jsonOutput) +} + +func TestPersonFactsClaimsTableRetainsMalformedPersistedTargets(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal(http.MethodGet, r.Method) + assertions.Equal("/api/v1/people/7/fact-claims", r.URL.Path) + w.Header().Set("Content-Type", "application/json") + _, err := w.Write([]byte(`{"claims":[ + { + "id":8,"generation_id":6,"claim_key":"legacy-claim", + "program_id":"fixture-program","program_version":"v1", + "program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target":{"kind":"candidate","key":"legacy:key","revision":"legacy-revision"}, + "relation":"support","submitted_value":"legacy", + "evidence_ids":[],"origin":"extraction", + "confidence":{"reported_score":400},"created_at":"2026-08-22T12:00:00Z" + }, + { + "id":9,"generation_id":6,"claim_key":"valid-claim", + "program_id":"fixture-program","program_version":"v1", + "program_fingerprint":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "target":{"kind":"attribute","key":"target-key","revision":"` + personFactTestRevision + `"}, + "relation":"support","submitted_value":"valid", + "evidence_ids":[],"origin":"extraction", + "confidence":{"reported_score":900},"created_at":"2026-08-22T12:01:00Z" + } + ]}`)) + assertions.NoError(err) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + output, err := runPersonFactsCommand(t, "claims", "7") + requirements.NoError(err) + assertions.Contains(output, "candidate:legacy:key:legacy-revision") + assertions.Contains(output, "legacy-claim") + assertions.Contains(output, "valid-claim") +} + +func TestPersonFactsEvidenceStatusForwardsFalseFilterAndRendersNewestFirst(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + var query url.Values + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal(http.MethodGet, r.Method) + assertions.Equal("/api/v1/people/7/fact-evidence-status-events", r.URL.Path) + query = r.URL.Query() + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"events":[ + {"id":12,"generation_id":4,"evidence_key":"evidence-key","source_version":"source-v1", + "supported":true,"reason":"source-reimported","created_at":"2026-08-22T12:02:00Z"}, + {"id":11,"generation_id":3,"evidence_key":"evidence-key","source_version":"source-v1", + "supported":false,"reason":"source-deleted","created_at":"2026-08-22T12:01:00Z"} + ]}`)) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + output, err := runPersonFactsCommand(t, "evidence-status", "7", + "--evidence-key", "evidence-key", "--supported=false", "--limit", "5", "--offset", "1") + requirements.NoError(err) + assertions.Contains(output, "SUPPORTED") + assertions.Less(outputIndex(output, "source-reimported"), outputIndex(output, "source-deleted"), + "server newest-first order is preserved") + assertions.Equal("evidence-key", query.Get("evidence_key")) + assertions.Equal("false", query.Get("supported")) + assertions.Equal("5", query.Get("limit")) + assertions.Equal("1", query.Get("offset")) +} + +func TestPersonFactsPinAndUnpinSendExactBodyWithoutActorAndRenderProjection(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + var bodies []map[string]any + var paths []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + assertions.Equal(http.MethodPut, r.Method) + paths = append(paths, r.URL.EscapedPath()) + var body map[string]any + assertions.NoError(json.NewDecoder(r.Body).Decode(&body)) + bodies = append(bodies, body) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{ + "state":{"target":{"kind":"attribute","key":"target key","revision":"` + personFactTestRevision + `"}, + "pinned":false,"actor":"api","event_id":4}, + "resolutions":[{"id":5,"target":{"kind":"attribute","key":"target key","revision":"` + personFactTestRevision + `"}, + "resolver_version":"v1","input_fingerprint":"fingerprint", + "resolved_at":"2026-08-22T12:00:00Z","decisions":[], + "projections":[{"kind":"person_attribute_value","row_id":11}]}], + "projections":[{"kind":"person_attribute_value","row_id":11}] + }`)) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + for _, test := range []struct { + command string + pinned bool + }{ + {command: "pin", pinned: true}, + {command: "unpin", pinned: false}, + } { + output, err := runPersonFactsCommand(t, test.command, "7", "attribute", "target key") + requirements.NoError(err) + assertions.Contains(output, "person_attribute_value:11") + } + requirements.Len(bodies, 2) + assertions.Equal(map[string]any{"pinned": true}, bodies[0]) + assertions.Equal(map[string]any{"pinned": false}, bodies[1]) + assertions.Equal([]string{ + "/api/v1/people/7/fact-pins/attribute/target%20key", + "/api/v1/people/7/fact-pins/attribute/target%20key", + }, paths) +} + +func TestPersonFactsCommandsRejectInvalidInputsBeforeNetwork(t *testing.T) { + var requests atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + requests.Add(1) + })) + t.Cleanup(server.Close) + withStoreResolverConfig(t, &config.Config{ + Remote: config.RemoteConfig{URL: server.URL, AllowInsecure: true}, + }) + + for _, args := range [][]string{ + {"evidence", "0"}, + {"claims", "7", "--limit", "201"}, + {"decisions", "7", "--offset", "-1"}, + {"evidence", "7", "--target", "not-a-target"}, + {"evidence", "7", "--target", "employment:system:employment:revision-1"}, + {"pin", "7", "candidate", "target-key"}, + {"unpin", "7", "attribute", " "}, + } { + _, err := runPersonFactsCommand(t, args...) + require.Error(t, err, args) + } + assert.Zero(t, requests.Load()) +} + +func runPersonFactsCommand(t *testing.T, args ...string) (string, error) { + t.Helper() + command := newPersonFactsCommand() + var output bytes.Buffer + command.SetOut(&output) + command.SetErr(&output) + command.SetArgs(args) + err := command.Execute() + return output.String(), err +} + +func outputIndex(output, value string) int { + return strings.Index(output, value) +} diff --git a/cmd/msgvault/cmd/serve.go b/cmd/msgvault/cmd/serve.go index 2afbaf14f..3ec6229a0 100644 --- a/cmd/msgvault/cmd/serve.go +++ b/cmd/msgvault/cmd/serve.go @@ -29,6 +29,7 @@ import ( "go.kenn.io/msgvault/internal/meetingimport" "go.kenn.io/msgvault/internal/microsoft" "go.kenn.io/msgvault/internal/oauth" + "go.kenn.io/msgvault/internal/personfacts" "go.kenn.io/msgvault/internal/query" "go.kenn.io/msgvault/internal/scheduler" "go.kenn.io/msgvault/internal/search" @@ -1125,6 +1126,7 @@ type storeAPIAdapter struct { var _ api.MessageStore = (*storeAPIAdapter)(nil) var _ api.CtxMessageStore = (*storeAPIAdapter)(nil) var _ api.MessageIdentityStore = (*storeAPIAdapter)(nil) +var _ api.PersonFactStore = (*storeAPIAdapter)(nil) var _ api.MeetingImporter = (*storeAPIAdapter)(nil) var _ api.SourceStatusStore = (*storeAPIAdapter)(nil) var _ api.CLIStore = (*storeAPIAdapter)(nil) @@ -2095,6 +2097,49 @@ func (a *storeAPIAdapter) SetPersonTrackingContext( return a.store.SetPersonTrackingContext(ctx, id, tracked) } +func (a *storeAPIAdapter) BuildPersonFactCatalogContext( + ctx context.Context, includeSensitive bool, +) (personfacts.Catalog, error) { + return a.store.BuildPersonFactCatalogContext(ctx, includeSensitive) +} + +func (a *storeAPIAdapter) ListPersonFactEvidenceContext( + ctx context.Context, personID int64, filter personfacts.EvidenceFilter, +) ([]personfacts.Evidence, error) { + return a.store.ListPersonFactEvidenceContext(ctx, personID, filter) +} + +func (a *storeAPIAdapter) ListPersonFactEvidenceStatusEventsContext( + ctx context.Context, personID int64, filter personfacts.EvidenceStatusFilter, +) ([]personfacts.EvidenceStatusEvent, error) { + return a.store.ListPersonFactEvidenceStatusEventsContext(ctx, personID, filter) +} + +func (a *storeAPIAdapter) ListPersonFactClaimsContext( + ctx context.Context, personID int64, filter personfacts.ClaimFilter, +) ([]personfacts.Claim, error) { + return a.store.ListPersonFactClaimsContext(ctx, personID, filter) +} + +func (a *storeAPIAdapter) ListPersonFactDecisionsContext( + ctx context.Context, personID int64, filter personfacts.DecisionFilter, +) ([]personfacts.Decision, error) { + return a.store.ListPersonFactDecisionsContext(ctx, personID, filter) +} + +func (a *storeAPIAdapter) ListPersonFactPinsContext( + ctx context.Context, personID int64, +) ([]personfacts.PinState, error) { + return a.store.ListPersonFactPinsContext(ctx, personID) +} + +func (a *storeAPIAdapter) SetPersonFactPinContext( + ctx context.Context, personID int64, target personfacts.TargetRef, + pinned bool, actor string, +) (*personfacts.PinWrite, error) { + return a.store.SetPersonFactPinContext(ctx, personID, target, pinned, actor) +} + func (a *storeAPIAdapter) ListPersonsContext(ctx context.Context) ([]store.Person, error) { return a.store.ListPersonsContext(ctx) } diff --git a/internal/api/openapi.go b/internal/api/openapi.go index bbcdf2dd5..8674dfdf5 100644 --- a/internal/api/openapi.go +++ b/internal/api/openapi.go @@ -226,13 +226,18 @@ import ( // sync routes. Passwords are request-only and never appear in responses. // 2.9.0 adds reversible person merge/split mutations, merge history, snapshot // inspection, and merge-candidate decisions. Mutations require strong person -// revision tags; -// merge and split also require retry-stable Idempotency-Key headers. +// revision tags; merge and split also require retry-stable Idempotency-Key +// headers. // 2.10.0 adds graph-relative relationship temperatures to participant summaries // and a timezone-aware person/year relationship calendar endpoint. // Additive (minor bump): existing relationship and participant routes retain // their request and response behavior. -const APISchemaVersion = "2.10.0" +// 2.11.0 adds bounded person fact catalog, evidence, evidence-status, claim, +// decision, and pin diagnostics plus direct pin replacement. It adds no +// candidate, review, accept, or reject workflow. +// Additive (minor bump): existing person, participant, relationship, calendar, +// and CardDAV routes are unchanged. +const APISchemaVersion = "2.11.0" // OpenAPIDocument builds the API schema from the same Huma route registration // used by the daemon. It binds no socket and needs no database. diff --git a/internal/api/openapi_test.go b/internal/api/openapi_test.go index eb0b03969..13772a85c 100644 --- a/internal/api/openapi_test.go +++ b/internal/api/openapi_test.go @@ -34,11 +34,90 @@ func TestOpenAPIDocumentUsesAPISchemaVersion(t *testing.T) { assert.NotEmpty(t, doc.Paths, "paths") } +func TestOpenAPISchemaVersionPersonFactsIs2110(t *testing.T) { + assert.Equal(t, "2.11.0", APISchemaVersion) +} + +func TestPersonFactOpenAPIOperationsContainNoReviewMutation(t *testing.T) { + want := map[string]map[string]string{ + "/api/v1/person-fact-targets": { + http.MethodGet: "listPersonFactTargets", + }, + "/api/v1/people/{id}/fact-evidence": { + http.MethodGet: "listPersonFactEvidence", + }, + "/api/v1/people/{id}/fact-evidence-status-events": { + http.MethodGet: "listPersonFactEvidenceStatusEvents", + }, + "/api/v1/people/{id}/fact-claims": { + http.MethodGet: "listPersonFactClaims", + }, + "/api/v1/people/{id}/fact-decisions": { + http.MethodGet: "listPersonFactDecisions", + }, + "/api/v1/people/{id}/fact-pins": { + http.MethodGet: "listPersonFactPins", + }, + "/api/v1/people/{id}/fact-pins/{kind}/{key}": { + http.MethodPut: "setPersonFactPin", + }, + } + got := make(map[string]map[string]string) + for path, item := range OpenAPIDocument().Paths { + if path != "/api/v1/person-fact-targets" && + !strings.HasPrefix(path, "/api/v1/people/{id}/fact-") { + continue + } + operations := make(map[string]string) + for method, operation := range map[string]*huma.Operation{ + http.MethodGet: item.Get, http.MethodPut: item.Put, http.MethodPost: item.Post, + http.MethodDelete: item.Delete, http.MethodOptions: item.Options, + http.MethodHead: item.Head, http.MethodPatch: item.Patch, http.MethodTrace: item.Trace, + } { + if operation != nil { + operations[method] = operation.OperationID + } + } + got[path] = operations + } + assert.Equal(t, want, got) +} + +func TestPersonFactOpenAPIHistoryCollectionsAreNonNullable(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + for _, document := range []*huma.OpenAPI{OpenAPIDocument(), openAPIClientDocument()} { + for schemaName, propertyName := range map[string]string{ + "PersonFactEvidenceResponse": "evidence", + "PersonFactEvidenceStatusEventsResponse": "events", + "PersonFactClaimsResponse": "claims", + "PersonFactDecisionsResponse": "decisions", + } { + schema := document.Components.Schemas.Map()[schemaName] + requirements.NotNil(schema, schemaName) + property := schema.Properties[propertyName] + requirements.NotNil(property, schemaName+"."+propertyName) + assertions.False(property.Nullable, schemaName+"."+propertyName) + } + } +} + +func TestPersonFactPinsOpenAPIDeclaresBadPersonIDResponse(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + for _, document := range []*huma.OpenAPI{OpenAPIDocument(), openAPIClientDocument()} { + path := document.Paths["/api/v1/people/{id}/fact-pins"] + requirements.NotNil(path) + requirements.NotNil(path.Get) + assertions.Contains(path.Get.Responses, "400") + } +} + func TestOpenAPISeparatesParticipantAnalyticsFromDurablePeople(t *testing.T) { assert := assert.New(t) doc := OpenAPIDocument() - assert.Equal("2.10.0", APISchemaVersion) + assert.Equal("2.11.0", APISchemaVersion) for _, path := range []string{ "/api/v1/participants/search", "/api/v1/participants/{id}", @@ -60,11 +139,11 @@ func TestOpenAPISeparatesParticipantAnalyticsFromDurablePeople(t *testing.T) { } func TestAnalyticsCacheReadinessUsesAdditiveSchemaVersion(t *testing.T) { - assert.Equal(t, "2.10.0", APISchemaVersion) + assert.Equal(t, "2.11.0", APISchemaVersion) } func TestPersonFilesUseAdditiveSchemaVersion(t *testing.T) { - assert.Equal(t, "2.10.0", APISchemaVersion) + assert.Equal(t, "2.11.0", APISchemaVersion) } func TestPersonFileRoutesPublishTypedPathIDs(t *testing.T) { @@ -88,7 +167,7 @@ func TestPersonFileRoutesPublishTypedPathIDs(t *testing.T) { func TestOrganizationCreateOpenAPIDocumentsLocationHeader(t *testing.T) { require := require.New(t) - assert.Equal(t, "2.10.0", APISchemaVersion, + assert.Equal(t, "2.11.0", APISchemaVersion, "document and person-file search preserve the organization and employment contract") for _, document := range []*huma.OpenAPI{ OpenAPIDocument(), @@ -399,7 +478,7 @@ func TestOpenAPIFastSearchDocumentsSourceIDs(t *testing.T) { func TestOpenAPIPersonAttributeContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("2.10.0", APISchemaVersion, + assert.Equal("2.11.0", APISchemaVersion, "activity, identity match review, document search, and person files preserve the structured profile contract") doc := OpenAPIDocument() @@ -513,7 +592,7 @@ func TestOpenAPIPersonProfilePatchUsesWritableEnvelopeShape(t *testing.T) { func TestOpenAPIOrganizationProfilePutDocumentsLimits(t *testing.T) { assertions := assert.New(t) requirements := require.New(t) - assertions.Equal("2.10.0", APISchemaVersion, + assertions.Equal("2.11.0", APISchemaVersion, "organization profile write limits advance the published contract") doc := OpenAPIDocument() path := doc.Paths["/api/v1/organizations/{id}/profile"] @@ -533,7 +612,7 @@ func TestOpenAPIPersonProfileMediaContentContract(t *testing.T) { require := require.New(t) assert := assert.New(t) - assert.Equal("2.10.0", APISchemaVersion, + assert.Equal("2.11.0", APISchemaVersion, "activity, identity match review, document search, and person files preserve the raw profile media contract") doc := OpenAPIDocument() path := doc.Paths["/api/v1/people/{id}/profile/media/{media_id}/content"] @@ -561,7 +640,7 @@ func TestOpenAPIIdentityMatchReviewContract(t *testing.T) { requirements := require.New(t) assertions := assert.New(t) - assertions.Equal("2.10.0", APISchemaVersion, + assertions.Equal("2.11.0", APISchemaVersion, "document and person-file search preserve the identity match review contract") doc := OpenAPIDocument() @@ -607,8 +686,8 @@ func TestOpenAPIMeetingImportContract(t *testing.T) { // 2.0.0, tracking added in 2.1.0, and participant-scoped files added in // 2.5.0. Person search in 2.6.0, structured filters in 2.7.0, CardDAV routes // in 2.8.0, person merge/split operations in 2.9.0, and relationship - // calendars in 2.10.0 did not touch it. - assert.Equal("2.10.0", APISchemaVersion, "meeting import is an additive schema release") + // calendars in 2.10.0 and person fact diagnostics in 2.11.0 did not touch it. + assert.Equal("2.11.0", APISchemaVersion, "meeting import is an additive schema release") doc := OpenAPIDocument() path := doc.Paths["/api/v1/import/meeting"] diff --git a/internal/api/person_facts.go b/internal/api/person_facts.go new file mode 100644 index 000000000..3ae986d67 --- /dev/null +++ b/internal/api/person_facts.go @@ -0,0 +1,656 @@ +package api + +import ( + "bytes" + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/danielgtaylor/huma/v2" + "go.kenn.io/msgvault/internal/personfacts" + "go.kenn.io/msgvault/internal/store" +) + +// PersonFactStore is the bounded diagnostic and direct-pin capability used by +// the person fact API. It deliberately exposes no review workflow. +type PersonFactStore interface { + BuildPersonFactCatalogContext(ctx context.Context, includeSensitive bool) (personfacts.Catalog, error) + GetPersonContext(ctx context.Context, personID int64) (*store.Person, error) + ListPersonFactEvidenceContext( + ctx context.Context, personID int64, filter personfacts.EvidenceFilter, + ) ([]personfacts.Evidence, error) + ListPersonFactEvidenceStatusEventsContext( + ctx context.Context, personID int64, filter personfacts.EvidenceStatusFilter, + ) ([]personfacts.EvidenceStatusEvent, error) + ListPersonFactClaimsContext( + ctx context.Context, personID int64, filter personfacts.ClaimFilter, + ) ([]personfacts.Claim, error) + ListPersonFactDecisionsContext( + ctx context.Context, personID int64, filter personfacts.DecisionFilter, + ) ([]personfacts.Decision, error) + ListPersonFactPinsContext(ctx context.Context, personID int64) ([]personfacts.PinState, error) + SetPersonFactPinContext( + ctx context.Context, personID int64, target personfacts.TargetRef, pinned bool, actor string, + ) (*personfacts.PinWrite, error) +} + +var _ PersonFactStore = (*store.Store)(nil) + +const personFactPathParameterLocation = "path" + +type PersonFactEvidenceStatusEvent struct { + ID int64 `json:"id"` + GenerationID int64 `json:"generation_id"` + EvidenceKey string `json:"evidence_key"` + SourceVersion string `json:"source_version" nullable:"true"` + Supported bool `json:"supported"` + Reason personfacts.EvidenceStatusReason `json:"reason"` + CreatedAt time.Time `json:"created_at"` +} + +type PersonFactEvidence struct { + ID int64 `json:"id"` + EvidenceKey string `json:"evidence_key"` + SourceClass personfacts.EvidenceSourceClass `json:"source_class"` + Directness personfacts.EvidenceDirectness `json:"directness"` + Authority personfacts.EvidenceAuthority `json:"authority"` + SourceRef string `json:"source_ref" nullable:"true"` + SourceURL string `json:"source_url" nullable:"true"` + SubjectPersonID *int64 `json:"subject_person_id,omitempty" nullable:"true"` + SubjectRef string `json:"subject_ref" nullable:"true"` + SpanStart *int64 `json:"span_start,omitempty" nullable:"true"` + SpanEnd *int64 `json:"span_end,omitempty" nullable:"true"` + Excerpt string `json:"excerpt" nullable:"true"` + ContentSHA256 string `json:"content_sha256" nullable:"true"` + SourceVersion string `json:"source_version" nullable:"true"` + EventTime time.Time `json:"event_time"` + RecordedTime time.Time `json:"recorded_time"` + IdentityScore int `json:"identity_score"` + Supported bool `json:"supported"` + LatestStatus *PersonFactEvidenceStatusEvent `json:"latest_status,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type PersonFactClaim struct { + ID int64 `json:"id"` + GenerationID int64 `json:"generation_id"` + ClaimKey string `json:"claim_key"` + ProgramID string `json:"program_id"` + ProgramVersion string `json:"program_version"` + ProgramFingerprint string `json:"program_fingerprint"` + Target personfacts.TargetRef `json:"target"` + Relation personfacts.ClaimRelation `json:"relation"` + SubmittedValue string `json:"submitted_value"` + NormalizedValue *string `json:"normalized_value,omitempty" nullable:"true"` + ValueFingerprint *string `json:"value_fingerprint,omitempty" nullable:"true"` + EvidenceIDs []int64 `json:"evidence_ids"` + ValidFrom *time.Time `json:"valid_from,omitempty" nullable:"true"` + ValidUntil *time.Time `json:"valid_until,omitempty" nullable:"true"` + Origin personfacts.ClaimOrigin `json:"origin"` + Confidence personfacts.ConfidenceInputs `json:"confidence"` + CreatedAt time.Time `json:"created_at"` +} + +type PersonFactDecision struct { + ID int64 `json:"id"` + ResolutionID int64 `json:"resolution_id"` + DecisionKey string `json:"decision_key"` + ClaimKey string `json:"claim_key"` + Action personfacts.DecisionAction `json:"action"` + Reason personfacts.DecisionReason `json:"reason"` + Score PersonFactScoreBreakdown `json:"score"` + CompetingClaimKey string `json:"competing_claim_key,omitempty"` + Projection *personfacts.ProjectionRef `json:"projection,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type PersonFactScoreBreakdown struct { + SourceClass int `json:"source_class"` + Directness int `json:"directness"` + Authority int `json:"authority"` + Confidence int `json:"confidence"` + Freshness int `json:"freshness"` + Corroboration int `json:"corroboration"` + Total int `json:"total"` +} + +type PersonFactResolutionResult struct { + ID int64 `json:"id"` + Target personfacts.TargetRef `json:"target"` + ResolverVersion string `json:"resolver_version"` + InputFingerprint string `json:"input_fingerprint"` + ResolvedAt time.Time `json:"resolved_at"` + Decisions []PersonFactDecision `json:"decisions"` + Projections []personfacts.ProjectionRef `json:"projections"` +} + +type PersonFactPinWrite struct { + State personfacts.PinState `json:"state"` + Resolutions []PersonFactResolutionResult `json:"resolutions"` + Projections []personfacts.ProjectionRef `json:"projections"` +} + +type PersonFactEvidenceResponse struct { + Evidence []PersonFactEvidence `json:"evidence" nullable:"false"` +} + +type PersonFactEvidenceStatusEventsResponse struct { + Events []PersonFactEvidenceStatusEvent `json:"events" nullable:"false"` +} + +type PersonFactClaimsResponse struct { + Claims []PersonFactClaim `json:"claims" nullable:"false"` +} + +type PersonFactDecisionsResponse struct { + Decisions []PersonFactDecision `json:"decisions" nullable:"false"` +} + +type PersonFactPinsResponse struct { + Pins []personfacts.PinState `json:"pins"` +} + +type SetPersonFactPinRequest struct { + Pinned bool `json:"pinned"` +} + +func (s *Server) registerPersonFactRoutes(api huma.API) { + catalog := rawAPIV1Operation("listPersonFactTargets", http.MethodGet, + "/person-fact-targets", "List eligible automatic person fact targets") + catalog.Parameters = append(catalog.Parameters, + queryBooleanParam("include_sensitive", "Include sensitive targets")) + catalog.Responses = jsonResponsesFor[personfacts.Catalog](api) + addErrorResponses(api, catalog.Responses, http.StatusBadRequest, http.StatusServiceUnavailable) + registerRawHumaRoute(api, catalog, s.handleListPersonFactTargets) + + evidence := personFactHistoryOperation("listPersonFactEvidence", "/people/{id}/fact-evidence", + "List immutable person fact evidence") + evidence.Responses = jsonResponsesFor[PersonFactEvidenceResponse](api) + addErrorResponses(api, evidence.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, evidence, s.handleListPersonFactEvidence) + + status := rawAPIV1Operation("listPersonFactEvidenceStatusEvents", http.MethodGet, + "/people/{id}/fact-evidence-status-events", "List person fact evidence status history") + addPersonIDParameter(&status) + status.Parameters = append(status.Parameters, + queryStringParam("evidence_key", "Restrict to one immutable evidence key", false), + queryBooleanParam("supported", "Restrict to supported or unsupported events"), + queryIntegerParam(limitParam, "Maximum events to return (default 50, max 200)"), + queryIntegerParam("offset", "Zero-based event offset")) + status.Responses = jsonResponsesFor[PersonFactEvidenceStatusEventsResponse](api) + addErrorResponses(api, status.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, status, s.handleListPersonFactEvidenceStatusEvents) + + claims := personFactHistoryOperation("listPersonFactClaims", "/people/{id}/fact-claims", + "List immutable person fact claims") + claims.Responses = jsonResponsesFor[PersonFactClaimsResponse](api) + addErrorResponses(api, claims.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, claims, s.handleListPersonFactClaims) + + decisions := personFactHistoryOperation("listPersonFactDecisions", "/people/{id}/fact-decisions", + "List immutable person fact decisions") + decisions.Responses = jsonResponsesFor[PersonFactDecisionsResponse](api) + addErrorResponses(api, decisions.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, decisions, s.handleListPersonFactDecisions) + + pins := rawAPIV1Operation("listPersonFactPins", http.MethodGet, + "/people/{id}/fact-pins", "List effective person fact pins") + addPersonIDParameter(&pins) + pins.Responses = jsonResponsesFor[PersonFactPinsResponse](api) + addErrorResponses(api, pins.Responses, http.StatusBadRequest, http.StatusNotFound, + http.StatusServiceUnavailable) + registerRawHumaRoute(api, pins, s.handleListPersonFactPins) + + setPin := rawAPIV1Operation("setPersonFactPin", http.MethodPut, + "/people/{id}/fact-pins/{kind}/{key}", "Replace an effective person fact pin") + addPersonIDParameter(&setPin) + setPin.Parameters = append(setPin.Parameters, + &huma.Param{Name: "kind", In: personFactPathParameterLocation, Required: true, + Description: "Closed person fact target kind", + Schema: &huma.Schema{Type: huma.TypeString, Enum: []any{ + string(personfacts.TargetAttribute), string(personfacts.TargetEmployment), + }}}, + &huma.Param{Name: "key", In: personFactPathParameterLocation, Required: true, + Description: "Exact person fact target key", + Schema: &huma.Schema{Type: huma.TypeString}}, + ) + setPin.RequestBody = jsonRequestBodyFor[SetPersonFactPinRequest](api) + setPin.Responses = jsonResponsesFor[PersonFactPinWrite](api) + addErrorResponses(api, setPin.Responses, http.StatusBadRequest, http.StatusConflict, + http.StatusNotFound, http.StatusServiceUnavailable) + registerRawHumaRoute(api, setPin, s.handleSetPersonFactPin) +} + +func personFactHistoryOperation(operationID, path, summary string) huma.Operation { + operation := rawAPIV1Operation(operationID, http.MethodGet, path, summary) + addPersonIDParameter(&operation) + operation.Parameters = append(operation.Parameters, + queryStringParam("target", "Exact target as kind:key:sha256:<64 lowercase hex characters>", false), + queryIntegerParam(limitParam, "Maximum rows to return (default 50, max 200)"), + queryIntegerParam("offset", "Zero-based row offset")) + return operation +} + +func (s *Server) handleListPersonFactTargets(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, ok := s.personFactStore(w) + if !ok { + return + } + includeSensitive, _, err := queryBool(r, "include_sensitive") + if err != nil { + s.rejectBadParam(w, err) + return + } + catalog, err := facts.BuildPersonFactCatalogContext(r.Context(), includeSensitive) + if err != nil { + s.writePersonFactError(w, err) + return + } + if catalog.Targets == nil { + catalog.Targets = []personfacts.TargetDescriptor{} + } + writeJSON(w, http.StatusOK, catalog) +} + +func (s *Server) handleListPersonFactEvidence(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + filter, ok := personFactTargetPageFilter(w, r) + if !ok { + return + } + rows, err := facts.ListPersonFactEvidenceContext(r.Context(), personID, + personfacts.EvidenceFilter{Target: filter.target, Limit: filter.limit, Offset: filter.offset}) + if err != nil { + s.writePersonFactError(w, err) + return + } + response := PersonFactEvidenceResponse{Evidence: make([]PersonFactEvidence, len(rows))} + for index := range rows { + response.Evidence[index] = personFactEvidenceDTO(rows[index]) + } + writePersonFactDiagnostic(w, response) +} +func (s *Server) handleListPersonFactEvidenceStatusEvents(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + page, ok := personFactPageFilter(w, r) + if !ok { + return + } + supported, present, err := queryBool(r, "supported") + if err != nil { + s.rejectBadParam(w, err) + return + } + var supportedFilter *bool + if present { + supportedFilter = &supported + } + rows, err := facts.ListPersonFactEvidenceStatusEventsContext(r.Context(), personID, + personfacts.EvidenceStatusFilter{ + EvidenceKey: strings.TrimSpace(r.URL.Query().Get("evidence_key")), + Supported: supportedFilter, Limit: page.limit, Offset: page.offset, + }) + if err != nil { + s.writePersonFactError(w, err) + return + } + response := PersonFactEvidenceStatusEventsResponse{ + Events: make([]PersonFactEvidenceStatusEvent, len(rows)), + } + for index := range rows { + response.Events[index] = personFactEvidenceStatusEventDTO(rows[index]) + } + writePersonFactDiagnostic(w, response) +} + +func (s *Server) handleListPersonFactClaims(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + filter, ok := personFactTargetPageFilter(w, r) + if !ok { + return + } + rows, err := facts.ListPersonFactClaimsContext(r.Context(), personID, + personfacts.ClaimFilter{Target: filter.target, Limit: filter.limit, Offset: filter.offset}) + if err != nil { + s.writePersonFactError(w, err) + return + } + response := PersonFactClaimsResponse{Claims: make([]PersonFactClaim, len(rows))} + for index := range rows { + response.Claims[index] = personFactClaimDTO(rows[index]) + } + writePersonFactDiagnostic(w, response) +} + +func (s *Server) handleListPersonFactDecisions(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + filter, ok := personFactTargetPageFilter(w, r) + if !ok { + return + } + rows, err := facts.ListPersonFactDecisionsContext(r.Context(), personID, + personfacts.DecisionFilter{Target: filter.target, Limit: filter.limit, Offset: filter.offset}) + if err != nil { + s.writePersonFactError(w, err) + return + } + response := PersonFactDecisionsResponse{Decisions: make([]PersonFactDecision, len(rows))} + for index := range rows { + response.Decisions[index] = personFactDecisionDTO(rows[index]) + } + writePersonFactDiagnostic(w, response) +} + +func (s *Server) handleListPersonFactPins(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + rows, err := facts.ListPersonFactPinsContext(r.Context(), personID) + if err != nil { + s.writePersonFactError(w, err) + return + } + if rows == nil { + rows = []personfacts.PinState{} + } + writePersonFactDiagnostic(w, PersonFactPinsResponse{Pins: rows}) +} + +func (s *Server) handleSetPersonFactPin(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + facts, personID, ok := s.personFactRequestStore(w, r) + if !ok { + return + } + kind, err := parsePersonFactTargetKind(r.PathValue("kind")) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_target_kind", err.Error()) + return + } + // net/http has already unescaped each wildcard path value exactly once. + // Calling url.PathUnescape here would incorrectly decode literal %xx data. + key := strings.TrimSpace(r.PathValue("key")) + if key == "" { + writeError(w, http.StatusBadRequest, "invalid_target_key", "Person fact target key must not be empty") + return + } + var request SetPersonFactPinRequest + fields, ok := decodePersonRequestFields(w, r, &request) + if !ok { + return + } + if raw, present := fields["pinned"]; !present || bytes.Equal(bytes.TrimSpace(raw), []byte("null")) { + writeError(w, http.StatusBadRequest, "bad_request", "pinned must be a boolean") + return + } + catalog, err := facts.BuildPersonFactCatalogContext(r.Context(), true) + if err != nil { + s.writePersonFactError(w, err) + return + } + target, found := personFactCatalogTarget(catalog, kind, key) + if !found { + writeError(w, http.StatusBadRequest, "invalid_fact_target", "Person fact target is not active") + return + } + write, err := facts.SetPersonFactPinContext(r.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + request.Pinned, "api") + if err != nil { + s.writePersonFactError(w, err) + return + } + if write == nil { + s.logger.Error("person fact pin returned no result") + writeError(w, http.StatusInternalServerError, "person_fact_failed", "Person fact operation failed") + return + } + writePersonFactDiagnostic(w, personFactPinWriteDTO(write)) +} + +type personFactPageQuery struct { + target *personfacts.TargetRef + limit int + offset int +} + +func personFactTargetPageFilter(w http.ResponseWriter, r *http.Request) (personFactPageQuery, bool) { + filter, ok := personFactPageFilter(w, r) + if !ok { + return filter, false + } + rawTarget := r.URL.Query().Get("target") + if rawTarget == "" { + return filter, true + } + target, err := personfacts.DecodeTargetRef(rawTarget) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_target", err.Error()) + return filter, false + } + filter.target = &target + return filter, true +} + +func personFactPageFilter(w http.ResponseWriter, r *http.Request) (personFactPageQuery, bool) { + filter := personFactPageQuery{} + limit, present, err := queryInt(r, limitParam) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_limit", err.Error()) + return filter, false + } + if present && (limit < 1 || limit > 200) { + writeError(w, http.StatusBadRequest, "invalid_limit", "limit must be between 1 and 200") + return filter, false + } + if present { + filter.limit = limit + } + offset, present, err := queryInt(r, "offset") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid_offset", err.Error()) + return filter, false + } + if present && offset < 0 { + writeError(w, http.StatusBadRequest, "invalid_offset", "offset must not be negative") + return filter, false + } + if present { + filter.offset = offset + } + return filter, true +} + +func parsePersonFactTargetKind(raw string) (personfacts.TargetKind, error) { + kind := personfacts.TargetKind(strings.TrimSpace(raw)) + switch kind { + case personfacts.TargetAttribute, personfacts.TargetEmployment: + return kind, nil + default: + return "", fmt.Errorf("unknown person fact target kind %q", raw) + } +} + +func personFactCatalogTarget( + catalog personfacts.Catalog, kind personfacts.TargetKind, key string, +) (personfacts.TargetDescriptor, bool) { + for _, target := range catalog.Targets { + if target.Kind == kind && target.Key == key { + return target, true + } + } + return personfacts.TargetDescriptor{}, false +} + +func (s *Server) personFactStore(w http.ResponseWriter) (PersonFactStore, bool) { + facts, ok := s.store.(PersonFactStore) + if !ok { + writeError(w, http.StatusServiceUnavailable, "person_facts_unavailable", + "Person fact diagnostics are unavailable") + } + return facts, ok +} + +func (s *Server) personFactRequestStore( + w http.ResponseWriter, r *http.Request, +) (PersonFactStore, int64, bool) { + facts, ok := s.personFactStore(w) + if !ok { + return nil, 0, false + } + personID, ok := personProfileID(w, r) + if !ok { + return nil, 0, false + } + if _, err := facts.GetPersonContext(r.Context(), personID); err != nil { + s.writePersonFactError(w, err) + return nil, 0, false + } + return facts, personID, true +} + +func (s *Server) writePersonFactError(w http.ResponseWriter, err error) { + if s.writeIfContextError(w, err) { + return + } + switch { + case errors.Is(err, store.ErrPersonNotFound): + writeError(w, http.StatusNotFound, "person_profile_not_found", "Person profile not found") + case errors.Is(err, store.ErrPersonFactKeyCollision): + writeError(w, http.StatusConflict, "person_fact_conflict", "Person fact history conflicts with existing data") + case errors.Is(err, store.ErrPersonFactPersonNotTracked), + strings.Contains(err.Error(), "target descriptor is stale"), + strings.Contains(err.Error(), "target") && strings.Contains(err.Error(), "is not active"): + writeError(w, http.StatusConflict, "person_fact_conflict", err.Error()) + default: + s.logger.Error("person fact operation failed", "error", err) + writeError(w, http.StatusInternalServerError, "person_fact_failed", "Person fact operation failed") + } +} + +func writePersonFactDiagnostic(w http.ResponseWriter, response any) { + w.Header().Set("Cache-Control", "no-store") + writeJSON(w, http.StatusOK, response) +} + +func personFactEvidenceStatusEventDTO( + event personfacts.EvidenceStatusEvent, +) PersonFactEvidenceStatusEvent { + return PersonFactEvidenceStatusEvent{ + ID: event.ID, GenerationID: event.GenerationID, EvidenceKey: event.EvidenceKey, + SourceVersion: event.SourceVersion, Supported: event.Supported, + Reason: event.Reason, CreatedAt: event.CreatedAt, + } +} + +func personFactEvidenceDTO(evidence personfacts.Evidence) PersonFactEvidence { + result := PersonFactEvidence{ + ID: evidence.ID, EvidenceKey: evidence.Key, + SourceClass: evidence.Input.SourceClass, Directness: evidence.Input.Directness, + Authority: evidence.Input.Authority, SourceRef: evidence.Input.SourceRef, + SourceURL: evidence.Input.SourceURL, SubjectPersonID: evidence.Input.SubjectPersonID, + SubjectRef: evidence.Input.SubjectRef, SpanStart: evidence.Input.SpanStart, + SpanEnd: evidence.Input.SpanEnd, Excerpt: evidence.Input.Excerpt, + ContentSHA256: evidence.Input.ContentSHA256, SourceVersion: evidence.Input.SourceVersion, + EventTime: evidence.Input.EventTime, RecordedTime: evidence.Input.RecordedTime, + IdentityScore: evidence.Input.IdentityScore, Supported: evidence.Supported, + CreatedAt: evidence.CreatedAt, + } + if evidence.LatestStatus != nil { + status := personFactEvidenceStatusEventDTO(*evidence.LatestStatus) + result.LatestStatus = &status + } + return result +} + +func personFactClaimDTO(claim personfacts.Claim) PersonFactClaim { + result := PersonFactClaim{ + ID: claim.ID, GenerationID: claim.GenerationID, ClaimKey: claim.ClaimKey, + ProgramID: claim.Generation.ProgramID, ProgramVersion: claim.Generation.ProgramVersion, + ProgramFingerprint: claim.Generation.ProgramFingerprint, Target: claim.Target, + Relation: claim.Relation, SubmittedValue: string(claim.SubmittedValue), + EvidenceIDs: append([]int64(nil), claim.EvidenceIDs...), ValidFrom: claim.ValidFrom, + ValidUntil: claim.ValidUntil, Origin: claim.Origin, Confidence: claim.Confidence, + CreatedAt: claim.CreatedAt, + } + if result.EvidenceIDs == nil { + result.EvidenceIDs = []int64{} + } + if claim.Normalized != nil { + normalized := string(claim.Normalized.JSON) + fingerprint := claim.Normalized.Fingerprint + result.NormalizedValue = &normalized + result.ValueFingerprint = &fingerprint + } + return result +} + +func personFactDecisionDTO(decision personfacts.Decision) PersonFactDecision { + return PersonFactDecision{ + ID: decision.ID, ResolutionID: decision.ResolutionID, + DecisionKey: decision.DecisionKey, ClaimKey: decision.ClaimKey, + Action: decision.Action, Reason: decision.Reason, Score: PersonFactScoreBreakdown{ + SourceClass: decision.Score.SourceClass, Directness: decision.Score.Directness, + Authority: decision.Score.Authority, Confidence: decision.Score.Confidence, + Freshness: decision.Score.Freshness, Corroboration: decision.Score.Corroboration, + Total: decision.Score.Total, + }, + CompetingClaimKey: decision.CompetingClaimKey, Projection: decision.Projection, + CreatedAt: decision.CreatedAt, + } +} + +func personFactPinWriteDTO(write *personfacts.PinWrite) PersonFactPinWrite { + response := PersonFactPinWrite{ + State: write.State, + Resolutions: make([]PersonFactResolutionResult, len(write.Resolutions)), + Projections: append([]personfacts.ProjectionRef(nil), write.Projections...), + } + if response.Projections == nil { + response.Projections = []personfacts.ProjectionRef{} + } + for index, resolution := range write.Resolutions { + item := PersonFactResolutionResult{ + ID: resolution.ID, Target: resolution.Target, + ResolverVersion: resolution.ResolverVersion, + InputFingerprint: resolution.InputFingerprint, + ResolvedAt: resolution.ResolvedAt, + Decisions: make([]PersonFactDecision, len(resolution.Decisions)), + Projections: append([]personfacts.ProjectionRef(nil), resolution.Projections...), + } + if item.Projections == nil { + item.Projections = []personfacts.ProjectionRef{} + } + for decisionIndex, decision := range resolution.Decisions { + item.Decisions[decisionIndex] = personFactDecisionDTO(decision) + } + response.Resolutions[index] = item + } + return response +} diff --git a/internal/api/person_facts_test.go b/internal/api/person_facts_test.go new file mode 100644 index 000000000..7ff6ede76 --- /dev/null +++ b/internal/api/person_facts_test.go @@ -0,0 +1,447 @@ +package api + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "strconv" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/config" + "go.kenn.io/msgvault/internal/personfacts" + "go.kenn.io/msgvault/internal/store" +) + +var personFactAPINow = time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) + +func TestPersonFactCatalogHTTPDefaultsSensitiveAndSupportsExplicitInclude(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, st, _, _ := newPersonFactAPIFixture(t) + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + UPDATE attribute_definitions SET is_sensitive = TRUE WHERE slug = ?`), + store.AttributeSlugAskMeAbout) + requirements.NoError(err) + + response := doRequest(t, srv.Router(), http.MethodGet, + "/api/v1/person-fact-targets", nil, nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + assertions.Equal("no-store", response.Header().Get("Cache-Control")) + var catalog personfacts.Catalog + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &catalog)) + for _, target := range catalog.Targets { + assertions.False(target.Sensitive, "sensitive targets are opt-in") + } + + response = doRequest(t, srv.Router(), http.MethodGet, + "/api/v1/person-fact-targets?include_sensitive=true", nil, nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &catalog)) + assertions.Condition(func() bool { + for _, target := range catalog.Targets { + if target.Slug == store.AttributeSlugAskMeAbout && target.Sensitive { + return true + } + } + return false + }, "explicit sensitive catalog request includes the marked target") +} + +func TestPersonFactHistoryHTTPIsBoundedNewestFirstNonNullAndNoStore(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, st, personID, targets := newPersonFactAPIFixture(t) + target := targets[store.AttributeSlugPrimaryChannel] + applyPersonFactAPIGeneration(t, st, personID, target, "second", `"phone"`) + targetQuery := personFactAPITargetQuery(target) + + tests := []struct { + name string + path string + field string + }{ + {name: "evidence", path: "fact-evidence", field: "evidence"}, + {name: "claims", path: "fact-claims", field: "claims"}, + {name: "decisions", path: "fact-decisions", field: "decisions"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + path := fmt.Sprintf("/api/v1/people/%d/%s?target=%s&limit=1&offset=0", + personID, test.path, targetQuery) + response := doRequest(t, srv.Router(), http.MethodGet, path, nil, nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + assertions.Equal("no-store", response.Header().Get("Cache-Control")) + var body map[string][]map[string]any + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &body)) + requirements.Len(body[test.field], 1) + assertions.NotZero(body[test.field][0]["id"]) + newestID, ok := body[test.field][0]["id"].(float64) + requirements.True(ok, "history id must decode as a number") + + olderPath := fmt.Sprintf("/api/v1/people/%d/%s?target=%s&limit=1&offset=1", + personID, test.path, targetQuery) + older := doRequest(t, srv.Router(), http.MethodGet, olderPath, nil, nil) + requirements.Equal(http.StatusOK, older.Code, older.Body.String()) + requirements.NoError(json.Unmarshal(older.Body.Bytes(), &body)) + requirements.Len(body[test.field], 1) + olderID, ok := body[test.field][0]["id"].(float64) + requirements.True(ok, "history id must decode as a number") + assertions.Greater(newestID, olderID, + "history must be newest-first") + + emptyPath := fmt.Sprintf("/api/v1/people/%d/%s?limit=1&offset=999", + personID, test.path) + empty := doRequest(t, srv.Router(), http.MethodGet, emptyPath, nil, nil) + requirements.Equal(http.StatusOK, empty.Code, empty.Body.String()) + assertions.JSONEq(fmt.Sprintf(`{"%s":[]}`, test.field), empty.Body.String()) + }) + } + + claims := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-claims?limit=1", personID), nil, nil) + requirements.Equal(http.StatusOK, claims.Code, claims.Body.String()) + var claimBody struct { + Claims []map[string]any `json:"claims"` + } + requirements.NoError(json.Unmarshal(claims.Body.Bytes(), &claimBody)) + requirements.Len(claimBody.Claims, 1) + assertions.Equal("fixture-program", claimBody.Claims[0]["program_id"]) + assertions.Equal("v1", claimBody.Claims[0]["program_version"]) + assertions.Equal(strings.Repeat("a", 64), claimBody.Claims[0]["program_fingerprint"]) +} + +func TestPersonFactHistoryHTTPFiltersRealEmploymentTargetWithColonKey(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, st, personID, targets := newPersonFactAPIFixtureWithoutClaims(t) + target := targets["employment"] + requirements.Equal(personfacts.TargetEmployment, target.Kind) + requirements.Equal("system:employment", target.Key) + requirements.Regexp(`^sha256:[0-9a-f]{64}$`, target.Revision) + applyPersonFactAPIGeneration(t, st, personID, target, "employment", + `{"organization":{"name":"Codec Company"},"title":"Engineer"}`) + + encoded := "employment:system:employment:" + target.Revision + response := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-evidence?target=%s", personID, url.QueryEscape(encoded)), + nil, nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + var body PersonFactEvidenceResponse + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &body)) + assertions.Len(body.Evidence, 1) +} + +func TestPersonFactEvidenceStatusHTTPFiltersPaginatesAndExposesSafeReactivationHistory(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, st, personID, _ := newPersonFactAPIFixture(t) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{}) + requirements.NoError(err) + requirements.Len(evidence, 1) + key := evidence[0].Key + applyPersonFactAPIStatus(t, st, personID, key, "off", false, + personfacts.EvidenceStatusSourceDeleted) + applyPersonFactAPIStatus(t, st, personID, key, "on", true, + personfacts.EvidenceStatusSourceReimported) + + path := fmt.Sprintf("/api/v1/people/%d/fact-evidence-status-events?evidence_key=%s&limit=1&offset=0", + personID, key) + response := doRequest(t, srv.Router(), http.MethodGet, path, nil, nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + assertions.Equal("no-store", response.Header().Get("Cache-Control")) + var body struct { + Events []map[string]json.RawMessage `json:"events"` + } + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &body)) + requirements.Len(body.Events, 1) + assertions.JSONEq("true", string(body.Events[0]["supported"])) + assertions.ElementsMatch([]string{ + "id", "generation_id", "evidence_key", "source_version", "supported", "reason", "created_at", + }, personFactRawMessageKeys(body.Events[0])) + assertions.NotContains(response.Body.String(), "fixture-provider-secret") + assertions.NotContains(response.Body.String(), "Synthetic archive payload") + + for _, supported := range []bool{true, false} { + filteredPath := fmt.Sprintf( + "/api/v1/people/%d/fact-evidence-status-events?evidence_key=%s&supported=%t&limit=10&offset=0", + personID, key, supported) + filtered := doRequest(t, srv.Router(), http.MethodGet, filteredPath, nil, nil) + requirements.Equal(http.StatusOK, filtered.Code, filtered.Body.String()) + requirements.NoError(json.Unmarshal(filtered.Body.Bytes(), &body)) + requirements.Len(body.Events, 1) + assertions.JSONEq(strconv.FormatBool(supported), string(body.Events[0]["supported"])) + } + + empty := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-evidence-status-events?evidence_key=missing", personID), + nil, nil) + requirements.Equal(http.StatusOK, empty.Code, empty.Body.String()) + assertions.JSONEq(`{"events":[]}`, empty.Body.String()) +} + +func TestPersonFactPinHTTPUsesAPIActorAndReturnsOnlyFinalProjectionReferences(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, st, personID, targets := newPersonFactAPIFixtureWithoutClaims(t) + target := targets[store.AttributeSlugPrimaryChannel] + _, err := st.SetPersonAttributeValueContext(t.Context(), store.PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, + }) + requirements.NoError(err) + applyPersonFactAPIGeneration(t, st, personID, target, "pin-candidate", `"chat"`) + + path := fmt.Sprintf("/api/v1/people/%d/fact-pins/%s/%s", + personID, target.Kind, target.Key) + response := doRequest(t, srv.Router(), http.MethodPut, path, + []byte(`{"pinned":false}`), nil) + requirements.Equal(http.StatusOK, response.Code, response.Body.String()) + assertions.Equal("no-store", response.Header().Get("Cache-Control")) + var body map[string]any + requirements.NoError(json.Unmarshal(response.Body.Bytes(), &body)) + state, ok := body["state"].(map[string]any) + requirements.True(ok, "pin state must decode as an object") + assertions.Equal("api", state["actor"]) + projections, ok := body["projections"].([]any) + requirements.True(ok, "projections must decode as an array") + requirements.NotEmpty(projections) + projection, ok := projections[0].(map[string]any) + requirements.True(ok, "projection must decode as an object") + assertions.ElementsMatch([]string{"kind", "row_id"}, + mapKeysAny(projection)) + for _, forbidden := range []string{"operation", "current_ref", "active_from", "active_until"} { + assertions.NotContains(response.Body.String(), forbidden) + } + + listed := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-pins", personID), nil, nil) + requirements.Equal(http.StatusOK, listed.Code, listed.Body.String()) + assertions.Contains(listed.Body.String(), `"pinned":false`) + + unknownField := doRequest(t, srv.Router(), http.MethodPut, path, + []byte(`{"pinned":true,"actor":"client"}`), nil) + assertions.Equal(http.StatusBadRequest, unknownField.Code, unknownField.Body.String()) +} + +func TestPersonFactHTTPMapsInvalidMissingConflictAndUnavailable(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + srv, _, personID, _ := newPersonFactAPIFixture(t) + invalid := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-claims?limit=201", personID), nil, nil) + assertions.Equal(http.StatusBadRequest, invalid.Code, invalid.Body.String()) + for _, target := range []string{ + "candidate:system:employment:sha256:" + strings.Repeat("a", 64), + "employment:system:employment:revision-1", + } { + malformedTarget := doRequest(t, srv.Router(), http.MethodGet, + fmt.Sprintf("/api/v1/people/%d/fact-claims?target=%s", personID, url.QueryEscape(target)), + nil, nil) + assertions.Equal(http.StatusBadRequest, malformedTarget.Code, + malformedTarget.Body.String()) + } + + invalidPinsPerson := doRequest(t, srv.Router(), http.MethodGet, + "/api/v1/people/not-a-person/fact-pins", nil, nil) + assertions.Equal(http.StatusBadRequest, invalidPinsPerson.Code, invalidPinsPerson.Body.String()) + + missing := doRequest(t, srv.Router(), http.MethodGet, + "/api/v1/people/999999/fact-evidence", nil, nil) + assertions.Equal(http.StatusNotFound, missing.Code, missing.Body.String()) + + _, wrapped := newIdentityLinkTestServer(t) + conflicting := &personFactFailingStore{stubIdentityCacheStore: wrapped, + pinErr: store.ErrPersonFactKeyCollision} + conflictServer := NewServer(&config.Config{Server: config.ServerConfig{APIPort: 8080}}, + conflicting, nil, testLogger()) + catalog, err := conflicting.BuildPersonFactCatalogContext(t.Context(), true) + requirements.NoError(err) + requirements.NotEmpty(catalog.Targets) + conflictPerson := conflicting.mustParticipant(t, "conflict@example.test", "conflict", "example.test") + person, _, err := conflicting.CreatePersonFromParticipant(conflictPerson) + requirements.NoError(err) + target := catalog.Targets[0] + conflict := doRequest(t, conflictServer.Router(), http.MethodPut, + fmt.Sprintf("/api/v1/people/%d/fact-pins/%s/%s", person.ID, target.Kind, target.Key), + []byte(`{"pinned":true}`), nil) + assertions.Equal(http.StatusConflict, conflict.Code, conflict.Body.String()) + + unavailable, _ := newTestServerWithMockStore(t) + serviceUnavailable := doRequest(t, unavailable.Router(), http.MethodGet, + "/api/v1/person-fact-targets", nil, nil) + assertions.Equal(http.StatusServiceUnavailable, serviceUnavailable.Code, + serviceUnavailable.Body.String()) +} + +func TestPersonFactPinHTTPDecodesTargetKeyExactlyOnce(t *testing.T) { + _, wrapped := newIdentityLinkTestServer(t) + participantID := wrapped.mustParticipant(t, "encoded@example.test", "encoded", "example.test") + person, _, err := wrapped.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + target := personfacts.TargetDescriptor{ + Kind: personfacts.TargetAttribute, Key: "encoded%2Fkey", Revision: "revision-1", + UniversalID: "encoded%2Fkey", Slug: "encoded_key", Description: "Synthetic encoded key", + ValueType: personfacts.ValueText, Cardinality: personfacts.CardinalitySingle, + Choices: []personfacts.ChoiceDescriptor{}, Fields: []personfacts.FieldDescriptor{}, + } + capturing := &personFactCapturingStore{stubIdentityCacheStore: wrapped, + catalog: personfacts.Catalog{Version: "v1", Fingerprint: "fixture", Targets: []personfacts.TargetDescriptor{target}}} + srv := NewServer(&config.Config{Server: config.ServerConfig{APIPort: 8080}}, + capturing, nil, testLogger()) + response := doRequest(t, srv.Router(), http.MethodPut, + fmt.Sprintf("/api/v1/people/%d/fact-pins/attribute/encoded%%252Fkey", person.ID), + []byte(`{"pinned":true}`), nil) + require.Equal(t, http.StatusOK, response.Code, response.Body.String()) + assert.Equal(t, "encoded%2Fkey", capturing.target.Key) +} + +type personFactFailingStore struct { + *stubIdentityCacheStore + + pinErr error +} + +func (s *personFactFailingStore) SetPersonFactPinContext( + _ context.Context, _ int64, _ personfacts.TargetRef, _ bool, _ string, +) (*personfacts.PinWrite, error) { + return nil, s.pinErr +} + +type personFactCapturingStore struct { + *stubIdentityCacheStore + + catalog personfacts.Catalog + target personfacts.TargetRef +} + +func (s *personFactCapturingStore) BuildPersonFactCatalogContext( + _ context.Context, _ bool, +) (personfacts.Catalog, error) { + return s.catalog, nil +} + +func (s *personFactCapturingStore) SetPersonFactPinContext( + _ context.Context, _ int64, target personfacts.TargetRef, pinned bool, actor string, +) (*personfacts.PinWrite, error) { + s.target = target + return &personfacts.PinWrite{ + State: personfacts.PinState{Target: target, Pinned: pinned, Actor: actor}, + Resolutions: []personfacts.ResolutionResult{}, Projections: []personfacts.ProjectionRef{}, + }, nil +} + +func newPersonFactAPIFixture( + t *testing.T, +) (*Server, *store.Store, int64, map[string]personfacts.TargetDescriptor) { + t.Helper() + srv, st, personID, targets := newPersonFactAPIFixtureWithoutClaims(t) + applyPersonFactAPIGeneration(t, st, personID, + targets[store.AttributeSlugPrimaryChannel], "first", `"chat"`) + return srv, st, personID, targets +} + +func newPersonFactAPIFixtureWithoutClaims( + t *testing.T, +) (*Server, *store.Store, int64, map[string]personfacts.TargetDescriptor) { + t.Helper() + srv, wrapped := newIdentityLinkTestServer(t) + participantID := wrapped.mustParticipant(t, "facts@example.test", "facts", "example.test") + person, _, err := wrapped.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + _, err = wrapped.SetPersonTrackingContext(t.Context(), person.ID, true) + require.NoError(t, err) + catalog, err := wrapped.BuildPersonFactCatalogContext(t.Context(), true) + require.NoError(t, err) + targets := make(map[string]personfacts.TargetDescriptor) + for _, target := range catalog.Targets { + targets[target.Slug] = target + } + require.Contains(t, targets, store.AttributeSlugPrimaryChannel) + return srv, wrapped.Store, person.ID, targets +} + +func applyPersonFactAPIGeneration( + t *testing.T, st *store.Store, personID int64, target personfacts.TargetDescriptor, + suffix, submitted string, +) { + t.Helper() + subject := personID + _, err := st.ApplyPersonFactGenerationContext(t.Context(), personfacts.GenerationInput{ + PersonID: personID, + SourceCursors: []personfacts.SourceCursor{{ + Lane: "fixture", Start: suffix, End: suffix + "-end", + }}, + ProgramID: "fixture-program", ProgramVersion: "v1", + ProgramFingerprint: strings.Repeat("a", 64), CatalogFingerprint: "fixture-catalog", + Provider: "fixture-provider-secret", ProviderVersion: "v1", + Model: "fixture-model", ModelVersion: "v1", ResolvedAt: personFactAPINow, + Policy: personfacts.PolicyContext{ProviderPolicyFingerprint: "fixture-policy"}, + Claims: []personfacts.ProposedClaim{{ + Target: target, Relation: personfacts.RelationSupport, + SubmittedValue: json.RawMessage(submitted), Origin: personfacts.OriginExtraction, + Confidence: personfacts.ConfidenceInputs{ReportedScore: 900}, + Evidence: []personfacts.EvidenceInput{{ + PersonID: personID, SourceClass: personfacts.EvidencePublic, + Directness: personfacts.DirectSelf, Authority: personfacts.AuthorityAuthoritative, + SourceURL: "https://example.test/" + suffix, SubjectPersonID: &subject, + SubjectRef: "synthetic-person", Excerpt: "Synthetic archive payload " + suffix, + SourceVersion: "source-v1", EventTime: personFactAPINow.Add(-time.Hour), + RecordedTime: personFactAPINow, IdentityScore: 990, + }}, + }}, + }, nil) + require.NoError(t, err) +} + +func applyPersonFactAPIStatus( + t *testing.T, st *store.Store, personID int64, evidenceKey, suffix string, + supported bool, reason personfacts.EvidenceStatusReason, +) { + t.Helper() + _, err := st.ApplyPersonFactGenerationContext(t.Context(), personfacts.GenerationInput{ + PersonID: personID, + SourceCursors: []personfacts.SourceCursor{{ + Lane: "fixture-status", Start: suffix, End: suffix + "-end", + }}, + ProgramID: "fixture-program", ProgramVersion: "v1", + ProgramFingerprint: strings.Repeat("a", 64), CatalogFingerprint: "fixture-catalog", + Provider: "fixture-provider-secret", ProviderVersion: "v1", + ResolvedAt: personFactAPINow.Add(time.Duration(len(suffix)) * time.Minute), + Policy: personfacts.PolicyContext{ProviderPolicyFingerprint: "fixture-policy"}, + EvidenceStatusChanges: []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", + Supported: supported, Reason: reason, + }}, + }, nil) + require.NoError(t, err) +} + +func personFactAPITargetQuery(target personfacts.TargetDescriptor) string { + return fmt.Sprintf("%s:%s:%s", target.Kind, target.Key, target.Revision) +} + +func personFactRawMessageKeys(values map[string]json.RawMessage) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} + +func mapKeysAny(values map[string]any) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + return keys +} diff --git a/internal/api/relationship_calendar_test.go b/internal/api/relationship_calendar_test.go index c938bc179..0962b0f8e 100644 --- a/internal/api/relationship_calendar_test.go +++ b/internal/api/relationship_calendar_test.go @@ -177,7 +177,7 @@ func TestRelationshipCalendarRequestRejectsTrailingJSON(t *testing.T) { func TestRelationshipCalendarOpenAPIContract(t *testing.T) { assert := assert.New(t) require := require.New(t) - assert.Equal("2.10.0", APISchemaVersion) + assert.Equal("2.11.0", APISchemaVersion) document := OpenAPIDocument() path := document.Paths["/api/v1/relationships/{id}/calendar"] require.NotNil(path) diff --git a/internal/api/routes.go b/internal/api/routes.go index 32ae829ec..994de0f5b 100644 --- a/internal/api/routes.go +++ b/internal/api/routes.go @@ -240,6 +240,7 @@ func (s *Server) registerHumaRoutes(api huma.API, apiV1 huma.API) { s.registerCommunicationServiceRoutes(apiV1) s.registerAttributeDefinitionRoutes(apiV1) s.registerPersonAttributeRoutes(apiV1) + s.registerPersonFactRoutes(apiV1) s.registerParticipantRoutes(apiV1) s.registerRelationshipRoutes(apiV1) s.registerPersonRelationshipRoutes(apiV1) diff --git a/internal/codegenfix/fix.go b/internal/codegenfix/fix.go index 9af4fd097..a3611bb8d 100644 --- a/internal/codegenfix/fix.go +++ b/internal/codegenfix/fix.go @@ -13,6 +13,12 @@ var requiredPointerValidators = [][3]string{ {"FileSearchRow", "MimeType", "f"}, {"PersonFileSearchRow", "Filename", "p"}, {"PersonFileSearchRow", "MimeType", "p"}, + {"PersonFactEvidence", "SourceRef", "p"}, + {"PersonFactEvidence", "SourceURL", "p"}, + {"PersonFactEvidence", "ContentSha256", "p"}, + {"PersonFactEvidence", "SourceVersion", "p"}, + {"PersonFactEvidence", "SubjectRef", "p"}, + {"PersonFactEvidence", "Excerpt", "p"}, } // RewriteGeneratedValidators preserves required-but-empty string fields as diff --git a/internal/codegenfix/fix_test.go b/internal/codegenfix/fix_test.go index ea4227e5d..92b2098a1 100644 --- a/internal/codegenfix/fix_test.go +++ b/internal/codegenfix/fix_test.go @@ -16,6 +16,12 @@ func TestRewriteGeneratedValidatorsRepairsKnownGeneratorGaps(t *testing.T) { assertions.NotContains(string(got), "if f.MimeType != nil") assertions.NotContains(string(got), "if p.Filename != nil") assertions.NotContains(string(got), "if p.MimeType != nil") + assertions.NotContains(string(got), "if p.SourceRef != nil") + assertions.NotContains(string(got), "if p.SourceURL != nil") + assertions.NotContains(string(got), "if p.ContentSha256 != nil") + assertions.NotContains(string(got), "if p.SourceVersion != nil") + assertions.NotContains(string(got), "if p.SubjectRef != nil") + assertions.NotContains(string(got), "if p.Excerpt != nil") assertions.Contains(string(got), `typesValidator.Var(e.Grouping, "required,min=1,max=1")`) assertions.Contains(string(got), `typesValidator.Var(f.Grouping, "required,min=1,max=1")`) assertions.NotContains(string(got), exploreCacheRecoveryActionRequiredValidatorBlock()) @@ -100,7 +106,9 @@ func (f FileGroupsHTTPRequest) Validate() error { var errors runtime.ValidationErrors } ` + pointerValidatorFixture("FileMetadataResponse", "f") + pointerValidatorFixture("FileSearchRow", "f") + - pointerValidatorFixture("PersonFileSearchRow", "p") + ` + pointerValidatorFixture("PersonFileSearchRow", "p") + + requiredStringPointerValidatorFixture("PersonFactEvidence", "p", + "SourceRef", "SourceURL", "ContentSha256", "SourceVersion", "SubjectRef", "Excerpt") + ` func (c CreateDailyNoteEntryRequest) Validate() error { var errors runtime.ValidationErrors ` + dailyNoteDecoyValidatorBlock() + dailyNotePersonIDsValidatorBlock("omitempty,gte=1") + ` @@ -149,3 +157,29 @@ func pointerValidatorFixture(typeName, receiver string) string { } ` } + +func requiredStringPointerValidatorFixture( + typeName, receiver string, fields ...string, +) string { + var result strings.Builder + result.WriteString("func (") + result.WriteString(receiver) + result.WriteString(" ") + result.WriteString(typeName) + result.WriteString(") Validate() error {\n\tvar errors runtime.ValidationErrors\n") + for _, field := range fields { + result.WriteString("\tif ") + result.WriteString(receiver) + result.WriteString(".") + result.WriteString(field) + result.WriteString(" != nil {\n\t\tif err := typesValidator.Var(") + result.WriteString(receiver) + result.WriteString(".") + result.WriteString(field) + result.WriteString(", \"required\"); err != nil {\n\t\t\terrors = errors.Append(\"") + result.WriteString(field) + result.WriteString("\", err)\n\t\t}\n\t}\n") + } + result.WriteString("}\n") + return result.String() +} diff --git a/internal/personfacts/alignment.go b/internal/personfacts/alignment.go new file mode 100644 index 000000000..edc5875f4 --- /dev/null +++ b/internal/personfacts/alignment.go @@ -0,0 +1,199 @@ +package personfacts + +import ( + "encoding/json" + "errors" + "fmt" + "net/url" + "regexp" + "strings" + "time" + "unicode/utf8" +) + +var lowercaseSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) + +type evidenceValidationError struct { + reason DecisionReason + detail string +} + +func (e *evidenceValidationError) Error() string { return e.detail } + +// EvidenceKey validates an immutable evidence version and hashes every field +// that identifies or interprets that version. +func EvidenceKey(input EvidenceInput) (string, error) { + if err := validateEvidenceInput(input); err != nil { + return "", err + } + encoded, err := json.Marshal(evidenceKeyView(input)) + if err != nil { + return "", fmt.Errorf("encode evidence key: %w", err) + } + return fingerprint(encoded), nil +} + +func validateEvidenceInput(input EvidenceInput) error { + invalid := func(reason DecisionReason, format string, args ...any) error { + return &evidenceValidationError{reason: reason, detail: fmt.Sprintf(format, args...)} + } + if input.PersonID <= 0 { + return invalid(ReasonMalformedValue, "evidence person id must be positive") + } + if input.SubjectPersonID == nil || *input.SubjectPersonID != input.PersonID { + return invalid(ReasonIdentityMismatch, "evidence subject person id must equal evidence person id") + } + if !validEvidenceSourceClass(input.SourceClass) { + return invalid(ReasonMalformedValue, "unknown evidence source class %q", input.SourceClass) + } + if !validEvidenceDirectness(input.Directness) { + return invalid(ReasonMalformedValue, "unknown evidence directness %q", input.Directness) + } + if !validEvidenceAuthority(input.Authority) { + return invalid(ReasonMalformedValue, "unknown evidence authority %q", input.Authority) + } + if utf8.RuneCountInString(input.Excerpt) > MaxEvidenceExcerptRunes { + return invalid(ReasonMalformedValue, "evidence excerpt exceeds %d Unicode characters", MaxEvidenceExcerptRunes) + } + if input.IdentityScore < 0 || input.IdentityScore > 1000 { + return invalid(ReasonMalformedValue, "evidence identity score must be between 0 and 1000") + } + if input.EventTime.IsZero() || !isUTC(input.EventTime) { + return invalid(ReasonMalformedValue, "evidence event time must be nonzero UTC") + } + if input.RecordedTime.IsZero() || !isUTC(input.RecordedTime) { + return invalid(ReasonMalformedValue, "evidence recorded time must be nonzero UTC") + } + if input.ContentSHA256 != "" && !lowercaseSHA256Pattern.MatchString(input.ContentSHA256) { + return invalid(ReasonMalformedValue, "evidence content hash must be lowercase 64-hex SHA-256") + } + + pairedSpan := input.SpanStart != nil && input.SpanEnd != nil + if (input.SpanStart == nil) != (input.SpanEnd == nil) { + return invalid(ReasonMalformedValue, "evidence span start and end must be paired") + } + if pairedSpan && (*input.SpanStart < 0 || *input.SpanEnd < *input.SpanStart) { + return invalid(ReasonMalformedValue, "evidence span must be nonnegative and ordered") + } + + switch input.SourceClass { + case EvidenceArchive: + if strings.TrimSpace(input.SourceRef) == "" { + return invalid(ReasonMalformedValue, "archive evidence source ref is required") + } + if !pairedSpan { + return invalid(ReasonMalformedValue, "archive evidence requires a source span") + } + if input.SourceVersion == "" { + return invalid(ReasonMalformedValue, "archive evidence source version is required") + } + if !lowercaseSHA256Pattern.MatchString(input.ContentSHA256) { + return invalid(ReasonMalformedValue, "archive evidence content hash must be lowercase 64-hex SHA-256") + } + if input.SourceURL != "" && !validHTTPSURL(input.SourceURL) { + return invalid(ReasonMalformedValue, "archive evidence source URL must use HTTPS") + } + case EvidencePublic: + if !validHTTPSURL(input.SourceURL) { + return invalid(ReasonMalformedValue, "public evidence requires an HTTPS source URL") + } + if pairedSpan { + return invalid(ReasonMalformedValue, "public evidence forbids archive spans") + } + case EvidenceProviderAssertion: + if strings.TrimSpace(input.SourceRef) == "" { + return invalid(ReasonMalformedValue, "provider assertion requires an opaque source ref") + } + if pairedSpan { + return invalid(ReasonMalformedValue, "provider assertion forbids archive spans") + } + if input.SourceURL != "" && !validHTTPSURL(input.SourceURL) { + return invalid(ReasonMalformedValue, "provider assertion source URL must use HTTPS") + } + if input.Directness != Indirect { + return invalid(ReasonMalformedValue, "provider assertion directness must be indirect") + } + if input.Authority != AuthorityOrdinary && input.Authority != AuthorityAggregator { + return invalid(ReasonMalformedValue, "provider assertion authority must be ordinary or aggregator") + } + case EvidenceSystem: + if strings.TrimSpace(input.SourceRef) == "" { + return invalid(ReasonMalformedValue, "system evidence source ref is required") + } + if pairedSpan { + return invalid(ReasonMalformedValue, "system evidence forbids archive spans") + } + if input.SourceURL != "" && !validHTTPSURL(input.SourceURL) { + return invalid(ReasonMalformedValue, "system evidence source URL must use HTTPS") + } + } + return nil +} + +func validEvidenceSourceClass(value EvidenceSourceClass) bool { + return value == EvidenceArchive || value == EvidencePublic || + value == EvidenceSystem || value == EvidenceProviderAssertion +} + +func validEvidenceDirectness(value EvidenceDirectness) bool { + return value == DirectSelf || value == DirectOther || value == Indirect +} + +func validEvidenceAuthority(value EvidenceAuthority) bool { + return value == AuthorityAuthoritative || value == AuthorityOrdinary || value == AuthorityAggregator +} + +func validHTTPSURL(raw string) bool { + parsed, err := url.Parse(raw) + return err == nil && parsed.Scheme == "https" && parsed.Hostname() != "" && parsed.User == nil +} + +func isUTC(value time.Time) bool { + _, offset := value.Zone() + return offset == 0 +} + +type canonicalEvidence struct { + PersonID int64 `json:"person_id"` + SourceClass EvidenceSourceClass `json:"source_class"` + Directness EvidenceDirectness `json:"directness"` + Authority EvidenceAuthority `json:"authority"` + SourceRef string `json:"source_ref"` + SourceURL string `json:"source_url"` + SubjectPersonID *int64 `json:"subject_person_id"` + SubjectRef string `json:"subject_ref"` + SpanStart *int64 `json:"span_start"` + SpanEnd *int64 `json:"span_end"` + Excerpt string `json:"excerpt"` + ContentSHA256 string `json:"content_sha256"` + SourceVersion string `json:"source_version"` + EventTime string `json:"event_time"` + RecordedTime string `json:"recorded_time"` + IdentityScore int `json:"identity_score"` +} + +func evidenceKeyView(input EvidenceInput) canonicalEvidence { + return canonicalEvidence{ + PersonID: input.PersonID, SourceClass: input.SourceClass, + Directness: input.Directness, Authority: input.Authority, + SourceRef: input.SourceRef, SourceURL: input.SourceURL, + SubjectPersonID: copyInt64Pointer(input.SubjectPersonID), SubjectRef: input.SubjectRef, + SpanStart: copyInt64Pointer(input.SpanStart), SpanEnd: copyInt64Pointer(input.SpanEnd), + Excerpt: input.Excerpt, ContentSHA256: input.ContentSHA256, + SourceVersion: input.SourceVersion, + EventTime: portableFactTime(input.EventTime).Format(time.RFC3339Nano), + RecordedTime: portableFactTime(input.RecordedTime).Format(time.RFC3339Nano), + IdentityScore: input.IdentityScore, + } +} + +func evidenceFailure(err error) *ValidationFailure { + if validation, ok := errors.AsType[*evidenceValidationError](err); ok { + action := DecisionInvalid + if validation.reason == ReasonIdentityMismatch { + action = DecisionIdentityRejected + } + return &ValidationFailure{Action: action, Reason: validation.reason, Detail: validation.detail} + } + return &ValidationFailure{Action: DecisionInvalid, Reason: ReasonMalformedValue, Detail: err.Error()} +} diff --git a/internal/personfacts/alignment_test.go b/internal/personfacts/alignment_test.go new file mode 100644 index 000000000..825cc1847 --- /dev/null +++ b/internal/personfacts/alignment_test.go @@ -0,0 +1,182 @@ +package personfacts + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestEvidenceValidationBoundsAndPairs(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + valid := validArchiveEvidence() + valid.Excerpt = strings.Repeat("界", MaxEvidenceExcerptRunes) + validPublic := valid + validPublic.SourceClass = EvidencePublic + validPublic.SourceRef = "" + validPublic.SourceURL = "https://example.com/profile" + validPublic.SpanStart = nil + validPublic.SpanEnd = nil + validProvider := validPublic + validProvider.SourceClass = EvidenceProviderAssertion + validProvider.SourceRef = "provider-job-123" + validProvider.SourceURL = "" + validProvider.Directness = Indirect + validProvider.Authority = AuthorityOrdinary + citedProvider := validProvider + citedProvider.SourceURL = "https://example.com/provider-result" + validSystem := validProvider + validSystem.SourceClass = EvidenceSystem + validSystem.SourceRef = "system-observation-123" + + tests := []struct { + name string + mutate func(*EvidenceInput) + wantErr bool + }{ + {name: "valid archive"}, + {name: "2000 rune excerpt"}, + {name: "2001 rune excerpt", mutate: func(v *EvidenceInput) { v.Excerpt += "界" }, wantErr: true}, + {name: "missing span end", mutate: func(v *EvidenceInput) { v.SpanEnd = nil }, wantErr: true}, + {name: "negative span", mutate: func(v *EvidenceInput) { n := int64(-1); v.SpanStart = &n }, wantErr: true}, + {name: "reversed span", mutate: func(v *EvidenceInput) { n := int64(5); v.SpanEnd = &n }, wantErr: true}, + {name: "uppercase hash", mutate: func(v *EvidenceInput) { v.ContentSHA256 = strings.Repeat("A", 64) }, wantErr: true}, + {name: "non UTC event time", mutate: func(v *EvidenceInput) { v.EventTime = v.EventTime.In(fixedZone()) }, wantErr: true}, + {name: "non UTC recorded time", mutate: func(v *EvidenceInput) { v.RecordedTime = v.RecordedTime.In(fixedZone()) }, wantErr: true}, + {name: "identity below bound", mutate: func(v *EvidenceInput) { v.IdentityScore = -1 }, wantErr: true}, + {name: "identity above bound", mutate: func(v *EvidenceInput) { v.IdentityScore = 1001 }, wantErr: true}, + {name: "archive missing ref", mutate: func(v *EvidenceInput) { v.SourceRef = "" }, wantErr: true}, + {name: "archive missing source version", mutate: func(v *EvidenceInput) { v.SourceVersion = "" }, wantErr: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + input := valid + if test.mutate != nil { + test.mutate(&input) + } + key, err := EvidenceKey(input) + if test.wantErr { + require.Error(err) + assert.Empty(key) + return + } + require.NoError(err) + assert.NotEmpty(key) + }) + } + + for _, input := range []EvidenceInput{validPublic, validProvider, citedProvider} { + key, err := EvidenceKey(input) + requirements.NoError(err) + assertions.NotEmpty(key) + } + for _, input := range []EvidenceInput{validPublic, validProvider, validSystem} { + input.SourceVersion = "" + key, err := EvidenceKey(input) + requirements.NoError(err) + assertions.NotEmpty(key) + } + + invalidProvider := []EvidenceInput{validProvider, validProvider} + invalidProvider[0].Directness = DirectSelf + invalidProvider[1].Authority = AuthorityAuthoritative + for _, input := range invalidProvider { + _, err := EvidenceKey(input) + requirements.Error(err) + } + + for _, sourceURL := range []string{"http://example.com/profile", "https://:443/source"} { + invalidPublic := validPublic + invalidPublic.SourceURL = sourceURL + _, err := EvidenceKey(invalidPublic) + requirements.Error(err, sourceURL) + } +} + +func TestEvidenceAlignmentBindsImmutableSourceVersion(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + evidence := validArchiveEvidence() + originalVersion := evidence.SourceVersion + originalHash := evidence.ContentSHA256 + aligner := evidenceAlignerFunc(func(_ context.Context, got EvidenceInput) (AlignmentResult, error) { + assert.Equal(originalVersion, got.SourceVersion) + assert.Equal(originalHash, got.ContentSHA256) + return AlignmentResult{ + Accepted: true, SourceVersion: "immutable-v2", ContentSHA256: strings.Repeat("b", 64), + }, nil + }) + input := validGenerationInput() + input.Claims = []ProposedClaim{validClaim(evidence)} + + prepared, err := PreparePersonFactGeneration(t.Context(), input, aligner) + require.NoError(err) + claims := prepared.Claims() + require.Len(claims, 1) + require.Len(claims[0].Evidence, 1) + assert.Equal("immutable-v2", claims[0].Evidence[0].SourceVersion) + assert.Equal(strings.Repeat("b", 64), claims[0].Evidence[0].ContentSHA256) + assert.Equal(originalVersion, input.Claims[0].Evidence[0].SourceVersion) + assert.Equal(originalHash, input.Claims[0].Evidence[0].ContentSHA256) + wantKey, err := EvidenceKey(claims[0].Evidence[0]) + require.NoError(err) + assert.Equal([]string{wantKey}, claims[0].EvidenceKeys) +} + +func TestEvidenceSubjectIsCanonicalPersonID(t *testing.T) { + tests := []struct { + name string + subject *int64 + failed bool + }{ + {name: "canonical subject", subject: new(int64(7))}, + {name: "missing subject", subject: nil, failed: true}, + {name: "wrong subject", subject: new(int64(8)), failed: true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + evidence := validArchiveEvidence() + evidence.SubjectPersonID = test.subject + input := validGenerationInput() + input.Claims = []ProposedClaim{validClaim(evidence)} + prepared, err := PreparePersonFactGeneration(t.Context(), input, acceptingAligner()) + require.NoError(err) + claim := prepared.Claims()[0] + if !test.failed { + assert.Nil(claim.Failure) + return + } + require.NotNil(claim.Failure) + assert.Equal(DecisionIdentityRejected, claim.Failure.Action) + assert.Equal(ReasonIdentityMismatch, claim.Failure.Reason) + assert.Empty(claim.Evidence) + assert.Empty(claim.EvidenceKeys) + }) + } +} + +func fixedZone() *time.Location { return time.FixedZone("fixture", 3600) } + +type evidenceAlignerFunc func(context.Context, EvidenceInput) (AlignmentResult, error) + +func (f evidenceAlignerFunc) Align(ctx context.Context, input EvidenceInput) (AlignmentResult, error) { + return f(ctx, input) +} + +func acceptingAligner() EvidenceAligner { + return evidenceAlignerFunc(func(_ context.Context, input EvidenceInput) (AlignmentResult, error) { + return AlignmentResult{Accepted: true, SourceVersion: input.SourceVersion, ContentSHA256: input.ContentSHA256}, nil + }) +} diff --git a/internal/personfacts/catalog.go b/internal/personfacts/catalog.go new file mode 100644 index 000000000..145452018 --- /dev/null +++ b/internal/personfacts/catalog.go @@ -0,0 +1,141 @@ +package personfacts + +import ( + "crypto/sha256" + "encoding/json" + "fmt" + "sort" + "strings" + "unicode/utf8" +) + +const catalogVersion = "1" + +// BuildCatalog constructs the inference target catalog from portable attribute +// definitions and fixed structured targets. +func BuildCatalog(definitions []Definition, opts CatalogOptions) (Catalog, error) { + targets := []TargetDescriptor{employmentDescriptor()} + for _, definition := range definitions { + description, ok := inferenceDescription(definition.Description) + if !ok || !eligibleDefinition(definition, opts) { + continue + } + targets = append(targets, TargetDescriptor{ + Kind: TargetAttribute, + Key: definition.UniversalID, + UniversalID: definition.UniversalID, + Slug: definition.Slug, + Description: description, + ValueType: definition.ValueType, + Cardinality: definition.Cardinality, + RecordTarget: definition.RecordTarget, + MaxLength: definition.MaxLength, + Choices: cloneChoices(definition.Choices), + Sensitive: definition.Sensitive, + }) + } + + targets = canonicalTargets(targets) + for i := range targets { + revision, err := DescriptorRevision(targets[i]) + if err != nil { + return Catalog{}, err + } + targets[i].Revision = revision + } + fingerprint, err := CatalogFingerprint(targets) + if err != nil { + return Catalog{}, err + } + return Catalog{Version: catalogVersion, Fingerprint: fingerprint, Targets: targets}, nil +} + +// DescriptorRevision hashes the inference-relevant content of one target. +func DescriptorRevision(target TargetDescriptor) (string, error) { + target = canonicalTarget(target) + target.Revision = "" + encoded, err := json.Marshal(target) + if err != nil { + return "", fmt.Errorf("encode descriptor revision: %w", err) + } + return fmt.Sprintf("sha256:%x", sha256.Sum256(encoded)), nil +} + +// CatalogFingerprint hashes the sorted target descriptors, including their +// descriptor revisions. +func CatalogFingerprint(targets []TargetDescriptor) (string, error) { + encoded, err := json.Marshal(canonicalTargets(targets)) + if err != nil { + return "", fmt.Errorf("encode catalog fingerprint: %w", err) + } + return fmt.Sprintf("sha256:%x", sha256.Sum256(encoded)), nil +} + +func eligibleDefinition(definition Definition, opts CatalogOptions) bool { + if !definition.Active || !definition.APIMutable || definition.Derived || + (definition.Sensitive && !opts.IncludeSensitive) || definition.RecordTarget != "" { + return false + } + switch definition.ValueType { + case ValueText, ValueInteger, ValueReal, ValueBoolean, ValueDate, ValueTimestamp: + return true + default: + return false + } +} + +func inferenceDescription(value string) (string, bool) { + normalized := strings.TrimSpace(value) + return normalized, normalized != "" && utf8.RuneCountInString(normalized) <= 280 +} + +func employmentDescriptor() TargetDescriptor { + return TargetDescriptor{ + Kind: TargetEmployment, + Key: "system:employment", + UniversalID: "system:employment", + Slug: "employment", + Description: "Current and historical employment, including organization, title, role, department, location, and partial start and end dates", + ValueType: ValueEmployment, + Cardinality: CardinalityMulti, + Fields: []FieldDescriptor{ + {Name: "organization", ValueType: ValueOrganization, Cardinality: CardinalitySingle, Required: true}, + {Name: "title", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "role", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "department", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "location", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "start_date", ValueType: ValuePartialDate, Cardinality: CardinalitySingle}, + {Name: "end_date", ValueType: ValuePartialDate, Cardinality: CardinalitySingle}, + }, + } +} + +func canonicalTargets(targets []TargetDescriptor) []TargetDescriptor { + cloned := make([]TargetDescriptor, len(targets)) + for i := range targets { + cloned[i] = canonicalTarget(targets[i]) + } + sort.Slice(cloned, func(i, j int) bool { + if cloned[i].Kind != cloned[j].Kind { + return cloned[i].Kind < cloned[j].Kind + } + return cloned[i].Key < cloned[j].Key + }) + return cloned +} + +func canonicalTarget(target TargetDescriptor) TargetDescriptor { + target.Choices = cloneChoices(target.Choices) + sort.Slice(target.Choices, func(i, j int) bool { + if target.Choices[i].Value != target.Choices[j].Value { + return target.Choices[i].Value < target.Choices[j].Value + } + return target.Choices[i].Label < target.Choices[j].Label + }) + target.Fields = append([]FieldDescriptor(nil), target.Fields...) + return target +} + +func cloneChoices(choices []ChoiceDescriptor) []ChoiceDescriptor { + return append([]ChoiceDescriptor(nil), choices...) +} diff --git a/internal/personfacts/catalog_test.go b/internal/personfacts/catalog_test.go new file mode 100644 index 000000000..0773bac2b --- /dev/null +++ b/internal/personfacts/catalog_test.go @@ -0,0 +1,171 @@ +package personfacts + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestBuildCatalogFiltersIneligibleAttributeDefinitions(t *testing.T) { + definitions := []Definition{ + {UniversalID: "eligible", Slug: "eligible", Description: "Eligible field", ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "inactive", Slug: "inactive", Description: "Inactive field", ValueType: ValueText, Cardinality: CardinalitySingle, APIMutable: true}, + {UniversalID: "immutable", Slug: "immutable", Description: "Immutable field", ValueType: ValueText, Cardinality: CardinalitySingle, Active: true}, + {UniversalID: "derived", Slug: "derived", Description: "Derived field", ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true, Derived: true}, + {UniversalID: "blank", Slug: "blank", Description: " \t", ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "long", Slug: "long", Description: strings.Repeat("🙂", 281), ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "json", Slug: "json", Description: "JSON field", ValueType: "json", Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "reference", Slug: "reference", Description: "Reference field", ValueType: "record_reference", Cardinality: CardinalitySingle, RecordTarget: "person", Active: true, APIMutable: true}, + {UniversalID: "organization", Slug: "organization", Description: "Structured only", ValueType: ValueOrganization, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "partial_date", Slug: "partial_date", Description: "Structured only", ValueType: ValuePartialDate, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + } + + catalog, err := BuildCatalog(definitions, CatalogOptions{}) + require.NoError(t, err) + require.Len(t, catalog.Targets, 2) + assert.Equal(t, []string{"eligible", "system:employment"}, []string{ + catalog.Targets[0].Key, + catalog.Targets[1].Key, + }) +} + +func TestBuildCatalogIncludesSupportedGenericTypes(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + definitions := []Definition{ + {UniversalID: "text", Slug: "text", Description: "Text", ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "integer", Slug: "integer", Description: "Integer", ValueType: ValueInteger, Cardinality: CardinalityMulti, Active: true, APIMutable: true}, + {UniversalID: "real", Slug: "real", Description: "Real", ValueType: ValueReal, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "boolean", Slug: "boolean", Description: "Boolean", ValueType: ValueBoolean, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "date", Slug: "date", Description: "Date", ValueType: ValueDate, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + {UniversalID: "timestamp", Slug: "timestamp", Description: "Timestamp", ValueType: ValueTimestamp, Cardinality: CardinalitySingle, Active: true, APIMutable: true}, + } + + catalog, err := BuildCatalog(definitions, CatalogOptions{}) + require.NoError(err) + require.Len(catalog.Targets, 7) + assert.Equal([]ValueType{ValueBoolean, ValueDate, ValueInteger, ValueReal, ValueText, ValueTimestamp}, []ValueType{ + catalog.Targets[0].ValueType, + catalog.Targets[1].ValueType, + catalog.Targets[2].ValueType, + catalog.Targets[3].ValueType, + catalog.Targets[4].ValueType, + catalog.Targets[5].ValueType, + }) + assert.Equal(TargetEmployment, catalog.Targets[6].Kind) +} + +func TestBuildCatalogCarriesChoiceValuesAndLabels(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + definitions := []Definition{{ + UniversalID: "status", Slug: "status", Description: "Relationship status", + ValueType: ValueText, Cardinality: CardinalitySingle, Active: true, APIMutable: true, + Choices: []ChoiceDescriptor{{Value: "z", Label: "Zulu"}, {Value: "a", Label: "Alpha"}}, + }} + + catalog, err := BuildCatalog(definitions, CatalogOptions{}) + require.NoError(err) + require.Len(catalog.Targets, 2) + assert.Equal([]ChoiceDescriptor{{Value: "a", Label: "Alpha"}, {Value: "z", Label: "Zulu"}}, catalog.Targets[0].Choices) + assert.Equal([]ChoiceDescriptor{{Value: "z", Label: "Zulu"}, {Value: "a", Label: "Alpha"}}, definitions[0].Choices) +} + +func TestDescriptorRevisionUsesInferenceContentOnly(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + target := TargetDescriptor{ + Kind: TargetAttribute, Key: "preferred_language", UniversalID: "preferred_language", + Slug: "preferred_language", Description: "Preferred language", ValueType: ValueText, + Cardinality: CardinalitySingle, Choices: []ChoiceDescriptor{{Value: "fr", Label: "French"}}, + } + + first, err := DescriptorRevision(target) + require.NoError(err) + target.Revision = "previous-api-revision" + second, err := DescriptorRevision(target) + require.NoError(err) + assert.Equal(first, second) + + target.Description = "Preferred language for communication" + third, err := DescriptorRevision(target) + require.NoError(err) + assert.NotEqual(first, third) +} + +func TestBuildCatalogMaxLengthChangesDescriptorAndCatalogIdentity(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + definition := Definition{ + UniversalID: "biography", Slug: "biography", Description: "Short biography", + ValueType: ValueText, Cardinality: CardinalitySingle, MaxLength: 12, + Active: true, APIMutable: true, + } + + first, err := BuildCatalog([]Definition{definition}, CatalogOptions{}) + require.NoError(err) + require.Len(first.Targets, 2) + assert.Equal(12, first.Targets[0].MaxLength) + + definition.MaxLength = 24 + second, err := BuildCatalog([]Definition{definition}, CatalogOptions{}) + require.NoError(err) + require.Len(second.Targets, 2) + assert.NotEqual(first.Targets[0].Revision, second.Targets[0].Revision) + assert.NotEqual(first.Fingerprint, second.Fingerprint) +} + +func TestBuildCatalogEmploymentDescriptor(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + catalog, err := BuildCatalog(nil, CatalogOptions{}) + require.NoError(err) + require.Len(catalog.Targets, 1) + + employment := catalog.Targets[0] + employment.Revision = "" + assert.Equal(TargetDescriptor{ + Kind: TargetEmployment, + Key: "system:employment", + UniversalID: "system:employment", + Slug: "employment", + Description: "Current and historical employment, including organization, title, role, department, location, and partial start and end dates", + ValueType: ValueEmployment, + Cardinality: CardinalityMulti, + Fields: []FieldDescriptor{ + {Name: "organization", ValueType: ValueOrganization, Cardinality: CardinalitySingle, Required: true}, + {Name: "title", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "role", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "department", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "location", ValueType: ValueText, Cardinality: CardinalitySingle}, + {Name: "start_date", ValueType: ValuePartialDate, Cardinality: CardinalitySingle}, + {Name: "end_date", ValueType: ValuePartialDate, Cardinality: CardinalitySingle}, + }, + }, employment) + assert.NotEmpty(catalog.Targets[0].Revision) +} + +func TestBuildCatalogSensitiveOption(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + definitions := []Definition{{ + UniversalID: "sensitive", Slug: "sensitive", Description: "Sensitive detail", + ValueType: ValueText, Cardinality: CardinalitySingle, Sensitive: true, Active: true, APIMutable: true, + }} + + withoutSensitive, err := BuildCatalog(definitions, CatalogOptions{}) + require.NoError(err) + assert.Len(withoutSensitive.Targets, 1) + + withSensitive, err := BuildCatalog(definitions, CatalogOptions{IncludeSensitive: true}) + require.NoError(err) + require.Len(withSensitive.Targets, 2) + assert.True(withSensitive.Targets[0].Sensitive) +} diff --git a/internal/personfacts/domain.go b/internal/personfacts/domain.go new file mode 100644 index 000000000..3a4039936 --- /dev/null +++ b/internal/personfacts/domain.go @@ -0,0 +1,92 @@ +package personfacts + +import ( + "errors" + "fmt" + "net" + "net/url" + "strings" + + "golang.org/x/net/idna" +) + +// NormalizeDomain reduces a domain, URL, or email address to a lowercase +// ASCII host suitable for deterministic organization matching. +func NormalizeDomain(raw string) (string, error) { + candidate := strings.TrimSpace(raw) + if candidate == "" { + return "", errors.New("domain is required") + } + + if !strings.Contains(candidate, "://") { + if at := strings.LastIndex(candidate, "@"); at >= 0 { + if at == 0 || at == len(candidate)-1 { + return "", errors.New("email must contain a local part and host") + } + candidate = candidate[at+1:] + } + } + + host, err := extractDomainHost(candidate) + if err != nil { + return "", err + } + host = strings.TrimSuffix(host, ".") + ascii, err := idna.Lookup.ToASCII(host) + if err != nil { + return "", fmt.Errorf("convert domain to IDNA ASCII: %w", err) + } + ascii = strings.ToLower(strings.TrimSuffix(ascii, ".")) + if withoutWWW, ok := strings.CutPrefix(ascii, "www."); ok { + ascii = withoutWWW + } + if err := validateDomainHost(ascii); err != nil { + return "", err + } + return ascii, nil +} + +func extractDomainHost(candidate string) (string, error) { + if strings.Contains(candidate, "://") { + parsed, err := url.Parse(candidate) + if err != nil { + return "", fmt.Errorf("parse domain URL: %w", err) + } + if parsed.Scheme == "" || parsed.Hostname() == "" { + return "", errors.New("URL must contain a scheme and host") + } + return parsed.Hostname(), nil + } + if strings.ContainsAny(candidate, "/?#") { + return "", errors.New("bare domain must not contain a path, query, or fragment") + } + parsed, err := url.Parse("//" + candidate) + if err != nil { + return "", fmt.Errorf("parse bare domain: %w", err) + } + if parsed.Hostname() == "" { + return "", errors.New("domain host is required") + } + return parsed.Hostname(), nil +} + +func validateDomainHost(host string) error { + if host == "" || len(host) > 253 || !strings.Contains(host, ".") { + return errors.New("domain must contain at least two labels") + } + if net.ParseIP(host) != nil { + return errors.New("domain must not be an IP address") + } + for label := range strings.SplitSeq(host, ".") { + if label == "" || len(label) > 63 || label[0] == '-' || label[len(label)-1] == '-' { + return errors.New("domain contains an invalid label") + } + for _, character := range label { + if (character < 'a' || character > 'z') && + (character < '0' || character > '9') && character != '-' { + return errors.New("domain contains an invalid character") + } + } + } + return nil +} diff --git a/internal/personfacts/domain_test.go b/internal/personfacts/domain_test.go new file mode 100644 index 000000000..bc1becaa1 --- /dev/null +++ b/internal/personfacts/domain_test.go @@ -0,0 +1,39 @@ +package personfacts + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeDomain(t *testing.T) { + tests := []struct { + name string + raw string + want string + }{ + {name: "bare host", raw: " WWW.Example.COM. ", want: "example.com"}, + {name: "https URL", raw: "https://www.Example.com:443/careers?q=go", want: "example.com"}, + {name: "URL query containing email", raw: "https://www.Example.com/search?q=user@other.example", want: "example.com"}, + {name: "URL userinfo", raw: "https://user:secret@www.Example.com/path", want: "example.com"}, + {name: "email", raw: "Person@Example.COM", want: "example.com"}, + {name: "unicode URL host", raw: "https://www.bücher.example/jobs", want: "xn--bcher-kva.example"}, + {name: "unicode email host", raw: "person@BÜCHER.example", want: "xn--bcher-kva.example"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := NormalizeDomain(test.raw) + require.NoError(t, err) + assert.Equal(t, test.want, got) + }) + } + + for _, raw := range []string{"", "localhost", "example.com/path", "https://", "user@", "exa mple.com", "http://[::1"} { + t.Run("invalid "+raw, func(t *testing.T) { + got, err := NormalizeDomain(raw) + require.Error(t, err) + assert.Empty(t, got) + }) + } +} diff --git a/internal/personfacts/normalize.go b/internal/personfacts/normalize.go new file mode 100644 index 000000000..37508d1cc --- /dev/null +++ b/internal/personfacts/normalize.go @@ -0,0 +1,445 @@ +package personfacts + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "math/big" + "strconv" + "strings" + "time" + "unicode/utf8" + + "go.kenn.io/msgvault/internal/jsonexact" +) + +// NormalizeClaimValue validates a submitted value against its target and +// returns canonical JSON. Submitted-data defects are durable failures, not Go +// errors; errors are reserved for internal encoding failures. +func NormalizeClaimValue( + target TargetDescriptor, submitted json.RawMessage, +) (*NormalizedValue, *ValidationFailure, error) { + malformed := func(detail string) (*NormalizedValue, *ValidationFailure, error) { + return nil, &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonMalformedValue, Detail: detail, + }, nil + } + unsupported := func(detail string) (*NormalizedValue, *ValidationFailure, error) { + return nil, &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonUnsupportedTarget, Detail: detail, + }, nil + } + + var canonical []byte + var err error + switch { + case target.Kind == TargetEmployment && target.ValueType == ValueEmployment: + canonical, err = normalizeEmploymentValue(submitted) + case target.Kind == TargetAttribute && supportedGenericValueType(target.ValueType): + canonical, err = normalizeGenericValue(target.ValueType, submitted) + default: + return unsupported(fmt.Sprintf( + "target %s/%s uses unsupported value type %s", target.Kind, target.Key, target.ValueType)) + } + if err != nil { + return malformed(err.Error()) + } + if target.ValueType == ValueText && target.MaxLength > 0 { + text, err := canonicalScalarString(canonical) + if err != nil { + return malformed(fmt.Sprintf("text value: %v", err)) + } + if utf8.RuneCountInString(text) > target.MaxLength { + return malformed(fmt.Sprintf("text value exceeds max_length %d", target.MaxLength)) + } + } + + if len(target.Choices) > 0 { + choice, err := canonicalScalarString(canonical) + if err != nil { + return malformed(fmt.Sprintf("choice value: %v", err)) + } + matched := false + for _, option := range target.Choices { + if choice == option.Value { + matched = true + break + } + } + if !matched { + return malformed(fmt.Sprintf("value %q is not one of the target choices", choice)) + } + } + + return &NormalizedValue{ + JSON: append(json.RawMessage(nil), canonical...), Fingerprint: fingerprint(canonical), + }, nil, nil +} + +func supportedGenericValueType(valueType ValueType) bool { + switch valueType { + case ValueText, ValueInteger, ValueReal, ValueBoolean, ValueDate, ValueTimestamp: + return true + default: + return false + } +} + +func normalizeGenericValue(valueType ValueType, submitted json.RawMessage) ([]byte, error) { + switch valueType { + case ValueText: + var value string + if err := decodeJSONValue(submitted, &value); err != nil { + return nil, fmt.Errorf("decode text: %w", err) + } + value = strings.TrimSpace(value) + if value == "" { + return nil, errors.New("text value must not be blank") + } + return json.Marshal(value) + case ValueInteger: + var value json.Number + if err := decodeJSONNumber(submitted, &value); err != nil { + return nil, fmt.Errorf("decode integer: %w", err) + } + integer, err := semanticInteger(value.String()) + if err != nil { + return nil, err + } + return []byte(strconv.FormatInt(integer, 10)), nil + case ValueReal: + var value json.Number + if err := decodeJSONNumber(submitted, &value); err != nil { + return nil, fmt.Errorf("decode real: %w", err) + } + return canonicalNumber(value.String()) + case ValueBoolean: + var value bool + if err := decodeJSONValue(submitted, &value); err != nil { + return nil, fmt.Errorf("decode boolean: %w", err) + } + return json.Marshal(value) + case ValueDate: + var value string + if err := decodeJSONValue(submitted, &value); err != nil { + return nil, fmt.Errorf("decode date: %w", err) + } + value = strings.TrimSpace(value) + parsed, err := time.Parse("2006-01-02", value) + if err != nil || parsed.Format("2006-01-02") != value { + return nil, fmt.Errorf("date %q must be a YYYY-MM-DD calendar date", value) + } + return json.Marshal(value) + case ValueTimestamp: + var value string + if err := decodeJSONValue(submitted, &value); err != nil { + return nil, fmt.Errorf("decode timestamp: %w", err) + } + parsed, err := time.Parse(time.RFC3339Nano, strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("timestamp must be RFC3339 with a timezone: %w", err) + } + return json.Marshal(parsed.UTC().Format(time.RFC3339Nano)) + default: + return nil, fmt.Errorf("unsupported generic value type %s", valueType) + } +} + +func normalizeEmploymentValue(submitted json.RawMessage) ([]byte, error) { + if err := jsonexact.Validate(submitted, EmploymentValue{}); err != nil { + return nil, fmt.Errorf("validate employment fields: %w", err) + } + var value EmploymentValue + if err := decodeJSONValue(submitted, &value); err != nil { + return nil, fmt.Errorf("decode employment: %w", err) + } + if value.Organization.ID != nil && *value.Organization.ID <= 0 { + return nil, errors.New("employment organization id must be positive") + } + value.Organization.Name = normalizeHumanText(value.Organization.Name) + if value.Organization.Name == "" { + return nil, errors.New("employment organization name is required") + } + if value.Organization.Domain != "" { + domain, err := NormalizeDomain(value.Organization.Domain) + if err != nil { + return nil, fmt.Errorf("normalize employment organization domain: %w", err) + } + value.Organization.Domain = domain + } + value.Title = normalizeHumanText(value.Title) + value.Role = normalizeHumanText(value.Role) + value.Department = normalizeHumanText(value.Department) + value.Location = normalizeHumanText(value.Location) + if err := validatePartialDate("start_date", value.StartDate); err != nil { + return nil, err + } + if err := validatePartialDate("end_date", value.EndDate); err != nil { + return nil, err + } + if partialDateAfterAtSharedPrecision(value.StartDate, value.EndDate) { + return nil, errors.New("employment start_date must not be after end_date at their shared precision") + } + return json.Marshal(value) +} + +func partialDateAfterAtSharedPrecision(start, end *PartialDateValue) bool { + if start == nil || end == nil || start.Year != end.Year { + return start != nil && end != nil && start.Year > end.Year + } + if start.Month == 0 || end.Month == 0 || start.Month != end.Month { + return start.Month != 0 && end.Month != 0 && start.Month > end.Month + } + return start.Day != 0 && end.Day != 0 && start.Day > end.Day +} + +func normalizeHumanText(value string) string { + return strings.Join(strings.Fields(value), " ") +} + +func validatePartialDate(name string, value *PartialDateValue) error { + if value == nil { + return nil + } + if value.Year < 1 || value.Year > 9999 { + return fmt.Errorf("%s year must be between 1 and 9999", name) + } + if value.Month == 0 { + if value.Day != 0 { + return fmt.Errorf("%s day requires a month", name) + } + return nil + } + if value.Month < 1 || value.Month > 12 { + return fmt.Errorf("%s month must be between 1 and 12", name) + } + if value.Day == 0 { + return nil + } + if value.Day < 1 || value.Day > 31 { + return fmt.Errorf("%s day is outside the calendar range", name) + } + parsed := time.Date(value.Year, time.Month(value.Month), value.Day, 0, 0, 0, 0, time.UTC) + if parsed.Year() != value.Year || int(parsed.Month()) != value.Month || parsed.Day() != value.Day { + return fmt.Errorf("%s is not a valid calendar date", name) + } + return nil +} + +func decodeJSONValue(data []byte, destination any) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(destination); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + return nil +} + +func decodeJSONNumber(data []byte, destination *json.Number) error { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return errors.New("multiple JSON values") + } + return err + } + number, ok := value.(json.Number) + if !ok { + return errors.New("value must be a JSON number") + } + *destination = number + return nil +} + +func semanticInteger(raw string) (int64, error) { + negative := strings.HasPrefix(raw, "-") + unsigned := strings.TrimPrefix(raw, "-") + mantissa := unsigned + exponentText := "" + if index := strings.IndexAny(unsigned, "eE"); index >= 0 { + mantissa = unsigned[:index] + exponentText = unsigned[index+1:] + } + _, fraction, hasFraction := strings.Cut(mantissa, ".") + digits := strings.ReplaceAll(mantissa, ".", "") + digits = strings.TrimLeft(digits, "0") + if digits == "" { + return 0, nil + } + coefficient := strings.TrimRight(digits, "0") + trailingZeroes := len(digits) - len(coefficient) + + var exponent int64 + if exponentText != "" { + var err error + exponent, err = strconv.ParseInt(exponentText, 10, 64) + if err != nil { + if strings.HasPrefix(exponentText, "-") { + return 0, errors.New("integer value must have no fractional component") + } + return 0, errors.New("integer value is outside int64 range") + } + } + adjustment := int64(trailingZeroes) + if hasFraction { + adjustment -= int64(len(fraction)) + } + if adjustment > 0 && exponent > math.MaxInt64-adjustment { + return 0, errors.New("integer value is outside int64 range") + } + if adjustment < 0 && exponent < math.MinInt64-adjustment { + return 0, errors.New("integer value must have no fractional component") + } + scale := exponent + adjustment + if scale < 0 { + return 0, errors.New("integer value must have no fractional component") + } + if scale > int64(19-len(coefficient)) { + return 0, errors.New("integer value is outside int64 range") + } + expanded := coefficient + strings.Repeat("0", int(scale)) + if negative { + expanded = "-" + expanded + } + value, err := strconv.ParseInt(expanded, 10, 64) + if err != nil { + return 0, errors.New("integer value is outside int64 range") + } + return value, nil +} + +func canonicalNumber(raw string) ([]byte, error) { + value, err := strconv.ParseFloat(raw, 64) + if err != nil || math.IsInf(value, 0) || math.IsNaN(value) { + return nil, errors.New("real value must be a finite JSON number") + } + if value == 0 { + value = 0 + } + return []byte(strconv.FormatFloat(value, 'g', -1, 64)), nil +} + +func canonicalScalarString(data []byte) (string, error) { + var value any + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + if err := decoder.Decode(&value); err != nil { + return "", err + } + switch value := value.(type) { + case string: + return value, nil + case json.Number: + return value.String(), nil + case bool: + return strconv.FormatBool(value), nil + default: + return "", errors.New("choice must normalize to a scalar value") + } +} + +func canonicalizeRawJSON(data []byte) ([]byte, error) { + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.UseNumber() + var value any + if err := decoder.Decode(&value); err != nil { + return nil, err + } + if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) { + if err == nil { + return nil, errors.New("multiple JSON values") + } + return nil, err + } + normalized, err := normalizeJSONNumbers(value) + if err != nil { + return nil, err + } + return json.Marshal(normalized) +} + +func normalizeJSONNumbers(value any) (any, error) { + switch value := value.(type) { + case json.Number: + canonical, err := canonicalSubmittedJSONNumber(value.String()) + if err != nil { + return nil, err + } + return json.Number(canonical), nil + case []any: + for i := range value { + normalized, err := normalizeJSONNumbers(value[i]) + if err != nil { + return nil, err + } + value[i] = normalized + } + return value, nil + case map[string]any: + for key := range value { + normalized, err := normalizeJSONNumbers(value[key]) + if err != nil { + return nil, err + } + value[key] = normalized + } + return value, nil + default: + return value, nil + } +} + +func canonicalSubmittedJSONNumber(raw string) (string, error) { + negative := strings.HasPrefix(raw, "-") + unsigned := strings.TrimPrefix(raw, "-") + + mantissa := unsigned + exponent := new(big.Int) + if index := strings.IndexAny(unsigned, "eE"); index >= 0 { + mantissa = unsigned[:index] + if _, ok := exponent.SetString(unsigned[index+1:], 10); !ok { + return "", errors.New("invalid submitted JSON number exponent") + } + } + + integer, fraction, hasFraction := strings.Cut(mantissa, ".") + digits := integer + if hasFraction { + digits += fraction + } + digits = strings.TrimLeft(digits, "0") + if digits == "" { + return "0", nil + } + + coefficient := strings.TrimRight(digits, "0") + trailingZeroes := len(digits) - len(coefficient) + exponent.Sub(exponent, big.NewInt(int64(len(fraction)))) + exponent.Add(exponent, big.NewInt(int64(trailingZeroes))) + if negative { + coefficient = "-" + coefficient + } + if exponent.Sign() == 0 { + return coefficient, nil + } + return coefficient + "e" + exponent.String(), nil +} + +func fingerprint(data []byte) string { + return fmt.Sprintf("sha256:%x", sha256.Sum256(data)) +} diff --git a/internal/personfacts/normalize_test.go b/internal/personfacts/normalize_test.go new file mode 100644 index 000000000..1ae795421 --- /dev/null +++ b/internal/personfacts/normalize_test.go @@ -0,0 +1,350 @@ +package personfacts + +import ( + "bytes" + "encoding/json" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestNormalizeClaimValueSupportedGenericTypes(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + tests := []struct { + name string + valueType ValueType + choices []ChoiceDescriptor + submitted string + want string + }{ + {name: "text trims surrounding whitespace", valueType: ValueText, submitted: `" Alice "`, want: `"Alice"`}, + {name: "integer", valueType: ValueInteger, submitted: `42`, want: `42`}, + {name: "integer accepts semantic exponent form", valueType: ValueInteger, submitted: `4.2e1`, want: `42`}, + {name: "real canonicalizes semantic numeric form", valueType: ValueReal, submitted: `1.2500e1`, want: `12.5`}, + {name: "boolean", valueType: ValueBoolean, submitted: `true`, want: `true`}, + {name: "full calendar date", valueType: ValueDate, submitted: `"2024-02-29"`, want: `"2024-02-29"`}, + {name: "timestamp converts to UTC", valueType: ValueTimestamp, submitted: `"2024-01-02T03:04:05-05:00"`, want: `"2024-01-02T08:04:05Z"`}, + {name: "closed choice", valueType: ValueText, choices: []ChoiceDescriptor{{Value: "active", Label: "Active"}}, submitted: `"active"`, want: `"active"`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + target := testAttributeTarget(test.valueType) + target.Choices = test.choices + normalized, failure, err := NormalizeClaimValue(target, json.RawMessage(test.submitted)) + require.NoError(err) + require.Nil(failure) + require.NotNil(normalized) + assert.JSONEq(test.want, string(normalized.JSON)) + assert.NotEmpty(normalized.Fingerprint) + }) + } + + integerA, failure, err := NormalizeClaimValue(testAttributeTarget(ValueInteger), json.RawMessage(`42`)) + requirements.NoError(err) + requirements.Nil(failure) + realA, failure, err := NormalizeClaimValue(testAttributeTarget(ValueReal), json.RawMessage(`42.0`)) + requirements.NoError(err) + requirements.Nil(failure) + realB, failure, err := NormalizeClaimValue(testAttributeTarget(ValueReal), json.RawMessage(`4.2e1`)) + requirements.NoError(err) + requirements.Nil(failure) + assertions.Equal(integerA.Fingerprint, realA.Fingerprint) + assertions.Equal(realA.Fingerprint, realB.Fingerprint) +} + +func TestNormalizeClaimValueRejectsUnsupportedAndUnknownFields(t *testing.T) { + tests := []struct { + name string + target TargetDescriptor + submitted string + reason DecisionReason + }{ + {name: "unsupported generic type", target: testAttributeTarget(ValueOrganization), submitted: `{"name":"Example"}`, reason: ReasonUnsupportedTarget}, + {name: "integer rejects fractional form", target: testAttributeTarget(ValueInteger), submitted: `1.5`, reason: ReasonMalformedValue}, + {name: "invalid calendar date", target: testAttributeTarget(ValueDate), submitted: `"2023-02-29"`, reason: ReasonMalformedValue}, + {name: "timestamp requires zone", target: testAttributeTarget(ValueTimestamp), submitted: `"2024-01-02T03:04:05"`, reason: ReasonMalformedValue}, + {name: "choice is closed", target: choiceTarget(), submitted: `"unknown"`, reason: ReasonMalformedValue}, + {name: "employment unknown field", target: testEmploymentTarget(), submitted: `{"organization":{"name":"Example"},"titel":"Engineer"}`, reason: ReasonMalformedValue}, + {name: "employment case variant field", target: testEmploymentTarget(), submitted: `{"Organization":{"name":"Example"}}`, reason: ReasonMalformedValue}, + {name: "partial date day requires month", target: testEmploymentTarget(), submitted: `{"organization":{"name":"Example"},"start_date":{"year":2024,"day":1}}`, reason: ReasonMalformedValue}, + {name: "partial date rejects invalid day", target: testEmploymentTarget(), submitted: `{"organization":{"name":"Example"},"start_date":{"year":2023,"month":2,"day":29}}`, reason: ReasonMalformedValue}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + normalized, failure, err := NormalizeClaimValue(test.target, json.RawMessage(test.submitted)) + require.NoError(err) + assert.Nil(normalized) + require.NotNil(failure) + assert.Equal(DecisionInvalid, failure.Action) + assert.Equal(test.reason, failure.Reason) + assert.NotEmpty(failure.Detail) + }) + } +} + +func TestNormalizeClaimValueRejectsOversizedIntegerExponentAsOutOfRange(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + normalized, failure, err := NormalizeClaimValue( + testAttributeTarget(ValueInteger), json.RawMessage(`1e1000000000`)) + require.NoError(err) + assert.Nil(normalized) + require.NotNil(failure) + assert.Equal(ReasonMalformedValue, failure.Reason) + assert.Contains(failure.Detail, "outside int64 range") +} + +func TestNormalizeClaimValueRejectsTextOverTargetMaxLength(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + target := testAttributeTarget(ValueText) + target.MaxLength = 2 + + normalized, failure, err := NormalizeClaimValue(target, json.RawMessage(`"🙂🙂🙂"`)) + require.NoError(err) + assert.Nil(normalized) + require.NotNil(failure) + assert.Equal(DecisionInvalid, failure.Action) + assert.Equal(ReasonMalformedValue, failure.Reason) + assert.Contains(failure.Detail, "max_length 2") +} + +func TestNormalizeClaimValueRetainsSubmittedJSONOnFailure(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + input := validGenerationInput() + input.Claims = []ProposedClaim{{ + Target: testAttributeTarget(ValueInteger), Relation: RelationSupport, + SubmittedValue: json.RawMessage(`{"not":"an integer"`), Origin: OriginExtraction, + Confidence: ConfidenceInputs{ReportedScore: 700}, + }} + + prepared, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + claims := prepared.Claims() + require.Len(claims, 1) + assert.True(bytes.Equal(json.RawMessage(`{"not":"an integer"`), claims[0].SubmittedValue), + "malformed submitted bytes changed") + assert.NotEmpty(claims[0].SubmittedFingerprint) + assert.Nil(claims[0].Normalized) + require.NotNil(claims[0].Failure) + assert.Equal(ReasonMalformedValue, claims[0].Failure.Reason) +} + +func TestCanonicalizeRawJSONNumbersLosslessly(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "deterministic nested value", + input: `{"z":1.2300e+2,"a":[9007199254740993,0.00100,-0.0]}`, + want: `{"a":[9007199254740993,1e-3,0],"z":123}`, + }, + { + name: "very large positive exponent stays compact", + input: `1.00e1000000000000000000000`, + want: `1e1000000000000000000000`, + }, + { + name: "very large negative exponent stays compact", + input: `1e-1000000000000000000000`, + want: `1e-1000000000000000000000`, + }, + { + name: "signed scaled zero is canonical zero", + input: `-0.000e999999999999999999999`, + want: `0`, + }, + { + name: "negative sign stays on nonzero coefficient", + input: `-1.2300e2`, + want: `-123`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + canonical, err := canonicalizeRawJSON(json.RawMessage(test.input)) + require.NoError(t, err) + assert.Equal(t, test.want, string(canonical)) + }) + } +} + +func TestCanonicalizeRawJSONDistinguishesArbitraryPrecisionNumbers(t *testing.T) { + tests := []struct { + name string + left string + right string + }{ + { + name: "adjacent integers above exact float64 range", + left: `9007199254740992`, + right: `9007199254740993`, + }, + { + name: "large decimals differing in final digit", + left: `12345678901234567890.12345678901234567890`, + right: `12345678901234567890.12345678901234567891`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + left, err := canonicalizeRawJSON(json.RawMessage(test.left)) + require.NoError(err) + right, err := canonicalizeRawJSON(json.RawMessage(test.right)) + require.NoError(err) + + assert.NotEqual(string(left), string(right)) + assert.NotEqual(fingerprint(left), fingerprint(right)) + }) + } +} + +func TestCanonicalizeRawJSONUnifiesEquivalentNumbers(t *testing.T) { + tests := []struct { + name string + values []string + want string + }{ + { + name: "large integer exponent forms", + values: []string{ + `9223372036854775808000`, + `9.223372036854775808e21`, + `9223372036854775808e3`, + }, + want: `9223372036854775808e3`, + }, + { + name: "decimal exponent forms", + values: []string{`123.4500`, `1.2345e2`, `12345e-2`}, + want: `12345e-2`, + }, + { + name: "zero forms", + values: []string{`0`, `-0`, `0.000`, `-0e-999999999999999999999`}, + want: `0`, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + for _, value := range test.values { + canonical, err := canonicalizeRawJSON(json.RawMessage(value)) + require.NoError(t, err) + assert.Equal(t, test.want, string(canonical), value) + } + }) + } +} + +func TestCanonicalizeRawJSONRejectsMalformedInput(t *testing.T) { + for _, input := range []string{"", `{"n":1`, `[1,]`, `1 2`} { + t.Run(input, func(t *testing.T) { + _, err := canonicalizeRawJSON(json.RawMessage(input)) + require.Error(t, err) + }) + } +} + +func TestNormalizeEmploymentValueCanonicalizesOrganization(t *testing.T) { + require := require.New(t) + + submitted := json.RawMessage(`{ + "organization":{"id":42,"name":" Example GmbH ","domain":"https://www.bücher.example/jobs"}, + "title":" Staff Engineer ","role":" Platform ","department":" R&D ","location":" Berlin ", + "start_date":{"year":2020,"month":2},"end_date":{"year":2024,"month":2,"day":29} + }`) + normalized, failure, err := NormalizeClaimValue(testEmploymentTarget(), submitted) + require.NoError(err) + require.Nil(failure) + require.NotNil(normalized) + assert.JSONEq(t, `{ + "organization":{"id":42,"name":"Example GmbH","domain":"xn--bcher-kva.example"}, + "title":"Staff Engineer","role":"Platform","department":"R&D","location":"Berlin", + "start_date":{"year":2020,"month":2},"end_date":{"year":2024,"month":2,"day":29} + }`, string(normalized.JSON)) +} + +func TestNormalizeEmploymentValueRejectsReversedPartialDatesAtSharedPrecision(t *testing.T) { + tests := []struct { + name string + submitted string + wantFail bool + }{ + {name: "reversed year", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025},"end_date":{"year":2024}}`, wantFail: true}, + {name: "reversed shared month", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025,"month":7},"end_date":{"year":2025,"month":6}}`, wantFail: true}, + {name: "reversed shared day", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025,"month":6,"day":2},"end_date":{"year":2025,"month":6,"day":1}}`, wantFail: true}, + {name: "end month is more precise", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025},"end_date":{"year":2025,"month":1}}`}, + {name: "start month is more precise", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025,"month":12},"end_date":{"year":2025}}`}, + {name: "end day is more precise", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025,"month":6},"end_date":{"year":2025,"month":6,"day":1}}`}, + {name: "equal date", submitted: `{"organization":{"name":"Example"},"start_date":{"year":2025,"month":6,"day":1},"end_date":{"year":2025,"month":6,"day":1}}`}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + _, failure, err := NormalizeClaimValue(testEmploymentTarget(), json.RawMessage(test.submitted)) + require.NoError(err) + if test.wantFail { + require.NotNil(failure) + assert.Equal(ReasonMalformedValue, failure.Reason) + assert.Contains(failure.Detail, "start_date must not be after end_date") + return + } + assert.Nil(failure) + }) + } +} + +func testAttributeTarget(valueType ValueType) TargetDescriptor { + target := TargetDescriptor{ + Kind: TargetAttribute, Key: "attribute-id", + UniversalID: "attribute-id", Slug: "attribute", Description: "Fixture attribute", + ValueType: valueType, Cardinality: CardinalitySingle, + } + target.Revision = testDescriptorRevision(target) + return target +} + +func choiceTarget() TargetDescriptor { + target := testAttributeTarget(ValueText) + target.Choices = []ChoiceDescriptor{{Value: "active", Label: "Active"}, {Value: "inactive", Label: "Inactive"}} + target.Revision = testDescriptorRevision(target) + return target +} + +func testEmploymentTarget() TargetDescriptor { + target := employmentDescriptor() + target.Revision = testDescriptorRevision(target) + return target +} + +func testDescriptorRevision(target TargetDescriptor) string { + revision, err := DescriptorRevision(target) + if err != nil { + panic(err) + } + return revision +} + +func utcTime(year, day, hour int) time.Time { + return time.Date(year, time.January, day, hour, 0, 0, 0, time.UTC) +} diff --git a/internal/personfacts/prepare.go b/internal/personfacts/prepare.go new file mode 100644 index 000000000..d493cb188 --- /dev/null +++ b/internal/personfacts/prepare.go @@ -0,0 +1,834 @@ +package personfacts + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" +) + +type preparedKeyedEvidence struct { + key string + evidence EvidenceInput +} + +// PreparePersonFactGeneration is the only path from submitted generation data +// to persistence-ready claims. It finishes archive alignment before returning +// and retains deterministic submitted-data failures in the prepared value. +func PreparePersonFactGeneration( + ctx context.Context, input GenerationInput, aligner EvidenceAligner, +) (PreparedGeneration, error) { + canonicalInput := copyGenerationInput(input) + if err := validateGenerationInput(canonicalInput); err != nil { + return PreparedGeneration{}, err + } + canonicalInput.ResolvedAt = portableFactTime(canonicalInput.ResolvedAt) + sortSourceCursors(canonicalInput.SourceCursors) + + statusChanges, err := prepareEvidenceStatusChanges(canonicalInput.EvidenceStatusChanges) + if err != nil { + return PreparedGeneration{}, err + } + claims := make([]PreparedClaim, len(canonicalInput.Claims)) + for i := range canonicalInput.Claims { + claims[i], err = prepareClaim(ctx, canonicalInput.PersonID, canonicalInput.Policy, + canonicalInput.Claims[i], aligner) + if err != nil { + return PreparedGeneration{}, err + } + normalizeProposedClaimTimes(&canonicalInput.Claims[i]) + } + sortPreparedClaims(claims) + if err := rejectDuplicateCanonicalClaims(claims); err != nil { + return PreparedGeneration{}, err + } + + canonicalJSON, err := canonicalGenerationJSON(canonicalInput, claims, statusChanges) + if err != nil { + return PreparedGeneration{}, err + } + return PreparedGeneration{ + canonicalJSON: append([]byte(nil), canonicalJSON...), + generationKey: fingerprint(canonicalJSON), + programFingerprint: canonicalInput.ProgramFingerprint, + input: copyGenerationInput(canonicalInput), + claims: copyPreparedClaims(claims), + evidenceStatusChanges: append([]PreparedEvidenceStatusChange(nil), statusChanges...), + }, nil +} + +func (p PreparedGeneration) CanonicalJSON() []byte { + return append([]byte(nil), p.canonicalJSON...) +} + +func (p PreparedGeneration) GenerationKey() string { return p.generationKey } + +func (p PreparedGeneration) ProgramFingerprint() string { return p.programFingerprint } + +func (p PreparedGeneration) Input() GenerationInput { return copyGenerationInput(p.input) } + +func (p PreparedGeneration) Claims() []PreparedClaim { return copyPreparedClaims(p.claims) } + +func (p PreparedGeneration) EvidenceStatusChanges() []PreparedEvidenceStatusChange { + return append([]PreparedEvidenceStatusChange(nil), p.evidenceStatusChanges...) +} + +func prepareClaim( + ctx context.Context, personID int64, policy PolicyContext, + claim ProposedClaim, aligner EvidenceAligner, +) (PreparedClaim, error) { + prepared := PreparedClaim{ + Target: canonicalTarget(claim.Target), Relation: claim.Relation, + SubmittedValue: append(json.RawMessage(nil), claim.SubmittedValue...), + ValidFrom: copyTimePointer(claim.ValidFrom), ValidUntil: copyTimePointer(claim.ValidUntil), + Origin: claim.Origin, Confidence: claim.Confidence, + } + canonicalSubmitted, err := canonicalizeRawJSON(claim.SubmittedValue) + if err != nil { + prepared.SubmittedFingerprint = fingerprint(claim.SubmittedValue) + } else { + prepared.SubmittedFingerprint = fingerprint(canonicalSubmitted) + } + + prepared.Normalized, prepared.Failure, err = NormalizeClaimValue( + prepared.Target, claim.SubmittedValue) + if err != nil { + return PreparedClaim{}, err + } + vocabularyFailureDetail := invalidClaimVocabularyDetail(claim) + if failure := validateClaimEnvelope(personID, policy, claim); prepared.Failure == nil { + prepared.Failure = failure + } + if prepared.Failure == nil && supportedClaimTarget(prepared.Target) { + revision, revisionErr := DescriptorRevision(prepared.Target) + if revisionErr != nil { + return PreparedClaim{}, revisionErr + } + if revision != prepared.Target.Revision { + prepared.Normalized = nil + prepared.Failure = &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonStaleTargetRevision, + Detail: "claim target descriptor content does not match its revision", + } + } + } + if vocabularyFailureDetail != "" && prepared.Failure != nil && + prepared.Failure.Detail != vocabularyFailureDetail { + prepared.Failure.Detail += "; " + vocabularyFailureDetail + } + if claim.Relation != RelationSupport && claim.Relation != RelationContradict && + claim.Relation != RelationSupersede { + prepared.Relation = RelationInvalid + } + if claim.Origin != OriginExtraction && claim.Origin != OriginEnrichment && claim.Origin != OriginSystem { + prepared.Origin = OriginInvalid + } + prepared.ValidFrom = portableFactTimePointer(prepared.ValidFrom) + prepared.ValidUntil = portableFactTimePointer(prepared.ValidUntil) + + prepared.SubmittedEvidenceFingerprints = make([]string, 0, len(claim.Evidence)) + keyed := make([]preparedKeyedEvidence, 0, len(claim.Evidence)) + for _, submittedEvidence := range claim.Evidence { + submittedFingerprint, fingerprintErr := submittedEvidenceFingerprint(submittedEvidence) + if fingerprintErr != nil { + return PreparedClaim{}, fingerprintErr + } + prepared.SubmittedEvidenceFingerprints = append( + prepared.SubmittedEvidenceFingerprints, submittedFingerprint) + evidence := copyEvidenceInput(submittedEvidence) + if evidence.PersonID != personID { + if prepared.Failure == nil { + prepared.Failure = &ValidationFailure{ + Action: DecisionIdentityRejected, Reason: ReasonIdentityMismatch, + Detail: "evidence person id does not match generation person id", + } + } + continue + } + if validationErr := validateEvidenceInput(evidence); validationErr != nil { + if prepared.Failure == nil { + prepared.Failure = evidenceFailure(validationErr) + } + continue + } + evidence.EventTime = portableFactTime(evidence.EventTime) + evidence.RecordedTime = portableFactTime(evidence.RecordedTime) + if evidence.SourceClass == EvidenceArchive { + if aligner == nil { + return PreparedClaim{}, errors.New("archive evidence aligner is required") + } + result, alignErr := aligner.Align(ctx, copyEvidenceInput(evidence)) + if alignErr != nil { + return PreparedClaim{}, fmt.Errorf("align archive evidence %q: %w", evidence.SourceRef, alignErr) + } + if !result.Accepted { + failure := result.Failure + if failure == nil { + failure = &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonUnalignedEvidence, + Detail: "archive evidence could not be aligned", + } + } + if !validValidationFailure(failure) { + return PreparedClaim{}, errors.New("aligner returned a failure outside the closed vocabulary") + } + if prepared.Failure == nil { + prepared.Failure = copyValidationFailure(failure) + } + continue + } + if result.Failure != nil { + return PreparedClaim{}, errors.New("accepted alignment result must not contain a failure") + } + evidence.SourceVersion = result.SourceVersion + evidence.ContentSHA256 = result.ContentSHA256 + if validationErr := validateEvidenceInput(evidence); validationErr != nil { + return PreparedClaim{}, fmt.Errorf("aligner returned invalid immutable evidence: %w", validationErr) + } + } + key, keyErr := EvidenceKey(evidence) + if keyErr != nil { + return PreparedClaim{}, fmt.Errorf("key validated evidence: %w", keyErr) + } + keyed = append(keyed, preparedKeyedEvidence{key: key, evidence: evidence}) + } + sort.Strings(prepared.SubmittedEvidenceFingerprints) + sort.Slice(keyed, func(i, j int) bool { return keyed[i].key < keyed[j].key }) + for i := 1; i < len(keyed); i++ { + if keyed[i-1].key == keyed[i].key { + return PreparedClaim{}, fmt.Errorf("duplicate evidence key %q in one claim", keyed[i].key) + } + } + prepared.Evidence = make([]EvidenceInput, len(keyed)) + prepared.EvidenceKeys = make([]string, 0, len(keyed)) + for i := range keyed { + prepared.Evidence[i] = copyEvidenceInput(keyed[i].evidence) + if keyed[i].key != "" { + prepared.EvidenceKeys = append(prepared.EvidenceKeys, keyed[i].key) + } + } + return prepared, nil +} + +func submittedEvidenceFingerprint(input EvidenceInput) (string, error) { + type identity struct { + PersonID int64 `json:"person_id"` + SourceClass EvidenceSourceClass `json:"source_class"` + Directness EvidenceDirectness `json:"directness"` + Authority EvidenceAuthority `json:"authority"` + SourceRef string `json:"source_ref"` + SourceURL string `json:"source_url"` + SubjectPersonID *int64 `json:"subject_person_id"` + SubjectRef string `json:"subject_ref"` + SpanStart *int64 `json:"span_start"` + SpanEnd *int64 `json:"span_end"` + Excerpt string `json:"excerpt"` + ContentSHA256 string `json:"content_sha256"` + SourceVersion string `json:"source_version"` + EventTime string `json:"event_time"` + RecordedTime string `json:"recorded_time"` + IdentityScore int `json:"identity_score"` + } + encoded, err := json.Marshal(identity{ + PersonID: input.PersonID, SourceClass: input.SourceClass, + Directness: input.Directness, Authority: input.Authority, + SourceRef: input.SourceRef, SourceURL: input.SourceURL, + SubjectPersonID: copyInt64Pointer(input.SubjectPersonID), SubjectRef: input.SubjectRef, + SpanStart: copyInt64Pointer(input.SpanStart), SpanEnd: copyInt64Pointer(input.SpanEnd), + Excerpt: input.Excerpt, ContentSHA256: input.ContentSHA256, + SourceVersion: input.SourceVersion, + EventTime: portableFactTime(input.EventTime).Format(time.RFC3339Nano), + RecordedTime: portableFactTime(input.RecordedTime).Format(time.RFC3339Nano), + IdentityScore: input.IdentityScore, + }) + if err != nil { + return "", fmt.Errorf("encode submitted evidence identity: %w", err) + } + return fingerprint(encoded), nil +} + +func supportedClaimTarget(target TargetDescriptor) bool { + return target.Kind == TargetEmployment && target.ValueType == ValueEmployment || + target.Kind == TargetAttribute && supportedGenericValueType(target.ValueType) +} + +func validateClaimEnvelope( + personID int64, policy PolicyContext, claim ProposedClaim, +) *ValidationFailure { + invalid := func(action DecisionAction, reason DecisionReason, detail string) *ValidationFailure { + return &ValidationFailure{Action: action, Reason: reason, Detail: detail} + } + if personID <= 0 { + return invalid(DecisionInvalid, ReasonMalformedValue, "generation person id must be positive") + } + if claim.Target.Key == "" || claim.Target.Revision == "" { + return invalid(DecisionInvalid, ReasonUnsupportedTarget, "claim target key and revision are required") + } + if detail := invalidClaimVocabularyDetail(claim); detail != "" { + return invalid(DecisionInvalid, ReasonMalformedValue, detail) + } + if claim.Confidence.ReportedScore < 0 || claim.Confidence.ReportedScore > 1000 { + return invalid(DecisionInvalid, ReasonMalformedValue, "reported confidence must be between 0 and 1000") + } + if claim.ValidFrom != nil && (!isUTC(*claim.ValidFrom) || claim.ValidFrom.IsZero()) { + return invalid(DecisionInvalid, ReasonMalformedValue, "valid_from must be nonzero UTC") + } + if claim.ValidUntil != nil && (!isUTC(*claim.ValidUntil) || claim.ValidUntil.IsZero()) { + return invalid(DecisionInvalid, ReasonMalformedValue, "valid_until must be nonzero UTC") + } + if claim.ValidFrom != nil && claim.ValidUntil != nil && claim.ValidUntil.Before(*claim.ValidFrom) { + return invalid(DecisionInvalid, ReasonMalformedValue, "valid_until must not precede valid_from") + } + if claim.Target.Sensitive && !policy.AllowSensitive { + return invalid(DecisionPolicyRejected, ReasonSensitivePolicy, "sensitive target is disabled by policy") + } + return nil +} + +func invalidClaimVocabularyDetail(claim ProposedClaim) string { + details := make([]string, 0, 2) + if claim.Relation != RelationSupport && claim.Relation != RelationContradict && + claim.Relation != RelationSupersede { + details = append(details, fmt.Sprintf("relation %q", claim.Relation)) + } + if claim.Origin != OriginExtraction && claim.Origin != OriginEnrichment && claim.Origin != OriginSystem { + details = append(details, fmt.Sprintf("origin %q", claim.Origin)) + } + if len(details) == 0 { + return "" + } + return "claim " + strings.Join(details, " and ") + " is not in the closed vocabulary" +} + +func validateGenerationInput(input GenerationInput) error { + if input.PersonID <= 0 { + return errors.New("generation person id must be positive") + } + if len(input.SourceCursors) == 0 { + return errors.New("generation requires at least one source cursor") + } + seen := make(map[string]struct{}, len(input.SourceCursors)) + for _, cursor := range input.SourceCursors { + if cursor.Lane == "" || cursor.Start == "" || cursor.End == "" { + return errors.New("source cursor lane, start, and end are required") + } + key := cursor.Lane + "\x00" + cursor.Start + "\x00" + cursor.End + if _, exists := seen[key]; exists { + return fmt.Errorf("duplicate source cursor tuple %q/%q/%q", cursor.Lane, cursor.Start, cursor.End) + } + seen[key] = struct{}{} + } + if input.ProgramID == "" || input.ProgramVersion == "" { + return errors.New("generation program id and version are required") + } + if !lowercaseSHA256Pattern.MatchString(input.ProgramFingerprint) { + return errors.New("generation program fingerprint must be lowercase 64-hex SHA-256") + } + if input.CatalogFingerprint == "" { + return errors.New("generation catalog fingerprint is required") + } + if input.Provider == "" || input.ProviderVersion == "" { + return errors.New("generation provider and provider version are required") + } + if input.ResolvedAt.IsZero() || !isUTC(input.ResolvedAt) { + return errors.New("generation resolution time must be nonzero UTC") + } + return nil +} + +func prepareEvidenceStatusChanges( + changes []EvidenceStatusChange, +) ([]PreparedEvidenceStatusChange, error) { + prepared := make([]PreparedEvidenceStatusChange, len(changes)) + seen := make(map[string]struct{}, len(changes)) + for i, change := range changes { + if change.EvidenceKey == "" { + return nil, errors.New("evidence status key is required") + } + if !validEvidenceStatusTransition(change.Supported, change.Reason) { + return nil, fmt.Errorf("evidence status reason %q is invalid for supported=%t", change.Reason, change.Supported) + } + key := change.EvidenceKey + "\x00" + change.SourceVersion + if _, exists := seen[key]; exists { + return nil, fmt.Errorf("duplicate evidence status tuple for %q at %q", + change.EvidenceKey, change.SourceVersion) + } + seen[key] = struct{}{} + prepared[i] = PreparedEvidenceStatusChange(change) + } + sort.Slice(prepared, func(i, j int) bool { + if prepared[i].EvidenceKey != prepared[j].EvidenceKey { + return prepared[i].EvidenceKey < prepared[j].EvidenceKey + } + if prepared[i].SourceVersion != prepared[j].SourceVersion { + return prepared[i].SourceVersion < prepared[j].SourceVersion + } + return false + }) + return prepared, nil +} + +func validEvidenceStatusTransition(supported bool, reason EvidenceStatusReason) bool { + if supported { + return reason == EvidenceStatusSourceReimported || reason == EvidenceStatusScopeRelinked + } + return reason == EvidenceStatusSourceDeleted || reason == EvidenceStatusSourceEdited || + reason == EvidenceStatusScopeUnlinked || reason == EvidenceStatusIdentityReassigned +} + +// GenerationKey hashes the canonical production envelope. ResolvedAt is +// intentionally excluded and is stored separately with first-writer semantics. +func GenerationKey( + input GenerationInput, claims []PreparedClaim, statusChanges []PreparedEvidenceStatusChange, +) (string, error) { + canonical, err := canonicalGenerationJSON(copyGenerationInput(input), copyPreparedClaims(claims), + append([]PreparedEvidenceStatusChange(nil), statusChanges...)) + if err != nil { + return "", err + } + return fingerprint(canonical), nil +} + +// ClaimKey hashes the canonical claim identity within one generation. +func ClaimKey(generationKey string, prepared PreparedClaim) (string, error) { + if generationKey == "" { + return "", errors.New("generation key is required") + } + view := canonicalClaimView(prepared) + encoded, err := json.Marshal(struct { + GenerationKey string `json:"generation_key"` + Claim canonicalClaim `json:"claim"` + }{GenerationKey: generationKey, Claim: view}) + if err != nil { + return "", fmt.Errorf("encode claim key: %w", err) + } + return fingerprint(encoded), nil +} + +// DecisionKey binds a final decision action to one resolution and claim. +func DecisionKey(resolutionFingerprint, claimKey string, action DecisionAction) (string, error) { + if resolutionFingerprint == "" || claimKey == "" { + return "", errors.New("resolution fingerprint and claim key are required") + } + if !validDecisionAction(action) { + return "", fmt.Errorf("unknown decision action %q", action) + } + encoded, err := json.Marshal(struct { + ResolutionFingerprint string `json:"resolution_fingerprint"` + ClaimKey string `json:"claim_key"` + Action DecisionAction `json:"action"` + }{resolutionFingerprint, claimKey, action}) + if err != nil { + return "", fmt.Errorf("encode decision key: %w", err) + } + return fingerprint(encoded), nil +} + +// ResolutionInputFingerprint hashes a canonical copy of resolver input. Task +// 3 will consume this stable input-side seam when it adds resolver behavior. +func ResolutionInputFingerprint(input ResolutionInput) (string, error) { + cloned := copyResolutionInput(input) + normalizeResolutionInputFactTimes(&cloned) + sort.Slice(cloned.Current, func(i, j int) bool { + if cloned.Current[i].Ref.Kind != cloned.Current[j].Ref.Kind { + return cloned.Current[i].Ref.Kind < cloned.Current[j].Ref.Kind + } + if cloned.Current[i].Ref.RowID != cloned.Current[j].Ref.RowID { + return cloned.Current[i].Ref.RowID < cloned.Current[j].Ref.RowID + } + return cloned.Current[i].Normalized.Fingerprint < cloned.Current[j].Normalized.Fingerprint + }) + for i := range cloned.Claims { + sort.Slice(cloned.Claims[i].Evidence, func(a, b int) bool { + return evidenceSortKey(cloned.Claims[i].Evidence[a]) < evidenceSortKey(cloned.Claims[i].Evidence[b]) + }) + } + sort.Slice(cloned.Claims, func(i, j int) bool { + if cloned.Claims[i].Claim.Target.Key != cloned.Claims[j].Claim.Target.Key { + return cloned.Claims[i].Claim.Target.Key < cloned.Claims[j].Claim.Target.Key + } + if normalizedFingerprint(cloned.Claims[i].Normalized) != normalizedFingerprint(cloned.Claims[j].Normalized) { + return normalizedFingerprint(cloned.Claims[i].Normalized) < normalizedFingerprint(cloned.Claims[j].Normalized) + } + return cloned.Claims[i].ClaimKey < cloned.Claims[j].ClaimKey + }) + encoded, err := json.Marshal(cloned) + if err != nil { + return "", fmt.Errorf("encode resolution input fingerprint: %w", err) + } + return fingerprint(encoded), nil +} + +func normalizeResolutionInputFactTimes(input *ResolutionInput) { + input.ResolvedAt = portableFactTime(input.ResolvedAt) + for i := range input.Current { + input.Current[i].ActiveFrom = portableFactTime(input.Current[i].ActiveFrom) + input.Current[i].ActiveUntil = portableFactTimePointer(input.Current[i].ActiveUntil) + input.Current[i].TransactionTime = portableFactTime(input.Current[i].TransactionTime) + } + for i := range input.Claims { + normalizeProposedClaimTimes(&input.Claims[i].Claim) + for j := range input.Claims[i].Evidence { + input.Claims[i].Evidence[j].Input.EventTime = + portableFactTime(input.Claims[i].Evidence[j].Input.EventTime) + input.Claims[i].Evidence[j].Input.RecordedTime = + portableFactTime(input.Claims[i].Evidence[j].Input.RecordedTime) + } + } +} + +func validDecisionAction(action DecisionAction) bool { + switch action { + case DecisionApplied, DecisionRetained, DecisionSuperseded, DecisionInvalid, + DecisionIdentityRejected, DecisionPolicyRejected, DecisionConflictRejected, + DecisionAmbiguousRetained: + return true + default: + return false + } +} + +func validValidationFailure(failure *ValidationFailure) bool { + if failure == nil || !validDecisionAction(failure.Action) || failure.Detail == "" { + return false + } + switch failure.Reason { + case ReasonMalformedValue, ReasonUnsupportedTarget, ReasonStaleTargetRevision, + ReasonUnalignedEvidence, ReasonIdentityMismatch, ReasonSensitivePolicy, + ReasonPinRetained, ReasonBelowThreshold, ReasonInsufficientMargin, + ReasonCompetingTie, ReasonExplicitContradiction, ReasonExplicitSupersession, + ReasonOrganizationAmbiguous, ReasonAppliedProjection, ReasonEvidenceUnsupported: + return true + default: + return false + } +} + +type canonicalNormalized struct { + JSON json.RawMessage `json:"json"` + Fingerprint string `json:"fingerprint"` +} + +type canonicalFailure struct { + Action DecisionAction `json:"action"` + Reason DecisionReason `json:"reason"` + Detail string `json:"detail"` +} + +type canonicalClaim struct { + Target TargetRef `json:"target"` + Relation ClaimRelation `json:"relation"` + SubmittedFingerprint string `json:"submitted_fingerprint"` + SubmittedEvidenceFingerprints []string `json:"submitted_evidence_fingerprints"` + Normalized *canonicalNormalized `json:"normalized"` + EvidenceKeys []string `json:"evidence_keys"` + ValidFrom *string `json:"valid_from"` + ValidUntil *string `json:"valid_until"` + Origin ClaimOrigin `json:"origin"` + Confidence ConfidenceInputs `json:"confidence"` + Failure *canonicalFailure `json:"failure"` +} + +type canonicalGeneration struct { + PersonID int64 `json:"person_id"` + SourceCursors []SourceCursor `json:"source_cursors"` + ProgramID string `json:"program_id"` + ProgramVersion string `json:"program_version"` + ProgramFingerprint string `json:"program_fingerprint"` + CatalogFingerprint string `json:"catalog_fingerprint"` + Provider string `json:"provider"` + ProviderVersion string `json:"provider_version"` + Model string `json:"model"` + ModelVersion string `json:"model_version"` + Policy PolicyContext `json:"policy"` + Claims []canonicalClaim `json:"claims"` + StatusChanges []canonicalStatus `json:"evidence_status_changes"` +} + +type canonicalStatus struct { + EvidenceKey string `json:"evidence_key"` + SourceVersion string `json:"source_version"` + Supported bool `json:"supported"` + Reason EvidenceStatusReason `json:"reason"` +} + +func canonicalGenerationJSON( + input GenerationInput, claims []PreparedClaim, statusChanges []PreparedEvidenceStatusChange, +) ([]byte, error) { + if err := validateGenerationInput(input); err != nil { + return nil, err + } + sortSourceCursors(input.SourceCursors) + statusInput := make([]EvidenceStatusChange, len(statusChanges)) + for i := range statusChanges { + statusInput[i] = EvidenceStatusChange(statusChanges[i]) + } + canonicalStatuses, err := prepareEvidenceStatusChanges(statusInput) + if err != nil { + return nil, err + } + sortPreparedClaims(claims) + claimViews := make([]canonicalClaim, len(claims)) + for i := range claims { + claimViews[i] = canonicalClaimView(claims[i]) + } + statusViews := make([]canonicalStatus, len(canonicalStatuses)) + for i := range canonicalStatuses { + statusViews[i] = canonicalStatus{ + EvidenceKey: canonicalStatuses[i].EvidenceKey, + SourceVersion: canonicalStatuses[i].SourceVersion, + Supported: canonicalStatuses[i].Supported, + Reason: canonicalStatuses[i].Reason, + } + } + return json.Marshal(canonicalGeneration{ + PersonID: input.PersonID, SourceCursors: input.SourceCursors, + ProgramID: input.ProgramID, ProgramVersion: input.ProgramVersion, + ProgramFingerprint: input.ProgramFingerprint, CatalogFingerprint: input.CatalogFingerprint, + Provider: input.Provider, ProviderVersion: input.ProviderVersion, + Model: input.Model, ModelVersion: input.ModelVersion, Policy: input.Policy, + Claims: claimViews, StatusChanges: statusViews, + }) +} + +func canonicalClaimView(prepared PreparedClaim) canonicalClaim { + submittedEvidenceFingerprints := append( + []string(nil), prepared.SubmittedEvidenceFingerprints...) + sort.Strings(submittedEvidenceFingerprints) + if submittedEvidenceFingerprints == nil { + submittedEvidenceFingerprints = []string{} + } + evidenceKeys := append([]string(nil), prepared.EvidenceKeys...) + sort.Strings(evidenceKeys) + if evidenceKeys == nil { + evidenceKeys = []string{} + } + var normalized *canonicalNormalized + if prepared.Normalized != nil { + normalized = &canonicalNormalized{ + JSON: append(json.RawMessage(nil), prepared.Normalized.JSON...), + Fingerprint: prepared.Normalized.Fingerprint, + } + } + var failure *canonicalFailure + if prepared.Failure != nil { + failure = &canonicalFailure{ + Action: prepared.Failure.Action, Reason: prepared.Failure.Reason, Detail: prepared.Failure.Detail, + } + } + return canonicalClaim{ + Target: TargetRef{Kind: prepared.Target.Kind, Key: prepared.Target.Key, Revision: prepared.Target.Revision}, + Relation: prepared.Relation, SubmittedFingerprint: prepared.SubmittedFingerprint, + SubmittedEvidenceFingerprints: submittedEvidenceFingerprints, + Normalized: normalized, EvidenceKeys: evidenceKeys, + ValidFrom: canonicalTimePointer(prepared.ValidFrom), ValidUntil: canonicalTimePointer(prepared.ValidUntil), + Origin: prepared.Origin, Confidence: prepared.Confidence, Failure: failure, + } +} + +func sortPreparedClaims(claims []PreparedClaim) { + sort.Slice(claims, func(i, j int) bool { + left, _ := json.Marshal(canonicalClaimView(claims[i])) + right, _ := json.Marshal(canonicalClaimView(claims[j])) + return string(left) < string(right) + }) +} + +func rejectDuplicateCanonicalClaims(claims []PreparedClaim) error { + var previous string + for i := range claims { + encoded, err := json.Marshal(canonicalClaimView(claims[i])) + if err != nil { + return fmt.Errorf("encode canonical claim for duplicate validation: %w", err) + } + current := string(encoded) + if i > 0 && current == previous { + return errors.New("duplicate canonical claim in generation") + } + previous = current + } + return nil +} + +func sortSourceCursors(cursors []SourceCursor) { + sort.Slice(cursors, func(i, j int) bool { + if cursors[i].Lane != cursors[j].Lane { + return cursors[i].Lane < cursors[j].Lane + } + if cursors[i].Start != cursors[j].Start { + return cursors[i].Start < cursors[j].Start + } + return cursors[i].End < cursors[j].End + }) +} + +func copyGenerationInput(input GenerationInput) GenerationInput { + cloned := input + cloned.SourceCursors = append([]SourceCursor(nil), input.SourceCursors...) + cloned.Claims = make([]ProposedClaim, len(input.Claims)) + for i := range input.Claims { + cloned.Claims[i] = copyProposedClaim(input.Claims[i]) + } + cloned.EvidenceStatusChanges = append([]EvidenceStatusChange(nil), input.EvidenceStatusChanges...) + return cloned +} + +func copyProposedClaim(input ProposedClaim) ProposedClaim { + cloned := input + cloned.Target = canonicalTarget(input.Target) + cloned.SubmittedValue = append(json.RawMessage(nil), input.SubmittedValue...) + cloned.Evidence = make([]EvidenceInput, len(input.Evidence)) + for i := range input.Evidence { + cloned.Evidence[i] = copyEvidenceInput(input.Evidence[i]) + } + cloned.ValidFrom = copyTimePointer(input.ValidFrom) + cloned.ValidUntil = copyTimePointer(input.ValidUntil) + return cloned +} + +func copyEvidenceInput(input EvidenceInput) EvidenceInput { + cloned := input + cloned.SubjectPersonID = copyInt64Pointer(input.SubjectPersonID) + cloned.SpanStart = copyInt64Pointer(input.SpanStart) + cloned.SpanEnd = copyInt64Pointer(input.SpanEnd) + return cloned +} + +func copyPreparedClaims(input []PreparedClaim) []PreparedClaim { + cloned := make([]PreparedClaim, len(input)) + for i := range input { + cloned[i] = copyPreparedClaim(input[i]) + } + return cloned +} + +func copyPreparedClaim(input PreparedClaim) PreparedClaim { + cloned := input + cloned.Target = canonicalTarget(input.Target) + cloned.SubmittedValue = append(json.RawMessage(nil), input.SubmittedValue...) + cloned.SubmittedEvidenceFingerprints = append( + []string(nil), input.SubmittedEvidenceFingerprints...) + if input.Normalized != nil { + cloned.Normalized = &NormalizedValue{ + JSON: append(json.RawMessage(nil), input.Normalized.JSON...), Fingerprint: input.Normalized.Fingerprint, + } + } + cloned.Evidence = make([]EvidenceInput, len(input.Evidence)) + for i := range input.Evidence { + cloned.Evidence[i] = copyEvidenceInput(input.Evidence[i]) + } + cloned.EvidenceKeys = append([]string(nil), input.EvidenceKeys...) + cloned.ValidFrom = copyTimePointer(input.ValidFrom) + cloned.ValidUntil = copyTimePointer(input.ValidUntil) + cloned.Failure = copyValidationFailure(input.Failure) + return cloned +} + +func copyResolutionInput(input ResolutionInput) ResolutionInput { + cloned := input + cloned.Target = canonicalTarget(input.Target) + cloned.Current = append([]CurrentProjection(nil), input.Current...) + for i := range cloned.Current { + cloned.Current[i].Normalized.JSON = append(json.RawMessage(nil), input.Current[i].Normalized.JSON...) + cloned.Current[i].ActiveUntil = copyTimePointer(input.Current[i].ActiveUntil) + } + cloned.Claims = append([]ResolvedClaim(nil), input.Claims...) + for i := range cloned.Claims { + cloned.Claims[i].Claim = copyProposedClaim(input.Claims[i].Claim) + if input.Claims[i].Normalized != nil { + cloned.Claims[i].Normalized = &NormalizedValue{ + JSON: append(json.RawMessage(nil), input.Claims[i].Normalized.JSON...), + Fingerprint: input.Claims[i].Normalized.Fingerprint, + } + } + cloned.Claims[i].Evidence = append([]Evidence(nil), input.Claims[i].Evidence...) + for j := range cloned.Claims[i].Evidence { + cloned.Claims[i].Evidence[j].Input = copyEvidenceInput(input.Claims[i].Evidence[j].Input) + if input.Claims[i].Evidence[j].LatestStatus != nil { + status := *input.Claims[i].Evidence[j].LatestStatus + cloned.Claims[i].Evidence[j].LatestStatus = &status + } + } + cloned.Claims[i].Failure = copyValidationFailure(input.Claims[i].Failure) + } + cloned.Pin.EventID = copyInt64Pointer(input.Pin.EventID) + return cloned +} + +func copyInt64Pointer(input *int64) *int64 { + if input == nil { + return nil + } + value := *input + return &value +} + +func copyTimePointer(input *time.Time) *time.Time { + if input == nil { + return nil + } + value := *input + return &value +} + +func copyValidationFailure(input *ValidationFailure) *ValidationFailure { + if input == nil { + return nil + } + value := *input + return &value +} + +func canonicalTimePointer(input *time.Time) *string { + if input == nil { + return nil + } + value := portableFactTime(*input).Format(time.RFC3339Nano) + return &value +} + +func normalizeProposedClaimTimes(claim *ProposedClaim) { + claim.ValidFrom = portableFactTimePointer(claim.ValidFrom) + claim.ValidUntil = portableFactTimePointer(claim.ValidUntil) + for i := range claim.Evidence { + claim.Evidence[i].EventTime = portableFactTime(claim.Evidence[i].EventTime) + claim.Evidence[i].RecordedTime = portableFactTime(claim.Evidence[i].RecordedTime) + } +} + +func portableFactTime(input time.Time) time.Time { + return input.UTC().Truncate(time.Microsecond) +} + +func portableFactTimePointer(input *time.Time) *time.Time { + if input == nil { + return nil + } + value := portableFactTime(*input) + return &value +} + +func evidenceSortKey(evidence Evidence) string { + if evidence.Key != "" { + return evidence.Key + } + key, err := EvidenceKey(evidence.Input) + if err == nil { + return key + } + encoded, err := json.Marshal(evidenceKeyView(evidence.Input)) + if err != nil { + return "" + } + return fingerprint(encoded) +} + +func normalizedFingerprint(value *NormalizedValue) string { + if value == nil { + return "" + } + return value.Fingerprint +} diff --git a/internal/personfacts/prepare_test.go b/internal/personfacts/prepare_test.go new file mode 100644 index 000000000..ac4d19061 --- /dev/null +++ b/internal/personfacts/prepare_test.go @@ -0,0 +1,798 @@ +package personfacts + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestGenerationKeyBindsProgramFingerprintClaimsEvidenceAndStatuses(t *testing.T) { + assert := assert.New(t) + + base := validGenerationInput() + base.Claims = []ProposedClaim{validClaim(validArchiveEvidence())} + base.EvidenceStatusChanges = []EvidenceStatusChange{{ + EvidenceKey: "status-evidence", SourceVersion: "v1", Supported: false, + Reason: EvidenceStatusSourceDeleted, + }} + + basePrepared := mustPrepare(t, base) + baseKey := basePrepared.GenerationKey() + assert.NotEmpty(baseKey) + + programChanged := cloneGenerationInput(base) + programChanged.ProgramFingerprint = strings.Repeat("1", 64) + assert.NotEqual(baseKey, mustPrepare(t, programChanged).GenerationKey()) + + claimChanged := cloneGenerationInput(base) + claimChanged.Claims[0].SubmittedValue = json.RawMessage(`"different"`) + assert.NotEqual(baseKey, mustPrepare(t, claimChanged).GenerationKey()) + + evidenceChanged := cloneGenerationInput(base) + evidenceChanged.Claims[0].Evidence[0].Excerpt = "different excerpt" + assert.NotEqual(baseKey, mustPrepare(t, evidenceChanged).GenerationKey()) + + statusChanged := cloneGenerationInput(base) + statusChanged.EvidenceStatusChanges[0].Reason = EvidenceStatusSourceEdited + assert.NotEqual(baseKey, mustPrepare(t, statusChanged).GenerationKey()) + + resolvedAtChanged := cloneGenerationInput(base) + resolvedAtChanged.ResolvedAt = base.ResolvedAt.Add(24 * time.Hour) + assert.Equal(baseKey, mustPrepare(t, resolvedAtChanged).GenerationKey()) +} + +func TestPreparePersonFactGenerationSanitizesInvalidRelationAndOriginWithoutLosingIdentity(t *testing.T) { + tests := []struct { + name string + firstToken string + secondToken string + mutate func(*ProposedClaim, string) + assertSentinel func(*testing.T, PreparedClaim) + }{ + { + name: "relation", firstToken: "agrees", secondToken: "confirms", + mutate: func(claim *ProposedClaim, token string) { + claim.Relation = ClaimRelation(token) + }, + assertSentinel: func(t *testing.T, claim PreparedClaim) { + t.Helper() + assert.Equal(t, RelationInvalid, claim.Relation) + assert.Equal(t, OriginExtraction, claim.Origin) + }, + }, + { + name: "origin", firstToken: "crawler", secondToken: "importer", + mutate: func(claim *ProposedClaim, token string) { + claim.Origin = ClaimOrigin(token) + }, + assertSentinel: func(t *testing.T, claim PreparedClaim) { + t.Helper() + assert.Equal(t, RelationSupport, claim.Relation) + assert.Equal(t, OriginInvalid, claim.Origin) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + prepareWithToken := func(token string) PreparedGeneration { + input := validGenerationInput() + claim := validClaim() + test.mutate(&claim, token) + input.Claims = []ProposedClaim{claim} + prepared, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + return prepared + } + + first := prepareWithToken(test.firstToken) + claim := first.Claims()[0] + test.assertSentinel(t, claim) + require.NotNil(claim.Failure) + assert.Equal(DecisionInvalid, claim.Failure.Action) + assert.Equal(ReasonMalformedValue, claim.Failure.Reason) + assert.Contains(claim.Failure.Detail, test.firstToken) + + second := prepareWithToken(test.secondToken) + assert.NotEqual(first.GenerationKey(), second.GenerationKey()) + }) + } +} + +func TestPreparePersonFactGenerationPreservesValidationFailurePrecedenceOverDescriptorMismatch(t *testing.T) { + tests := []struct { + name string + mutate func(*ProposedClaim) + reason DecisionReason + wantNormalized bool + }{ + { + name: "missing target key", + mutate: func(claim *ProposedClaim) { + claim.Target.Key = "" + }, + reason: ReasonUnsupportedTarget, + wantNormalized: true, + }, + { + name: "missing target revision", + mutate: func(claim *ProposedClaim) { + claim.Target.Revision = "" + }, + reason: ReasonUnsupportedTarget, + wantNormalized: true, + }, + { + name: "malformed value with tampered descriptor", + mutate: func(claim *ProposedClaim) { + claim.Target.MaxLength = 1 + claim.SubmittedValue = json.RawMessage(`{"unterminated"`) + }, + reason: ReasonMalformedValue, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + input := validGenerationInput() + claim := validClaim() + test.mutate(&claim) + input.Claims = []ProposedClaim{claim} + + prepared, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + claims := prepared.Claims() + require.Len(claims, 1) + if test.wantNormalized { + assert.NotNil(claims[0].Normalized) + } else { + assert.Nil(claims[0].Normalized) + } + require.NotNil(claims[0].Failure) + assert.Equal(DecisionInvalid, claims[0].Failure.Action) + assert.Equal(test.reason, claims[0].Failure.Reason) + }) + } +} + +func TestClaimKeyBindsGenerationAndCanonicalClaim(t *testing.T) { + prepared := mustPrepare(t, generationWithTwoEvidence()) + claim := prepared.Claims()[0] + base, err := ClaimKey(prepared.GenerationKey(), claim) + require.NoError(t, err) + + mutations := []struct { + name string + change func(*PreparedClaim) + }{ + {name: "target key", change: func(v *PreparedClaim) { v.Target.Key = "different" }}, + {name: "target revision", change: func(v *PreparedClaim) { v.Target.Revision = "different" }}, + {name: "relation", change: func(v *PreparedClaim) { v.Relation = RelationContradict }}, + {name: "submitted", change: func(v *PreparedClaim) { v.SubmittedFingerprint = "different" }}, + {name: "submitted evidence", change: func(v *PreparedClaim) { + v.SubmittedEvidenceFingerprints[0] = "different" + }}, + {name: "normalized", change: func(v *PreparedClaim) { v.Normalized.Fingerprint = "different" }}, + {name: "valid from", change: func(v *PreparedClaim) { at := utcTime(2021, 1, 0); v.ValidFrom = &at }}, + {name: "valid until", change: func(v *PreparedClaim) { at := utcTime(2026, 1, 0); v.ValidUntil = &at }}, + {name: "origin", change: func(v *PreparedClaim) { v.Origin = OriginEnrichment }}, + {name: "confidence", change: func(v *PreparedClaim) { v.Confidence.ReportedScore++ }}, + {name: "evidence", change: func(v *PreparedClaim) { v.EvidenceKeys[0] = "different" }}, + } + for _, mutation := range mutations { + t.Run(mutation.name, func(t *testing.T) { + changed := clonePreparedClaim(claim) + mutation.change(&changed) + key, err := ClaimKey(prepared.GenerationKey(), changed) + require.NoError(t, err) + assert.NotEqual(t, base, key) + }) + } + + otherGeneration, err := ClaimKey("sha256:"+strings.Repeat("f", 64), claim) + require.NoError(t, err) + assert.NotEqual(t, base, otherGeneration) +} + +func TestDeterministicKeysIncludeEveryIdentityField(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + evidence := validArchiveEvidence() + baseEvidenceKey, err := EvidenceKey(evidence) + requirements.NoError(err) + evidenceMutations := []struct { + name string + change func(*EvidenceInput) + }{ + {name: "person", change: func(v *EvidenceInput) { v.PersonID++; *v.SubjectPersonID = v.PersonID }}, + {name: "source class", change: func(v *EvidenceInput) { v.SourceClass = EvidenceSystem; v.SpanStart = nil; v.SpanEnd = nil }}, + {name: "directness", change: func(v *EvidenceInput) { v.Directness = Indirect }}, + {name: "authority", change: func(v *EvidenceInput) { v.Authority = AuthorityOrdinary }}, + {name: "source ref", change: func(v *EvidenceInput) { v.SourceRef = "message-2" }}, + {name: "source URL", change: func(v *EvidenceInput) { v.SourceURL = "https://example.com/evidence" }}, + {name: "subject ref", change: func(v *EvidenceInput) { v.SubjectRef = "participant-2" }}, + {name: "span", change: func(v *EvidenceInput) { *v.SpanEnd++ }}, + {name: "excerpt", change: func(v *EvidenceInput) { v.Excerpt = "different" }}, + {name: "content hash", change: func(v *EvidenceInput) { v.ContentSHA256 = strings.Repeat("b", 64) }}, + {name: "source version", change: func(v *EvidenceInput) { v.SourceVersion = "v2" }}, + {name: "event time", change: func(v *EvidenceInput) { v.EventTime = v.EventTime.Add(time.Hour) }}, + {name: "recorded time", change: func(v *EvidenceInput) { v.RecordedTime = v.RecordedTime.Add(time.Hour) }}, + {name: "identity score", change: func(v *EvidenceInput) { v.IdentityScore-- }}, + } + for _, mutation := range evidenceMutations { + t.Run("evidence "+mutation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + changed := cloneEvidence(evidence) + mutation.change(&changed) + key, err := EvidenceKey(changed) + require.NoError(err) + assert.NotEqual(baseEvidenceKey, key) + }) + } + + base := validGenerationInput() + base.Claims = []ProposedClaim{validClaim(evidence)} + baseKey := mustPrepare(t, base).GenerationKey() + generationMutations := []struct { + name string + change func(*GenerationInput) + }{ + {name: "person", change: func(v *GenerationInput) { + v.PersonID++ + v.Claims[0].Evidence[0].PersonID++ + *v.Claims[0].Evidence[0].SubjectPersonID = v.PersonID + }}, + {name: "source cursor set", change: func(v *GenerationInput) { + v.SourceCursors = append(v.SourceCursors, SourceCursor{Lane: "backstop", Start: "a", End: "b"}) + }}, + {name: "program ID", change: func(v *GenerationInput) { v.ProgramID += "-2" }}, + {name: "program version", change: func(v *GenerationInput) { v.ProgramVersion += "-2" }}, + {name: "program fingerprint", change: func(v *GenerationInput) { v.ProgramFingerprint = strings.Repeat("1", 64) }}, + {name: "catalog fingerprint", change: func(v *GenerationInput) { v.CatalogFingerprint += "-2" }}, + {name: "policy allow sensitive", change: func(v *GenerationInput) { v.Policy.AllowSensitive = !v.Policy.AllowSensitive }}, + {name: "policy fingerprint", change: func(v *GenerationInput) { v.Policy.ProviderPolicyFingerprint += "-2" }}, + {name: "provider", change: func(v *GenerationInput) { v.Provider += "-2" }}, + {name: "provider version", change: func(v *GenerationInput) { v.ProviderVersion += "-2" }}, + {name: "model", change: func(v *GenerationInput) { v.Model += "-2" }}, + {name: "model version", change: func(v *GenerationInput) { v.ModelVersion += "-2" }}, + } + for _, mutation := range generationMutations { + t.Run("generation "+mutation.name, func(t *testing.T) { + assert := assert.New(t) + + changed := cloneGenerationInput(base) + mutation.change(&changed) + assert.NotEqual(baseKey, mustPrepare(t, changed).GenerationKey()) + }) + } + + decisionA, err := DecisionKey("resolution-a", "claim-a", DecisionApplied) + requirements.NoError(err) + for _, tuple := range []struct { + resolution string + claim string + action DecisionAction + }{{"resolution-b", "claim-a", DecisionApplied}, {"resolution-a", "claim-b", DecisionApplied}, {"resolution-a", "claim-a", DecisionRetained}} { + key, err := DecisionKey(tuple.resolution, tuple.claim, tuple.action) + requirements.NoError(err) + assertions.NotEqual(decisionA, key) + } + + resolution := ResolutionInput{ + PersonID: 7, Target: testAttributeTarget(ValueText), ResolvedAt: utcTime(2025, 4, 12), + Policy: PolicyContext{AllowSensitive: false, ProviderPolicyFingerprint: "policy-v1"}, + ProjectionContextFingerprint: "projection-v1", + } + resolutionA, err := ResolutionInputFingerprint(resolution) + requirements.NoError(err) + resolution.ResolvedAt = resolution.ResolvedAt.Add(time.Second) + resolutionB, err := ResolutionInputFingerprint(resolution) + requirements.NoError(err) + assertions.NotEqual(resolutionA, resolutionB) +} + +func TestDeterministicKeysIgnoreClaimEvidenceAndStatusOrder(t *testing.T) { + first := generationWithTwoEvidence() + second := cloneGenerationInput(first) + second.SourceCursors[0], second.SourceCursors[1] = second.SourceCursors[1], second.SourceCursors[0] + second.Claims[0], second.Claims[1] = second.Claims[1], second.Claims[0] + second.Claims[0].Evidence[0], second.Claims[0].Evidence[1] = second.Claims[0].Evidence[1], second.Claims[0].Evidence[0] + second.EvidenceStatusChanges[0], second.EvidenceStatusChanges[1] = second.EvidenceStatusChanges[1], second.EvidenceStatusChanges[0] + + preparedFirst := mustPrepare(t, first) + preparedSecond := mustPrepare(t, second) + assert.Equal(t, preparedFirst.GenerationKey(), preparedSecond.GenerationKey()) + assert.Equal(t, preparedFirst.CanonicalJSON(), preparedSecond.CanonicalJSON()) + assert.Equal(t, preparedFirst.Claims()[0].EvidenceKeys, preparedSecond.Claims()[0].EvidenceKeys) +} + +func TestPreparePersonFactGenerationDeepCopiesCanonicalInput(t *testing.T) { + assert := assert.New(t) + + input := generationWithTwoEvidence() + prepared := mustPrepare(t, input) + wantJSON := prepared.CanonicalJSON() + wantKey := prepared.GenerationKey() + wantInput := prepared.Input() + wantClaims := prepared.Claims() + wantStatuses := prepared.EvidenceStatusChanges() + + input.SourceCursors[0].Lane = "mutated" + input.Claims[0].SubmittedValue[0] = 'x' + input.Claims[0].Evidence[0].Excerpt = "mutated" + *input.Claims[0].Evidence[0].SubjectPersonID = 99 + *input.Claims[0].ValidFrom = input.Claims[0].ValidFrom.Add(time.Hour) + input.EvidenceStatusChanges[0].EvidenceKey = "mutated" + assert.True(bytes.Equal(wantJSON, prepared.CanonicalJSON()), "canonical JSON changed") + assert.Equal(wantKey, prepared.GenerationKey()) + assert.Equal(wantInput, prepared.Input()) + assert.Equal(wantClaims, prepared.Claims()) + assert.Equal(wantStatuses, prepared.EvidenceStatusChanges()) + + gotInput := prepared.Input() + gotInput.SourceCursors[0].Lane = "accessor-mutated" + gotInput.Claims[0].SubmittedValue[0] = 'y' + gotInput.Claims[0].Evidence[0].Excerpt = "accessor-mutated" + *gotInput.Claims[0].Evidence[0].SubjectPersonID = 100 + gotClaims := prepared.Claims() + gotClaims[0].SubmittedValue[0] = 'z' + gotClaims[0].EvidenceKeys[0] = "accessor-mutated" + gotStatuses := prepared.EvidenceStatusChanges() + gotStatuses[0].EvidenceKey = "accessor-mutated" + gotJSON := prepared.CanonicalJSON() + gotJSON[0] = 'x' + + assert.Equal(wantInput, prepared.Input()) + assert.Equal(wantClaims, prepared.Claims()) + assert.Equal(wantStatuses, prepared.EvidenceStatusChanges()) + assert.True(bytes.Equal(wantJSON, prepared.CanonicalJSON()), "canonical JSON changed") +} + +func TestPreparePersonFactGenerationCanonicalizesPortableTimestampPrecision(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + input := validGenerationInput() + input.ResolvedAt = time.Date(2025, time.January, 4, 12, 0, 0, 123456789, time.UTC) + evidence := validArchiveEvidence() + evidence.EventTime = time.Date(2025, time.January, 1, 12, 0, 0, 234567891, time.UTC) + evidence.RecordedTime = time.Date(2025, time.January, 2, 12, 0, 0, 345678912, time.UTC) + claim := validClaim(evidence) + validFrom := time.Date(2020, time.January, 1, 0, 0, 0, 456789123, time.UTC) + validUntil := time.Date(2021, time.January, 1, 0, 0, 0, 567891234, time.UTC) + claim.ValidFrom, claim.ValidUntil = &validFrom, &validUntil + input.Claims = []ProposedClaim{claim} + + first := mustPrepare(t, input) + firstInput := first.Input() + firstClaim := first.Claims()[0] + assert.Equal(input.ResolvedAt.Truncate(time.Microsecond), firstInput.ResolvedAt) + assert.Equal(evidence.EventTime.Truncate(time.Microsecond), firstClaim.Evidence[0].EventTime) + assert.Equal(evidence.RecordedTime.Truncate(time.Microsecond), firstClaim.Evidence[0].RecordedTime) + assert.Equal(validFrom.Truncate(time.Microsecond), *firstClaim.ValidFrom) + assert.Equal(validUntil.Truncate(time.Microsecond), *firstClaim.ValidUntil) + + replayInput := cloneGenerationInput(input) + replayInput.ResolvedAt = replayInput.ResolvedAt.Add(10 * time.Nanosecond) + replayInput.Claims[0].Evidence[0].EventTime = replayInput.Claims[0].Evidence[0].EventTime.Add(10 * time.Nanosecond) + replayInput.Claims[0].Evidence[0].RecordedTime = replayInput.Claims[0].Evidence[0].RecordedTime.Add(10 * time.Nanosecond) + *replayInput.Claims[0].ValidFrom = replayInput.Claims[0].ValidFrom.Add(10 * time.Nanosecond) + *replayInput.Claims[0].ValidUntil = replayInput.Claims[0].ValidUntil.Add(10 * time.Nanosecond) + second := mustPrepare(t, replayInput) + assert.Equal(first.GenerationKey(), second.GenerationKey()) + assert.Equal(first.CanonicalJSON(), second.CanonicalJSON()) + firstClaimKey, err := ClaimKey(first.GenerationKey(), firstClaim) + require.NoError(err) + secondClaimKey, err := ClaimKey(second.GenerationKey(), second.Claims()[0]) + require.NoError(err) + assert.Equal(firstClaimKey, secondClaimKey) + assert.Equal(firstClaim.EvidenceKeys, second.Claims()[0].EvidenceKeys) +} + +func TestPreparePersonFactGenerationRejectsDuplicateEvidenceKeys(t *testing.T) { + assert := assert.New(t) + + input := validGenerationInput() + evidence := validArchiveEvidence() + input.Claims = []ProposedClaim{validClaim(evidence, cloneEvidence(evidence))} + + prepared, err := PreparePersonFactGeneration(t.Context(), input, acceptingAligner()) + require.ErrorContains(t, err, "duplicate evidence key") + assert.Empty(prepared.GenerationKey()) + assert.Nil(prepared.CanonicalJSON()) + assert.Empty(prepared.Claims()) +} + +func TestPreparePersonFactGenerationRejectsDuplicateCanonicalClaims(t *testing.T) { + assert := assert.New(t) + + input := validGenerationInput() + claim := validClaim() + input.Claims = []ProposedClaim{claim, claim} + + prepared, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.ErrorContains(t, err, "duplicate canonical claim") + assert.Empty(prepared.GenerationKey()) + assert.Nil(prepared.CanonicalJSON()) + assert.Empty(prepared.Claims()) +} + +func TestResolutionInputFingerprintCanonicalizesPortableTimestampPrecision(t *testing.T) { + build := func(delta time.Duration) ResolutionInput { + evidence := validArchiveEvidence() + evidence.EventTime = time.Date(2025, time.April, 1, 2, 3, 4, 123456111, time.UTC).Add(delta) + evidence.RecordedTime = time.Date(2025, time.April, 2, 3, 4, 5, 234567222, time.UTC).Add(delta) + claim := validClaim(evidence) + validFrom := time.Date(2024, time.April, 1, 0, 0, 0, 345678333, time.UTC).Add(delta) + validUntil := time.Date(2026, time.April, 1, 0, 0, 0, 456789444, time.UTC).Add(delta) + claim.ValidFrom, claim.ValidUntil = &validFrom, &validUntil + activeUntil := time.Date(2026, time.April, 2, 0, 0, 0, 678912666, time.UTC).Add(delta) + return ResolutionInput{ + PersonID: 7, + Target: testAttributeTarget(ValueText), + ResolvedAt: time.Date(2025, time.April, 3, 4, 5, 6, 567891555, time.UTC).Add(delta), + Current: []CurrentProjection{{ + Ref: ProjectionRef{Kind: "attribute", RowID: 42}, + Normalized: NormalizedValue{ + JSON: json.RawMessage(`"current"`), Fingerprint: "current", + }, + ActiveFrom: time.Date(2024, time.April, 2, 0, 0, 0, 789123777, time.UTC).Add(delta), + ActiveUntil: &activeUntil, + TransactionTime: time.Date(2025, time.April, 2, 0, 0, 0, + 891234888, time.UTC).Add(delta), + }}, + Claims: []ResolvedClaim{{ + ClaimKey: "claim-key", Claim: claim, + Evidence: []Evidence{{Key: "evidence-key", Input: evidence, Supported: true}}, + }}, + } + } + + first, err := ResolutionInputFingerprint(build(0)) + require.NoError(t, err) + second, err := ResolutionInputFingerprint(build(10 * time.Nanosecond)) + require.NoError(t, err) + assert.Equal(t, first, second) +} + +func TestResolutionInputFingerprintPreservesLegacyEncoding(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + input := legacyFingerprintResolutionInput() + encoded, err := json.Marshal(input) + requirements.NoError(err) + assertions.True(bytes.Equal([]byte(`{"PersonID":7,"Target":{"kind":"attribute","key":"attribute:favorite","revision":"revision-1","universal_id":"attribute:favorite","slug":"favorite","description":"Fixture favorite","value_type":"text","cardinality":"single","choices":null,"fields":null,"sensitive":false},"ResolvedAt":"2026-08-22T12:00:00Z","Policy":{"allow_sensitive":true,"provider_policy_fingerprint":"policy-context-v1"},"ProjectionContextFingerprint":"projection-context-v1","Current":[{"Ref":{"kind":"attribute","row_id":44},"Normalized":{"JSON":"legacy-current","Fingerprint":"legacy-current"},"ActiveFrom":"2025-08-22T12:00:00Z","ActiveUntil":null,"TransactionTime":"2026-08-22T11:00:00Z","Declared":true}],"Claims":[{"ClaimKey":"legacy-claim","Claim":{"Target":{"kind":"attribute","key":"attribute:favorite","revision":"revision-1","universal_id":"attribute:favorite","slug":"favorite","description":"Fixture favorite","value_type":"text","cardinality":"single","choices":null,"fields":null,"sensitive":false},"Relation":"support","SubmittedValue":"legacy-value","Evidence":null,"ValidFrom":null,"ValidUntil":null,"Origin":"extraction","Confidence":{"reported_score":875}},"Normalized":{"JSON":"legacy-value","Fingerprint":"legacy-value"},"Evidence":[{"ID":7,"PersonID":7,"Key":"evidence-legacy","CreatedAt":"0001-01-01T00:00:00Z","Input":{"PersonID":7,"SourceClass":"archive","Directness":"direct-self","Authority":"ordinary","SourceRef":"legacy","SourceURL":"","SubjectPersonID":7,"SubjectRef":"person-7","SpanStart":0,"SpanEnd":8,"Excerpt":"fixture evidence","ContentSHA256":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa","SourceVersion":"v1","EventTime":"2026-08-22T12:00:00Z","RecordedTime":"2026-08-22T12:00:00Z","IdentityScore":950},"Supported":true,"LatestStatus":{"ID":91,"PersonID":7,"GenerationID":0,"EvidenceID":7,"EvidenceKey":"evidence-legacy","SourceVersion":"v1","Supported":false,"Reason":"source-edited","CreatedAt":"2026-08-22T12:00:00Z"}}],"Failure":null},{"ClaimKey":"legacy-failure","Claim":{"Target":{"kind":"attribute","key":"attribute:favorite","revision":"revision-1","universal_id":"attribute:favorite","slug":"favorite","description":"Fixture favorite","value_type":"text","cardinality":"single","choices":null,"fields":null,"sensitive":false},"Relation":"support","SubmittedValue":"broken","Evidence":null,"ValidFrom":null,"ValidUntil":null,"Origin":"extraction","Confidence":{"reported_score":400}},"Normalized":null,"Evidence":null,"Failure":{"Action":"invalid","Reason":"malformed-value","Detail":"legacy failure"}}],"Pin":{"target":{"kind":"attribute","key":"attribute:favorite","revision":"revision-1"},"pinned":true,"actor":"operator","event_id":73}}`), encoded), + "resolution input encoding changed") + + digest, err := ResolutionInputFingerprint(input) + requirements.NoError(err) + assertions.Equal("sha256:2a2e78418caca2ffcb851dc66c3243df4fd19bc548986378b26fef357a0e8fb1", digest) +} + +func legacyFingerprintResolutionInput() ResolutionInput { + evidence := resolverTestEvidence("legacy", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + evidence.LatestStatus = resolverTestStatus(evidence, 91, false, EvidenceStatusSourceEdited) + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("legacy-current", true, 44)} + eventID := int64(73) + input.Pin = PinState{ + Target: resolverTestTargetRef(input.Target), Pinned: true, Actor: "operator", EventID: &eventID, + } + input.Claims = []ResolvedClaim{ + resolverTestClaim("legacy-claim", "legacy-value", 875, evidence), + { + ClaimKey: "legacy-failure", + Claim: ProposedClaim{ + Target: input.Target, Relation: RelationSupport, SubmittedValue: json.RawMessage(`"broken"`), + Origin: OriginExtraction, Confidence: ConfidenceInputs{ReportedScore: 400}, + }, + Failure: &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonMalformedValue, Detail: "legacy failure", + }, + }, + } + return input +} + +func TestPreparePersonFactGenerationPerformsAlignmentBeforeReturn(t *testing.T) { + input := validGenerationInput() + first := validArchiveEvidence() + second := cloneEvidence(first) + second.SourceRef = "message-2" + input.Claims = []ProposedClaim{validClaim(first), validClaim(second)} + calls := 0 + aligner := evidenceAlignerFunc(func(_ context.Context, evidence EvidenceInput) (AlignmentResult, error) { + calls++ + return AlignmentResult{Accepted: true, SourceVersion: evidence.SourceVersion + "-aligned", ContentSHA256: strings.Repeat(string(rune('a'+calls)), 64)}, nil + }) + + prepared, err := PreparePersonFactGeneration(t.Context(), input, aligner) + require.NoError(t, err) + assert.Equal(t, 2, calls) + for _, claim := range prepared.Claims() { + assert.Contains(t, claim.Evidence[0].SourceVersion, "-aligned") + } +} + +func TestPreparePersonFactGenerationOperationalAlignmentFailureReturnsNoPreparedValue(t *testing.T) { + assert := assert.New(t) + + input := validGenerationInput() + input.Claims = []ProposedClaim{validClaim(validArchiveEvidence())} + wantErr := errors.New("archive unavailable") + aligner := evidenceAlignerFunc(func(context.Context, EvidenceInput) (AlignmentResult, error) { + return AlignmentResult{}, wantErr + }) + + prepared, err := PreparePersonFactGeneration(t.Context(), input, aligner) + require.ErrorIs(t, err, wantErr) + assert.Empty(prepared.GenerationKey()) + assert.Nil(prepared.CanonicalJSON()) + assert.Empty(prepared.Claims()) +} + +func TestPreparePersonFactGenerationAllowsStatusOnlyAndEmptyGenerations(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + empty := validGenerationInput() + prepared, err := PreparePersonFactGeneration(t.Context(), empty, nil) + requirements.NoError(err) + assertions.NotEmpty(prepared.GenerationKey()) + assertions.Empty(prepared.Claims()) + assertions.Empty(prepared.EvidenceStatusChanges()) + + statusOnly := validGenerationInput() + statusOnly.EvidenceStatusChanges = []EvidenceStatusChange{ + {EvidenceKey: "evidence-a", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceDeleted}, + {EvidenceKey: "evidence-a", SourceVersion: "v2", Supported: true, Reason: EvidenceStatusSourceReimported}, + {EvidenceKey: "evidence-b", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceEdited}, + {EvidenceKey: "evidence-c", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusScopeUnlinked}, + {EvidenceKey: "evidence-d", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusIdentityReassigned}, + {EvidenceKey: "evidence-e", SourceVersion: "v1", Supported: true, Reason: EvidenceStatusScopeRelinked}, + } + prepared, err = PreparePersonFactGeneration(t.Context(), statusOnly, nil) + requirements.NoError(err) + assertions.Empty(prepared.Claims()) + assertions.Len(prepared.EvidenceStatusChanges(), 6) + + invalidCases := []GenerationInput{ + func() GenerationInput { value := validGenerationInput(); value.SourceCursors = nil; return value }(), + func() GenerationInput { + value := validGenerationInput() + value.SourceCursors[0].Lane = "" + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.SourceCursors[0].Start = "" + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.SourceCursors[0].End = "" + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.SourceCursors = append(value.SourceCursors, value.SourceCursors[0]) + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.ProgramFingerprint = "not-a-hash" + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.EvidenceStatusChanges = []EvidenceStatusChange{{EvidenceKey: "", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceDeleted}} + return value + }(), + func() GenerationInput { + value := validGenerationInput() + value.EvidenceStatusChanges = []EvidenceStatusChange{{EvidenceKey: "e", SourceVersion: "v1", Supported: true, Reason: EvidenceStatusSourceDeleted}} + return value + }(), + func() GenerationInput { + value := validGenerationInput() + change := EvidenceStatusChange{EvidenceKey: "e", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceDeleted} + value.EvidenceStatusChanges = []EvidenceStatusChange{change, change} + return value + }(), + } + for index, input := range invalidCases { + t.Run(string(rune('a'+index)), func(t *testing.T) { + require := require.New(t) + + _, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.Error(err) + }) + } + + for _, score := range []int{-1, 1001} { + t.Run(fmt.Sprintf("reported confidence %d", score), func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + input := validGenerationInput() + claim := validClaim() + claim.Confidence.ReportedScore = score + input.Claims = []ProposedClaim{claim} + prepared, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + failure := prepared.Claims()[0].Failure + require.NotNil(failure) + assert.Equal(ReasonMalformedValue, failure.Reason) + }) + } +} + +func TestPreparePersonFactGenerationRejectsMultipleStatusesForOneEvidenceVersion(t *testing.T) { + changes := []EvidenceStatusChange{ + {EvidenceKey: "evidence", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceDeleted}, + {EvidenceKey: "evidence", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceEdited}, + {EvidenceKey: "evidence", SourceVersion: "v1", Supported: true, Reason: EvidenceStatusSourceReimported}, + } + for left := range changes { + for right := range changes { + t.Run(fmt.Sprintf("%d-then-%d", left, right), func(t *testing.T) { + require := require.New(t) + input := validGenerationInput() + input.EvidenceStatusChanges = []EvidenceStatusChange{changes[left], changes[right]} + + _, err := PreparePersonFactGeneration(t.Context(), input, nil) + require.ErrorContains(err, `duplicate evidence status tuple for "evidence" at "v1"`) + }) + } + } +} + +func validGenerationInput() GenerationInput { + return GenerationInput{ + PersonID: 7, + SourceCursors: []SourceCursor{{Lane: "optimistic", Start: "100", End: "200"}}, + ProgramID: "people-extraction", ProgramVersion: "v1", + ProgramFingerprint: strings.Repeat("f", 64), CatalogFingerprint: "catalog-v1", + Provider: "fixture-provider", ProviderVersion: "2026-08-22", + Model: "fixture-model", ModelVersion: "model-v1", + ResolvedAt: utcTime(2025, 4, 12), + Policy: PolicyContext{AllowSensitive: false, ProviderPolicyFingerprint: "policy-v1"}, + } +} + +func validArchiveEvidence() EvidenceInput { + start, end := int64(10), int64(20) + return EvidenceInput{ + PersonID: 7, SourceClass: EvidenceArchive, Directness: DirectSelf, + Authority: AuthorityAuthoritative, SourceRef: "message-1", + SubjectPersonID: new(int64(7)), SubjectRef: "participant-1", + SpanStart: &start, SpanEnd: &end, Excerpt: "I joined Example", + ContentSHA256: strings.Repeat("a", 64), SourceVersion: "immutable-v1", + EventTime: utcTime(2025, 1, 12), + RecordedTime: utcTime(2025, 2, 12), IdentityScore: 950, + } +} + +func validClaim(evidence ...EvidenceInput) ProposedClaim { + validFrom := utcTime(2020, 1, 0) + return ProposedClaim{ + Target: testAttributeTarget(ValueText), Relation: RelationSupport, + SubmittedValue: json.RawMessage(`"Example value"`), Evidence: evidence, + ValidFrom: &validFrom, Origin: OriginExtraction, + Confidence: ConfidenceInputs{ReportedScore: 800}, + } +} + +func generationWithTwoEvidence() GenerationInput { + first := validArchiveEvidence() + second := cloneEvidence(first) + second.SourceRef = "message-2" + second.Excerpt = "Second source" + second.ContentSHA256 = strings.Repeat("b", 64) + input := validGenerationInput() + input.SourceCursors = append(input.SourceCursors, SourceCursor{Lane: "backstop", Start: "a", End: "z"}) + firstClaim := validClaim(first, second) + secondClaim := validClaim(second, first) + secondClaim.Target = choiceTarget() + secondClaim.SubmittedValue = json.RawMessage(`"active"`) + input.Claims = []ProposedClaim{firstClaim, secondClaim} + input.EvidenceStatusChanges = []EvidenceStatusChange{ + {EvidenceKey: "evidence-b", SourceVersion: "v1", Supported: false, Reason: EvidenceStatusSourceEdited}, + {EvidenceKey: "evidence-a", SourceVersion: "v1", Supported: true, Reason: EvidenceStatusSourceReimported}, + } + return input +} + +func mustPrepare(t *testing.T, input GenerationInput) PreparedGeneration { + t.Helper() + prepared, err := PreparePersonFactGeneration(t.Context(), input, acceptingAligner()) + require.NoError(t, err) + return prepared +} + +func cloneEvidence(input EvidenceInput) EvidenceInput { + cloned := input + if input.SubjectPersonID != nil { + value := *input.SubjectPersonID + cloned.SubjectPersonID = &value + } + if input.SpanStart != nil { + value := *input.SpanStart + cloned.SpanStart = &value + } + if input.SpanEnd != nil { + value := *input.SpanEnd + cloned.SpanEnd = &value + } + return cloned +} + +func cloneGenerationInput(input GenerationInput) GenerationInput { + cloned := input + cloned.SourceCursors = append([]SourceCursor(nil), input.SourceCursors...) + cloned.Claims = make([]ProposedClaim, len(input.Claims)) + for i, claim := range input.Claims { + cloned.Claims[i] = claim + cloned.Claims[i].Target = canonicalTarget(claim.Target) + cloned.Claims[i].SubmittedValue = append(json.RawMessage(nil), claim.SubmittedValue...) + cloned.Claims[i].Evidence = make([]EvidenceInput, len(claim.Evidence)) + for j, evidence := range claim.Evidence { + cloned.Claims[i].Evidence[j] = cloneEvidence(evidence) + } + if claim.ValidFrom != nil { + value := *claim.ValidFrom + cloned.Claims[i].ValidFrom = &value + } + if claim.ValidUntil != nil { + value := *claim.ValidUntil + cloned.Claims[i].ValidUntil = &value + } + } + cloned.EvidenceStatusChanges = append([]EvidenceStatusChange(nil), input.EvidenceStatusChanges...) + return cloned +} + +func clonePreparedClaim(input PreparedClaim) PreparedClaim { + cloned := input + cloned.Target = canonicalTarget(input.Target) + cloned.SubmittedValue = append(json.RawMessage(nil), input.SubmittedValue...) + cloned.SubmittedEvidenceFingerprints = append( + []string(nil), input.SubmittedEvidenceFingerprints...) + if input.Normalized != nil { + value := *input.Normalized + value.JSON = append(json.RawMessage(nil), input.Normalized.JSON...) + cloned.Normalized = &value + } + cloned.Evidence = make([]EvidenceInput, len(input.Evidence)) + for i, evidence := range input.Evidence { + cloned.Evidence[i] = cloneEvidence(evidence) + } + cloned.EvidenceKeys = append([]string(nil), input.EvidenceKeys...) + if input.ValidFrom != nil { + value := *input.ValidFrom + cloned.ValidFrom = &value + } + if input.ValidUntil != nil { + value := *input.ValidUntil + cloned.ValidUntil = &value + } + if input.Failure != nil { + value := *input.Failure + cloned.Failure = &value + } + return cloned +} diff --git a/internal/personfacts/resolver.go b/internal/personfacts/resolver.go new file mode 100644 index 000000000..32c95a5ae --- /dev/null +++ b/internal/personfacts/resolver.go @@ -0,0 +1,1093 @@ +package personfacts + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "net/url" + "sort" + "strings" + "time" +) + +const ResolverVersionV1 = "person-fact-resolver-v1" + +type resolverCandidate struct { + claim ResolvedClaim + supportedEvidence []Evidence + score ScoreBreakdown + canonicalKey string + decision Decision + eligible bool +} + +type resolverDirection struct { + fingerprint string + negative bool + indexes []int + representative int + score ScoreBreakdown + canonicalKey string +} + +type resolverDirectionKey struct { + fingerprint string + negative bool +} + +type effectiveEvidenceStatus struct { + personID int64 + key string + sourceVersion string + supported bool + eventID int64 + reason EvidenceStatusReason +} + +func DefaultPolicyV1() Policy { + return Policy{ + Version: ResolverVersionV1, ApplyThreshold: 750, ReplacementMargin: 100, + HysteresisMargin: 75, MinimumIdentityScore: 900, MaxCorroborationBonus: 150, + SourceClassWeights: map[EvidenceSourceClass]int{ + EvidenceArchive: 300, EvidencePublic: 250, EvidenceSystem: 500, + EvidenceProviderAssertion: 150, + }, + DirectnessWeights: map[EvidenceDirectness]int{ + DirectSelf: 400, DirectOther: 200, Indirect: 0, + }, + AuthorityWeights: map[EvidenceAuthority]int{ + AuthorityAuthoritative: 300, AuthorityOrdinary: 100, AuthorityAggregator: 0, + }, + } +} + +func NewResolver(policy Policy) (Resolver, error) { + if err := validateResolverPolicy(policy); err != nil { + return Resolver{}, err + } + return Resolver{Policy: copyResolverPolicy(policy)}, nil +} + +func (r Resolver) Resolve(input ResolutionInput) (Resolution, error) { + if err := validateResolverPolicy(r.Policy); err != nil { + return Resolution{}, fmt.Errorf("resolver policy: %w", err) + } + if err := validateResolutionInput(input); err != nil { + return Resolution{}, err + } + input = copyResolutionInput(input) + statuses, err := collectEffectiveEvidenceStatuses(input) + if err != nil { + return Resolution{}, err + } + inputFingerprint, err := resolverInputFingerprint(input, r.Policy, statuses) + if err != nil { + return Resolution{}, err + } + + candidates := make([]resolverCandidate, len(input.Claims)) + seenClaimKeys := make(map[string]struct{}, len(input.Claims)) + for index := range input.Claims { + claim := input.Claims[index] + if claim.ClaimKey == "" { + return Resolution{}, errors.New("resolver claim key is required") + } + if _, exists := seenClaimKeys[claim.ClaimKey]; exists { + return Resolution{}, fmt.Errorf("duplicate resolver claim key %q", claim.ClaimKey) + } + seenClaimKeys[claim.ClaimKey] = struct{}{} + candidate, candidateErr := r.prepareCandidate(input, claim, statuses) + if candidateErr != nil { + return Resolution{}, candidateErr + } + candidates[index] = candidate + } + sort.Slice(candidates, func(i, j int) bool { return candidates[i].canonicalKey < candidates[j].canonicalKey }) + + pinned := input.Pin.Pinned || bootstrapDeclaredPin(input) + if input.Pin.EventID != nil { + pinned = input.Pin.Pinned + } + if pinned { + for index := range candidates { + if candidates[index].eligible { + candidates[index].decision = resolverDecision(input.PersonID, candidates[index], + DecisionRetained, ReasonPinRetained, "") + } + } + } else { + switch input.Target.Cardinality { + case CardinalitySingle: + r.resolveSingle(input, candidates) + case CardinalityMulti: + r.resolveMulti(input, candidates) + } + } + + resolution := Resolution{ + Target: resolverTargetRef(input.Target), ResolverVersion: r.Policy.Version, + InputFingerprint: inputFingerprint, ResolvedAt: input.ResolvedAt, + Decisions: make([]Decision, 0, len(candidates)), Projections: []ProjectionPlan{}, + } + for index := range candidates { + decision := candidates[index].decision + if decision.Action == "" { + decision = resolverDecision(input.PersonID, candidates[index], + DecisionRetained, ReasonBelowThreshold, "") + } + decisionKey, keyErr := DecisionKey(inputFingerprint, decision.ClaimKey, decision.Action) + if keyErr != nil { + return Resolution{}, keyErr + } + decision.DecisionKey = decisionKey + resolution.Decisions = append(resolution.Decisions, decision) + resolution.Projections = append(resolution.Projections, candidates[index].projectionPlans(input)...) + } + return resolution, nil +} + +func (r Resolver) prepareCandidate( + input ResolutionInput, claim ResolvedClaim, statuses map[string]effectiveEvidenceStatus, +) (resolverCandidate, error) { + candidate := resolverCandidate{claim: claim} + candidate.canonicalKey = resolverClaimSortKey(claim) + failure := claim.Failure + if failure != nil { + if !validResolverRejectionFailure(failure) { + return resolverCandidate{}, fmt.Errorf("claim %q has an invalid durable rejection", claim.ClaimKey) + } + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: failure.Action, Reason: failure.Reason, + } + return candidate, nil + } + if claim.Claim.Target.Kind != input.Target.Kind || claim.Claim.Target.Key != input.Target.Key { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonUnsupportedTarget, + } + return candidate, nil + } + if claim.Claim.Target.Revision != input.Target.Revision { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonStaleTargetRevision, + } + return candidate, nil + } + if claim.Claim.Relation != RelationSupport && claim.Claim.Relation != RelationContradict && + claim.Claim.Relation != RelationSupersede { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonMalformedValue, + } + return candidate, nil + } + if claim.Claim.Origin != OriginExtraction && claim.Claim.Origin != OriginEnrichment && + claim.Claim.Origin != OriginSystem { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonMalformedValue, + } + return candidate, nil + } + if claim.Claim.Confidence.ReportedScore < 0 || claim.Claim.Confidence.ReportedScore > 1000 || + claim.Normalized == nil || claim.Normalized.Fingerprint == "" { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonMalformedValue, + } + return candidate, nil + } + if (claim.Claim.ValidFrom != nil && + (claim.Claim.ValidFrom.IsZero() || !isUTC(*claim.Claim.ValidFrom))) || + (claim.Claim.ValidUntil != nil && + (claim.Claim.ValidUntil.IsZero() || !isUTC(*claim.Claim.ValidUntil))) || + (claim.Claim.ValidFrom != nil && claim.Claim.ValidUntil != nil && + claim.Claim.ValidUntil.Before(*claim.Claim.ValidFrom)) { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionInvalid, Reason: ReasonMalformedValue, + } + return candidate, nil + } + // Claim validity is a half-open interval: valid_from is inclusive and + // valid_until is exclusive at the resolution's world time. + if (claim.Claim.ValidFrom != nil && input.ResolvedAt.Before(*claim.Claim.ValidFrom)) || + (claim.Claim.ValidUntil != nil && !input.ResolvedAt.Before(*claim.Claim.ValidUntil)) { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionRetained, Reason: ReasonOutsideValidity, + } + return candidate, nil + } + if input.Target.Sensitive && !input.Policy.AllowSensitive { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionPolicyRejected, Reason: ReasonSensitivePolicy, + } + return candidate, nil + } + + for _, evidence := range claim.Evidence { + status := statuses[effectiveEvidenceTupleKey(evidence)] + if !status.supported { + continue + } + if evidence.PersonID != input.PersonID || evidence.Input.PersonID != input.PersonID || + evidence.Input.SubjectPersonID == nil || *evidence.Input.SubjectPersonID != input.PersonID || + evidence.Input.IdentityScore < r.Policy.MinimumIdentityScore { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionIdentityRejected, Reason: ReasonIdentityMismatch, + } + return candidate, nil + } + if validationErr := validateEvidenceInput(evidence.Input); validationErr != nil { + reason := ReasonMalformedValue + action := DecisionInvalid + if typed, ok := errors.AsType[*evidenceValidationError](validationErr); ok { + reason = typed.reason + if reason == ReasonIdentityMismatch { + action = DecisionIdentityRejected + } + } + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, Action: action, Reason: reason, + } + return candidate, nil + } + candidate.supportedEvidence = append(candidate.supportedEvidence, evidence) + } + if len(candidate.supportedEvidence) == 0 { + candidate.decision = Decision{ + PersonID: input.PersonID, ClaimKey: claim.ClaimKey, + Action: DecisionRetained, Reason: ReasonEvidenceUnsupported, + } + return candidate, nil + } + candidate.score = r.scoreClaim(input.ResolvedAt, claim, candidate.supportedEvidence) + candidate.eligible = true + return candidate, nil +} + +func (r Resolver) scoreClaim( + resolvedAt time.Time, claim ResolvedClaim, evidence []Evidence, +) ScoreBreakdown { + return r.scoreEvidence(resolvedAt, claim.Claim.Confidence.ReportedScore, evidence) +} + +func (r Resolver) scoreEvidence( + resolvedAt time.Time, reportedConfidence int, evidence []Evidence, +) ScoreBreakdown { + score := ScoreBreakdown{Confidence: reportedConfidence / 10} + independent := make(map[string]struct{}, len(evidence)) + for _, item := range evidence { + input := item.Input + score.SourceClass = max(score.SourceClass, r.Policy.SourceClassWeights[input.SourceClass]) + score.Directness = max(score.Directness, r.Policy.DirectnessWeights[input.Directness]) + score.Authority = max(score.Authority, r.Policy.AuthorityWeights[input.Authority]) + score.Freshness = max(score.Freshness, resolverFreshness(resolvedAt, input.EventTime)) + independent[resolverIndependentSource(input)] = struct{}{} + } + if len(independent) > 1 { + score.Corroboration = min((len(independent)-1)*50, r.Policy.MaxCorroborationBonus) + } + score.Total = score.SourceClass + score.Directness + score.Authority + + score.Confidence + score.Freshness + score.Corroboration + return score +} + +func (r Resolver) resolveSingle(input ResolutionInput, candidates []resolverCandidate) { + activeCurrent := activeResolverCurrent(input) + if len(activeCurrent) > 1 { + for index := range candidates { + if candidates[index].eligible { + candidates[index].decision = resolverDecision(input.PersonID, candidates[index], + DecisionConflictRejected, ReasonInsufficientMargin, "") + } + } + return + } + directions := r.singleValueDirections(input.ResolvedAt, candidates) + directions = r.scopeSingleValueNegativeDirections(input, candidates, directions) + if len(directions) == 0 { + return + } + sort.Slice(directions, func(i, j int) bool { + if directions[i].score.Total != directions[j].score.Total { + return directions[i].score.Total > directions[j].score.Total + } + return directions[i].canonicalKey < directions[j].canonicalKey + }) + winner := directions[0] + if winner.score.Total < r.Policy.ApplyThreshold { + for _, direction := range directions { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionRetained, ReasonBelowThreshold, "") + } + return + } + if len(directions) > 1 && winner.score.Total == directions[1].score.Total { + tied := make([]resolverDirection, 0, len(directions)) + for _, direction := range directions { + if direction.score.Total == winner.score.Total { + tied = append(tied, direction) + } + } + for tiedIndex, direction := range tied { + competing := candidates[tied[(tiedIndex+1)%len(tied)].representative].claim.ClaimKey + setDirectionDecision(input.PersonID, candidates, direction, + DecisionAmbiguousRetained, ReasonCompetingTie, competing) + } + for _, direction := range directions[len(tied):] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionConflictRejected, ReasonInsufficientMargin, + candidates[winner.representative].claim.ClaimKey) + } + return + } + if len(directions) > 1 && winner.score.Total-directions[1].score.Total < r.Policy.ReplacementMargin { + setDirectionDecision(input.PersonID, candidates, winner, + DecisionRetained, ReasonInsufficientMargin, + candidates[directions[1].representative].claim.ClaimKey) + for _, direction := range directions[1:] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionConflictRejected, ReasonInsufficientMargin, + candidates[winner.representative].claim.ClaimKey) + } + return + } + if len(activeCurrent) == 1 && directionChangesCurrent(winner, activeCurrent[0]) { + required := r.Policy.ApplyThreshold + r.Policy.ReplacementMargin + r.Policy.HysteresisMargin + if winner.score.Total < required { + setDirectionDecision(input.PersonID, candidates, winner, + DecisionRetained, ReasonInsufficientMargin, "") + for _, direction := range directions[1:] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionConflictRejected, ReasonInsufficientMargin, + candidates[winner.representative].claim.ClaimKey) + } + return + } + } + winnerReason := ReasonAppliedProjection + if winner.negative { + winnerReason = negativeDecisionReason(candidates[winner.representative].claim.Claim.Relation) + } + setWinningDirection(input.PersonID, candidates, winner, winnerReason) + for _, direction := range directions[1:] { + losingReason := ReasonAppliedProjection + if winner.negative { + losingReason = winnerReason + } + setDirectionDecision(input.PersonID, candidates, direction, + DecisionSuperseded, losingReason, candidates[winner.representative].claim.ClaimKey) + } +} + +func (r Resolver) scopeSingleValueNegativeDirections( + input ResolutionInput, candidates []resolverCandidate, directions []resolverDirection, +) []resolverDirection { + currentFingerprint := "" + if current := activeResolverCurrent(input); len(current) == 1 { + currentFingerprint = current[0].Normalized.Fingerprint + } + byFingerprint := make(map[string][]resolverDirection) + for _, direction := range directions { + byFingerprint[direction.fingerprint] = append( + byFingerprint[direction.fingerprint], direction) + } + remaining := make([]resolverDirection, 0, len(directions)) + for fingerprint, scoped := range byFingerprint { + hasNegative := false + for _, direction := range scoped { + hasNegative = hasNegative || direction.negative + } + if !hasNegative || fingerprint == currentFingerprint { + remaining = append(remaining, scoped...) + continue + } + if positive := r.resolveAbsentSingleValueDirections(input, candidates, scoped); positive != nil { + remaining = append(remaining, *positive) + } + } + return remaining +} + +func (r Resolver) resolveAbsentSingleValueDirections( + input ResolutionInput, candidates []resolverCandidate, directions []resolverDirection, +) *resolverDirection { + sort.Slice(directions, func(i, j int) bool { + if directions[i].score.Total != directions[j].score.Total { + return directions[i].score.Total > directions[j].score.Total + } + return directions[i].canonicalKey < directions[j].canonicalKey + }) + winner := directions[0] + if winner.score.Total < r.Policy.ApplyThreshold { + for _, direction := range directions { + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionRetained, ReasonBelowThreshold, "") + } + return nil + } + if len(directions) > 1 && winner.score.Total == directions[1].score.Total { + for index, direction := range directions { + competing := candidates[directions[(index+1)%len(directions)].representative].claim.ClaimKey + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionAmbiguousRetained, ReasonCompetingTie, competing) + } + return nil + } + if len(directions) > 1 && winner.score.Total-directions[1].score.Total < r.Policy.ReplacementMargin { + setMultiDirectionDecision(input.PersonID, candidates, winner, + DecisionRetained, ReasonInsufficientMargin, + candidates[directions[1].representative].claim.ClaimKey) + for _, direction := range directions[1:] { + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionConflictRejected, ReasonInsufficientMargin, + candidates[winner.representative].claim.ClaimKey) + } + return nil + } + if winner.negative { + winnerReason := negativeDecisionReason(candidates[winner.representative].claim.Claim.Relation) + setWinningDirection(input.PersonID, candidates, winner, winnerReason) + for _, direction := range directions[1:] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionSuperseded, winnerReason, + candidates[winner.representative].claim.ClaimKey) + } + return nil + } + for _, direction := range directions[1:] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionSuperseded, ReasonAppliedProjection, + candidates[winner.representative].claim.ClaimKey) + } + return &winner +} + +func (r Resolver) singleValueDirections( + resolvedAt time.Time, candidates []resolverCandidate, +) []resolverDirection { + grouped := make(map[resolverDirectionKey][]int) + for index := range candidates { + if !candidates[index].eligible { + continue + } + negative := candidates[index].claim.Claim.Relation != RelationSupport + key := resolverDirectionKey{ + fingerprint: candidates[index].claim.Normalized.Fingerprint, + negative: negative, + } + grouped[key] = append(grouped[key], index) + } + directions := make([]resolverDirection, 0, len(grouped)) + for _, indexes := range grouped { + direction := resolverDirection{ + fingerprint: candidates[indexes[0]].claim.Normalized.Fingerprint, + negative: candidates[indexes[0]].claim.Claim.Relation != RelationSupport, + indexes: append([]int(nil), indexes...), + } + sort.Slice(direction.indexes, func(i, j int) bool { + left, right := candidates[direction.indexes[i]], candidates[direction.indexes[j]] + if left.score.Total != right.score.Total { + return left.score.Total > right.score.Total + } + return left.canonicalKey < right.canonicalKey + }) + direction.representative = direction.indexes[0] + direction.canonicalKey = direction.fingerprint + fmt.Sprintf("\x00%t\x00", direction.negative) + + candidates[direction.representative].canonicalKey + reportedConfidence := 0 + evidence := make([]Evidence, 0) + for _, index := range direction.indexes { + reportedConfidence = max(reportedConfidence, + candidates[index].claim.Claim.Confidence.ReportedScore) + evidence = append(evidence, candidates[index].supportedEvidence...) + } + direction.score = r.scoreEvidence(resolvedAt, reportedConfidence, evidence) + for _, index := range direction.indexes { + candidates[index].score = direction.score + } + directions = append(directions, direction) + } + return directions +} + +func directionChangesCurrent(direction resolverDirection, current CurrentProjection) bool { + if direction.negative { + return direction.fingerprint == current.Normalized.Fingerprint + } + return direction.fingerprint != current.Normalized.Fingerprint +} + +func setWinningDirection( + personID int64, candidates []resolverCandidate, direction resolverDirection, reason DecisionReason, +) { + representative := direction.representative + representativeReason := directionClaimReason(direction, candidates[representative], reason) + candidates[representative].decision = resolverDecision(personID, candidates[representative], + DecisionApplied, representativeReason, "") + for _, index := range direction.indexes { + if index == representative { + continue + } + claimReason := directionClaimReason(direction, candidates[index], reason) + candidates[index].decision = resolverDecision(personID, candidates[index], + DecisionSuperseded, claimReason, candidates[representative].claim.ClaimKey) + } +} + +func directionClaimReason( + direction resolverDirection, candidate resolverCandidate, fallback DecisionReason, +) DecisionReason { + if direction.negative { + return negativeDecisionReason(candidate.claim.Claim.Relation) + } + return fallback +} + +func setDirectionDecision( + personID int64, candidates []resolverCandidate, direction resolverDirection, + action DecisionAction, reason DecisionReason, competing string, +) { + for _, index := range direction.indexes { + claimReason := directionClaimReason(direction, candidates[index], reason) + candidates[index].decision = resolverDecision(personID, candidates[index], action, claimReason, competing) + } +} + +func negativeDecisionReason(relation ClaimRelation) DecisionReason { + if relation == RelationSupersede { + return ReasonExplicitSupersession + } + return ReasonExplicitContradiction +} + +func (r Resolver) resolveMulti(input ResolutionInput, candidates []resolverCandidate) { + byFingerprint := make(map[string][]resolverDirection) + for _, direction := range r.singleValueDirections(input.ResolvedAt, candidates) { + byFingerprint[direction.fingerprint] = append(byFingerprint[direction.fingerprint], direction) + } + for _, directions := range byFingerprint { + sort.Slice(directions, func(i, j int) bool { + if directions[i].score.Total != directions[j].score.Total { + return directions[i].score.Total > directions[j].score.Total + } + return directions[i].canonicalKey < directions[j].canonicalKey + }) + winner := directions[0] + if winner.score.Total < r.Policy.ApplyThreshold { + for _, direction := range directions { + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionRetained, ReasonBelowThreshold, "") + } + continue + } + if len(directions) > 1 && winner.score.Total == directions[1].score.Total { + for index, direction := range directions { + competing := candidates[directions[(index+1)%len(directions)].representative].claim.ClaimKey + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionAmbiguousRetained, ReasonCompetingTie, competing) + } + continue + } + if len(directions) > 1 && winner.score.Total-directions[1].score.Total < r.Policy.ReplacementMargin { + setMultiDirectionDecision(input.PersonID, candidates, winner, + DecisionRetained, ReasonInsufficientMargin, + candidates[directions[1].representative].claim.ClaimKey) + for _, direction := range directions[1:] { + setMultiDirectionDecision(input.PersonID, candidates, direction, + DecisionConflictRejected, ReasonInsufficientMargin, + candidates[winner.representative].claim.ClaimKey) + } + continue + } + winnerReason := ReasonAppliedProjection + if winner.negative { + winnerReason = negativeDecisionReason(candidates[winner.representative].claim.Claim.Relation) + } + setWinningDirection(input.PersonID, candidates, winner, winnerReason) + for _, direction := range directions[1:] { + setDirectionDecision(input.PersonID, candidates, direction, + DecisionSuperseded, winnerReason, candidates[winner.representative].claim.ClaimKey) + } + } +} + +func setMultiDirectionDecision( + personID int64, candidates []resolverCandidate, direction resolverDirection, + action DecisionAction, reason DecisionReason, competing string, +) { + for _, index := range direction.indexes { + candidates[index].decision = resolverDecision( + personID, candidates[index], action, reason, competing) + } +} + +func (c resolverCandidate) projectionPlans(input ResolutionInput) []ProjectionPlan { + if c.decision.Action != DecisionApplied { + return nil + } + activeFrom := input.ResolvedAt + if c.claim.Claim.ValidFrom != nil { + activeFrom = *c.claim.Claim.ValidFrom + } + current := activeResolverCurrent(input) + if c.claim.Claim.Relation == RelationSupport { + for _, item := range current { + if item.Normalized.Fingerprint == c.claim.Normalized.Fingerprint { + return nil + } + } + plan := ProjectionPlan{ + Operation: ProjectionSet, Target: resolverTargetRef(input.Target), + ClaimKey: c.claim.ClaimKey, ActiveFrom: activeFrom, + } + if input.Target.Cardinality == CardinalitySingle && len(current) == 1 { + ref := current[0].Ref + plan.CurrentRef = &ref + } + return []ProjectionPlan{plan} + } + for _, item := range current { + if item.Normalized.Fingerprint == c.claim.Normalized.Fingerprint { + ref := item.Ref + return []ProjectionPlan{{ + Operation: ProjectionRetire, Target: resolverTargetRef(input.Target), + ClaimKey: c.claim.ClaimKey, CurrentRef: &ref, ActiveFrom: activeFrom, + }} + } + } + return nil +} + +func resolverDecision( + personID int64, candidate resolverCandidate, action DecisionAction, + reason DecisionReason, competing string, +) Decision { + return Decision{ + PersonID: personID, ClaimKey: candidate.claim.ClaimKey, Action: action, + Reason: reason, Score: candidate.score, CompetingClaimKey: competing, + } +} + +func resolverFreshness(resolvedAt, eventTime time.Time) int { + if eventTime.After(resolvedAt) { + return 0 + } + age := resolvedAt.Sub(eventTime) + switch { + case age <= 30*24*time.Hour: + return 100 + case age <= 180*24*time.Hour: + return 60 + case age <= 730*24*time.Hour: + return 20 + default: + return 0 + } +} + +func resolverIndependentSource(input EvidenceInput) string { + identity := strings.TrimSpace(input.SourceRef) + if input.SourceClass == EvidencePublic { + identity = canonicalResolverURL(input.SourceURL) + } + return string(input.SourceClass) + "\x00" + identity +} + +func canonicalResolverURL(raw string) string { + parsed, err := url.Parse(strings.TrimSpace(raw)) + if err != nil { + return strings.TrimSpace(raw) + } + parsed.Scheme = strings.ToLower(parsed.Scheme) + parsed.Host = strings.ToLower(parsed.Host) + parsed.Fragment = "" + return parsed.String() +} + +func collectEffectiveEvidenceStatuses( + input ResolutionInput, +) (map[string]effectiveEvidenceStatus, error) { + statuses := make(map[string]effectiveEvidenceStatus) + for _, claim := range input.Claims { + for _, evidence := range claim.Evidence { + tuple := effectiveEvidenceTupleKey(evidence) + if _, exists := statuses[tuple]; !exists { + statuses[tuple] = effectiveEvidenceStatus{ + personID: evidence.PersonID, key: resolverEvidenceKey(evidence), + sourceVersion: evidence.Input.SourceVersion, + supported: true, + } + } + if evidence.LatestStatus == nil { + continue + } + event := *evidence.LatestStatus + if event.ID <= 0 || event.PersonID != evidence.PersonID || + event.EvidenceKey != resolverEvidenceKey(evidence) || + event.SourceVersion != evidence.Input.SourceVersion || + !validEvidenceStatusTransition(event.Supported, event.Reason) { + return nil, fmt.Errorf("evidence %q has an invalid latest status event", resolverEvidenceKey(evidence)) + } + current := statuses[tuple] + if event.ID == current.eventID && current.eventID != 0 && + (event.Supported != current.supported || event.Reason != current.reason) { + return nil, fmt.Errorf("evidence %q has conflicting status event %d", event.EvidenceKey, event.ID) + } + if event.ID > current.eventID { + current.supported = event.Supported + current.eventID = event.ID + current.reason = event.Reason + statuses[tuple] = current + } + } + } + return statuses, nil +} + +func effectiveEvidenceTupleKey(evidence Evidence) string { + return fmt.Sprintf("%d\x00%s\x00%s", evidence.PersonID, resolverEvidenceKey(evidence), evidence.Input.SourceVersion) +} + +func resolverEvidenceKey(evidence Evidence) string { + if evidence.Key != "" { + return evidence.Key + } + if key, err := EvidenceKey(evidence.Input); err == nil { + return key + } + encoded, err := json.Marshal(evidenceKeyView(evidence.Input)) + if err != nil { + return "" + } + return fingerprint(encoded) +} + +func bootstrapDeclaredPin(input ResolutionInput) bool { + for _, current := range activeResolverCurrent(input) { + if current.Declared { + return true + } + } + return false +} + +func activeResolverCurrent(input ResolutionInput) []CurrentProjection { + current := make([]CurrentProjection, 0, len(input.Current)) + for _, item := range input.Current { + if item.ActiveUntil == nil || input.ResolvedAt.Before(*item.ActiveUntil) { + current = append(current, item) + } + } + sort.Slice(current, func(i, j int) bool { + if current[i].Normalized.Fingerprint != current[j].Normalized.Fingerprint { + return current[i].Normalized.Fingerprint < current[j].Normalized.Fingerprint + } + if current[i].Ref.Kind != current[j].Ref.Kind { + return current[i].Ref.Kind < current[j].Ref.Kind + } + return current[i].Ref.RowID < current[j].Ref.RowID + }) + return current +} + +func resolverClaimSortKey(claim ResolvedClaim) string { + evidenceKeys := make([]string, len(claim.Evidence)) + for index := range claim.Evidence { + evidenceKeys[index] = resolverEvidenceKey(claim.Evidence[index]) + } + sort.Strings(evidenceKeys) + return claim.Claim.Target.Key + "\x00" + normalizedFingerprint(claim.Normalized) + "\x00" + + strings.Join(evidenceKeys, "\x00") + "\x00" + claim.ClaimKey +} + +func resolverTargetRef(target TargetDescriptor) TargetRef { + return TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision} +} + +func validateResolverPolicy(policy Policy) error { + if policy.Version == "" { + return errors.New("resolver policy version is required") + } + if policy.ApplyThreshold < 0 || policy.ReplacementMargin < 0 || policy.HysteresisMargin < 0 || + policy.MinimumIdentityScore < 0 || policy.MinimumIdentityScore > 1000 || + policy.MaxCorroborationBonus < 0 { + return errors.New("resolver policy contains an out-of-range value") + } + if err := validatePolicyMap(policy.SourceClassWeights, + []EvidenceSourceClass{EvidenceArchive, EvidencePublic, EvidenceSystem, EvidenceProviderAssertion}); err != nil { + return fmt.Errorf("source class weights: %w", err) + } + if err := validatePolicyMap(policy.DirectnessWeights, + []EvidenceDirectness{DirectSelf, DirectOther, Indirect}); err != nil { + return fmt.Errorf("directness weights: %w", err) + } + if err := validatePolicyMap(policy.AuthorityWeights, + []EvidenceAuthority{AuthorityAuthoritative, AuthorityOrdinary, AuthorityAggregator}); err != nil { + return fmt.Errorf("authority weights: %w", err) + } + if policy.Version == ResolverVersionV1 && !sameResolverPolicy(policy, DefaultPolicyV1()) { + return errors.New("resolver-v1 policy must match its frozen definition") + } + return nil +} + +func sameResolverPolicy(left, right Policy) bool { + return left.Version == right.Version && + left.ApplyThreshold == right.ApplyThreshold && + left.ReplacementMargin == right.ReplacementMargin && + left.HysteresisMargin == right.HysteresisMargin && + left.MinimumIdentityScore == right.MinimumIdentityScore && + left.MaxCorroborationBonus == right.MaxCorroborationBonus && + samePolicyMap(left.SourceClassWeights, right.SourceClassWeights) && + samePolicyMap(left.DirectnessWeights, right.DirectnessWeights) && + samePolicyMap(left.AuthorityWeights, right.AuthorityWeights) +} + +func samePolicyMap[K comparable](left, right map[K]int) bool { + if len(left) != len(right) { + return false + } + for key, value := range left { + if right[key] != value { + return false + } + } + return true +} + +func validResolverRejectionFailure(failure *ValidationFailure) bool { + if !validValidationFailure(failure) { + return false + } + switch failure.Action { + case DecisionInvalid: + return failure.Reason == ReasonMalformedValue || + failure.Reason == ReasonUnsupportedTarget || + failure.Reason == ReasonStaleTargetRevision || + failure.Reason == ReasonUnalignedEvidence + case DecisionIdentityRejected: + return failure.Reason == ReasonIdentityMismatch + case DecisionPolicyRejected: + return failure.Reason == ReasonSensitivePolicy + default: + return false + } +} + +func validatePolicyMap[K comparable](weights map[K]int, allowed []K) error { + if len(weights) != len(allowed) { + return errors.New("weight map must contain exactly the closed vocabulary") + } + for _, key := range allowed { + weight, exists := weights[key] + if !exists || weight < 0 { + return errors.New("weight map is missing a key or contains a negative weight") + } + } + return nil +} + +func validateResolutionInput(input ResolutionInput) error { + if input.PersonID <= 0 { + return errors.New("resolution person id must be positive") + } + if input.ResolvedAt.IsZero() || !isUTC(input.ResolvedAt) { + return errors.New("resolution time must be nonzero UTC") + } + if input.Target.Key == "" || input.Target.Revision == "" { + return errors.New("resolution target key and revision are required") + } + if validUnsupportedTargetRejectionInput(input) { + return nil + } + if input.Target.Kind != TargetAttribute && input.Target.Kind != TargetEmployment { + return fmt.Errorf("unsupported resolution target kind %q", input.Target.Kind) + } + if input.Target.Cardinality != CardinalitySingle && input.Target.Cardinality != CardinalityMulti { + return fmt.Errorf("unsupported resolution cardinality %q", input.Target.Cardinality) + } + if input.Target.Kind == TargetEmployment { + if input.Target.ValueType != ValueEmployment || input.Target.Cardinality != CardinalityMulti { + return errors.New("employment resolution target must be a multi-value employment target") + } + } else { + switch input.Target.ValueType { + case ValueText, ValueInteger, ValueReal, ValueBoolean, ValueDate, ValueTimestamp: + default: + return fmt.Errorf("unsupported attribute resolution value type %q", input.Target.ValueType) + } + } + if input.Pin.Target != (TargetRef{}) && input.Pin.Target != resolverTargetRef(input.Target) { + return errors.New("pin target does not match resolution target") + } + if input.Pin.EventID != nil && *input.Pin.EventID <= 0 { + return errors.New("pin event id must be positive") + } + return nil +} + +func validUnsupportedTargetRejectionInput(input ResolutionInput) bool { + if len(input.Current) != 0 || input.Pin != (PinState{}) || len(input.Claims) == 0 { + return false + } + for _, claim := range input.Claims { + if claim.Failure == nil || claim.Failure.Action != DecisionInvalid || + claim.Failure.Reason != ReasonUnsupportedTarget { + return false + } + } + return true +} + +func copyResolverPolicy(policy Policy) Policy { + cloned := policy + cloned.SourceClassWeights = copyPolicyMap(policy.SourceClassWeights) + cloned.DirectnessWeights = copyPolicyMap(policy.DirectnessWeights) + cloned.AuthorityWeights = copyPolicyMap(policy.AuthorityWeights) + return cloned +} + +func copyPolicyMap[K comparable](input map[K]int) map[K]int { + cloned := make(map[K]int, len(input)) + maps.Copy(cloned, input) + return cloned +} + +type resolverFingerprintStatus struct { + PersonID int64 `json:"person_id"` + EvidenceKey string `json:"evidence_key"` + SourceVersion string `json:"source_version"` + Supported bool `json:"supported"` + EventID int64 `json:"latest_status_event_id"` + Reason EvidenceStatusReason `json:"reason"` +} + +type resolverFingerprintEvidence struct { + Key string `json:"key"` + Input canonicalEvidence `json:"input"` + Status resolverFingerprintStatus `json:"effective_status"` +} + +type resolverFingerprintClaim struct { + ClaimKey string `json:"claim_key"` + Target TargetRef `json:"target"` + Relation ClaimRelation `json:"relation"` + Submitted string `json:"submitted"` + Normalized *canonicalNormalized `json:"normalized"` + Evidence []resolverFingerprintEvidence `json:"evidence"` + ValidFrom *string `json:"valid_from"` + ValidUntil *string `json:"valid_until"` + Origin ClaimOrigin `json:"origin"` + Confidence ConfidenceInputs `json:"confidence"` + Failure *canonicalFailure `json:"failure"` + sortKey string +} + +type resolverFingerprintCurrent struct { + Ref ProjectionRef `json:"ref"` + Normalized canonicalNormalized `json:"normalized"` + ActiveFrom string `json:"active_from"` + ActiveUntil *string `json:"active_until"` + TransactionTime string `json:"transaction_time"` + Declared bool `json:"declared"` +} + +func resolverInputFingerprint( + input ResolutionInput, policy Policy, statuses map[string]effectiveEvidenceStatus, +) (string, error) { + current := make([]resolverFingerprintCurrent, len(input.Current)) + for index, item := range input.Current { + current[index] = resolverFingerprintCurrent{ + Ref: item.Ref, + Normalized: canonicalNormalized{JSON: append(json.RawMessage(nil), item.Normalized.JSON...), + Fingerprint: item.Normalized.Fingerprint}, + ActiveFrom: portableFactTime(item.ActiveFrom).Format(time.RFC3339Nano), + ActiveUntil: canonicalTimePointer(item.ActiveUntil), + TransactionTime: portableFactTime(item.TransactionTime).Format(time.RFC3339Nano), Declared: item.Declared, + } + } + sort.Slice(current, func(i, j int) bool { + left, _ := json.Marshal(current[i]) + right, _ := json.Marshal(current[j]) + return string(left) < string(right) + }) + + claims := make([]resolverFingerprintClaim, len(input.Claims)) + for index, item := range input.Claims { + view := resolverFingerprintClaim{ + ClaimKey: item.ClaimKey, Target: resolverTargetRef(item.Claim.Target), + Relation: item.Claim.Relation, Submitted: resolverSubmittedValue(item.Claim.SubmittedValue), + ValidFrom: canonicalTimePointer(item.Claim.ValidFrom), ValidUntil: canonicalTimePointer(item.Claim.ValidUntil), + Origin: item.Claim.Origin, Confidence: item.Claim.Confidence, + Evidence: []resolverFingerprintEvidence{}, + sortKey: resolverClaimSortKey(item), + } + if item.Normalized != nil { + view.Normalized = &canonicalNormalized{ + JSON: append(json.RawMessage(nil), item.Normalized.JSON...), Fingerprint: item.Normalized.Fingerprint, + } + } + if item.Failure != nil { + view.Failure = &canonicalFailure{ + Action: item.Failure.Action, Reason: item.Failure.Reason, Detail: item.Failure.Detail, + } + } + for _, evidence := range item.Evidence { + status := statuses[effectiveEvidenceTupleKey(evidence)] + view.Evidence = append(view.Evidence, resolverFingerprintEvidence{ + Key: resolverEvidenceKey(evidence), Input: evidenceKeyView(evidence.Input), + Status: resolverFingerprintStatus{ + PersonID: status.personID, EvidenceKey: status.key, SourceVersion: status.sourceVersion, + Supported: status.supported, EventID: status.eventID, Reason: status.reason, + }, + }) + } + sort.Slice(view.Evidence, func(i, j int) bool { + left, _ := json.Marshal(view.Evidence[i]) + right, _ := json.Marshal(view.Evidence[j]) + return string(left) < string(right) + }) + claims[index] = view + } + sort.Slice(claims, func(i, j int) bool { + return claims[i].sortKey < claims[j].sortKey + }) + + encoded, err := json.Marshal(struct { + PersonID int64 `json:"person_id"` + Target TargetDescriptor `json:"target"` + ResolvedAt string `json:"resolved_at"` + Policy Policy `json:"resolver_policy"` + PolicyContext PolicyContext `json:"policy_context"` + ProjectionContextFingerprint string `json:"projection_context_fingerprint"` + Current []resolverFingerprintCurrent `json:"current"` + Claims []resolverFingerprintClaim `json:"claims"` + Pin PinState `json:"pin"` + }{ + PersonID: input.PersonID, Target: canonicalTarget(input.Target), + ResolvedAt: portableFactTime(input.ResolvedAt).Format(time.RFC3339Nano), Policy: policy, + PolicyContext: input.Policy, ProjectionContextFingerprint: input.ProjectionContextFingerprint, + Current: current, Claims: claims, Pin: input.Pin, + }) + if err != nil { + return "", fmt.Errorf("encode resolver input fingerprint: %w", err) + } + return fingerprint(encoded), nil +} + +func resolverSubmittedValue(value json.RawMessage) string { + canonical, err := canonicalizeRawJSON(value) + if err != nil { + return string(value) + } + return string(canonical) +} diff --git a/internal/personfacts/resolver_test.go b/internal/personfacts/resolver_test.go new file mode 100644 index 000000000..bc2291e9b --- /dev/null +++ b/internal/personfacts/resolver_test.go @@ -0,0 +1,1254 @@ +package personfacts + +import ( + "bytes" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +var resolverTestNow = time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) + +func TestResolverV1DefaultPolicyAndScoring(t *testing.T) { + assertions := assert.New(t) + + policy := DefaultPolicyV1() + assertions.Equal(ResolverVersionV1, policy.Version) + assertions.Equal(750, policy.ApplyThreshold) + assertions.Equal(100, policy.ReplacementMargin) + assertions.Equal(75, policy.HysteresisMargin) + assertions.Equal(900, policy.MinimumIdentityScore) + assertions.Equal(150, policy.MaxCorroborationBonus) + assertions.Equal(map[EvidenceSourceClass]int{ + EvidenceArchive: 300, EvidencePublic: 250, EvidenceSystem: 500, + EvidenceProviderAssertion: 150, + }, policy.SourceClassWeights) + assertions.Equal(map[EvidenceDirectness]int{ + DirectSelf: 400, DirectOther: 200, Indirect: 0, + }, policy.DirectnessWeights) + assertions.Equal(map[EvidenceAuthority]int{ + AuthorityAuthoritative: 300, AuthorityOrdinary: 100, AuthorityAggregator: 0, + }, policy.AuthorityWeights) + + t.Run("recent direct self beats authoritative public evidence", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("authoritative", "public", 0, + resolverTestEvidence("public", EvidencePublic, DirectOther, AuthorityAuthoritative, 0)), + resolverTestClaim("direct-self", "self", 800, + resolverTestEvidence("self", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + direct := resolverTestDecision(t, resolution, "direct-self", DecisionApplied) + assert.Equal(ScoreBreakdown{ + SourceClass: 300, Directness: 400, Authority: 100, + Confidence: 80, Freshness: 100, Total: 980, + }, direct.Score) + authoritative := resolverTestDecision(t, resolution, "authoritative", DecisionSuperseded) + assert.Equal(850, authoritative.Score.Total) + assert.Equal("direct-self", authoritative.CompetingClaimKey) + require.Len(t, resolution.Projections, 1) + assert.Equal("direct-self", resolution.Projections[0].ClaimKey) + }) + + t.Run("authoritative employment applies", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestMultiTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("employment", "engineer", 900, + resolverTestEvidence("company", EvidencePublic, DirectOther, AuthorityAuthoritative, 0))} + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "employment", DecisionApplied) + assert.Equal(940, decision.Score.Total) + require.Len(t, resolution.Projections, 1) + assert.Equal(ProjectionSet, resolution.Projections[0].Operation) + }) + + t.Run("future evidence receives no freshness bonus", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("future", "value", 1000, + resolverTestEvidence("future", EvidencePublic, DirectOther, + AuthorityOrdinary, -365*24*time.Hour))} + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "future", DecisionRetained) + assert.Equal(ReasonBelowThreshold, decision.Reason) + assert.Equal(0, decision.Score.Freshness) + assert.Equal(650, decision.Score.Total) + assert.Empty(resolution.Projections) + }) + + for _, test := range []struct { + name string + evidence Evidence + wantScore int + }{ + { + name: "weak aggregator remains below threshold", + evidence: resolverTestEvidence("directory", EvidencePublic, Indirect, + AuthorityAggregator, 0), + wantScore: 450, + }, + { + name: "uncited provider assertion cannot apply itself", + evidence: resolverTestEvidence("provider", EvidenceProviderAssertion, Indirect, + AuthorityOrdinary, 0), + wantScore: 450, + }, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, test.evidence)} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", DecisionRetained) + assert.Equal(ReasonBelowThreshold, decision.Reason) + assert.Equal(test.wantScore, decision.Score.Total) + assert.Empty(resolution.Projections) + }) + } +} + +func TestResolverV1InputFingerprintUsesPortableTimestampPrecision(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + input.ResolvedAt = time.Date(2025, time.May, 3, 4, 5, 6, 123456111, time.UTC) + evidence := resolverTestEvidence("portable-time", EvidencePublic, DirectOther, + AuthorityAuthoritative, 0) + evidence.Input.EventTime = time.Date(2025, time.May, 1, 2, 3, 4, 234567222, time.UTC) + evidence.Input.RecordedTime = time.Date(2025, time.May, 2, 3, 4, 5, 345678333, time.UTC) + claim := resolverTestClaim("portable-time", "value", 900, evidence) + validFrom := time.Date(2024, time.May, 1, 0, 0, 0, 456789444, time.UTC) + validUntil := time.Date(2026, time.May, 1, 0, 0, 0, 567891555, time.UTC) + claim.Claim.ValidFrom, claim.Claim.ValidUntil = &validFrom, &validUntil + input.Claims = []ResolvedClaim{claim} + current := resolverTestCurrent("portable-current", false, 42) + current.ActiveFrom = time.Date(2024, time.May, 2, 0, 0, 0, 678912666, time.UTC) + activeUntil := time.Date(2026, time.May, 2, 0, 0, 0, 789123777, time.UTC) + current.ActiveUntil = &activeUntil + current.TransactionTime = time.Date(2025, time.May, 2, 0, 0, 0, 891234888, time.UTC) + input.Current = []CurrentProjection{current} + + first := resolverTestResolve(t, input) + input.ResolvedAt = input.ResolvedAt.Add(10 * time.Nanosecond) + input.Claims[0].Evidence[0].Input.EventTime = input.Claims[0].Evidence[0].Input.EventTime.Add(10 * time.Nanosecond) + input.Claims[0].Evidence[0].Input.RecordedTime = input.Claims[0].Evidence[0].Input.RecordedTime.Add(10 * time.Nanosecond) + *input.Claims[0].Claim.ValidFrom = input.Claims[0].Claim.ValidFrom.Add(10 * time.Nanosecond) + *input.Claims[0].Claim.ValidUntil = input.Claims[0].Claim.ValidUntil.Add(10 * time.Nanosecond) + input.Current[0].ActiveFrom = input.Current[0].ActiveFrom.Add(10 * time.Nanosecond) + *input.Current[0].ActiveUntil = input.Current[0].ActiveUntil.Add(10 * time.Nanosecond) + input.Current[0].TransactionTime = input.Current[0].TransactionTime.Add(10 * time.Nanosecond) + second := resolverTestResolve(t, input) + assert.Equal(t, first.InputFingerprint, second.InputFingerprint) +} + +func TestResolverV1InputFingerprintPreservesLegacyPolicyEncoding(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + encoded, err := json.Marshal(DefaultPolicyV1()) + requirements.NoError(err) + assertions.True(bytes.Equal([]byte(`{"Version":"person-fact-resolver-v1","ApplyThreshold":750,"ReplacementMargin":100,"HysteresisMargin":75,"MinimumIdentityScore":900,"MaxCorroborationBonus":150,"SourceClassWeights":{"archive":300,"provider_assertion":150,"public":250,"system":500},"DirectnessWeights":{"direct-other":200,"direct-self":400,"indirect":0},"AuthorityWeights":{"aggregator":0,"authoritative":300,"ordinary":100}}`), encoded), + "resolver policy encoding changed") + + resolution := resolverTestResolve(t, legacyFingerprintResolutionInput()) + assertions.Equal("sha256:d399cb5ff33c2adf563fc4934cb2d2f7d3bb64dd5246c9d051006c30bfa912d7", + resolution.InputFingerprint) +} + +func TestResolverV1UsesFixedFreshnessTime(t *testing.T) { + tests := []struct { + name string + age time.Duration + want int + }{ + {name: "thirty days", age: 30 * 24 * time.Hour, want: 100}, + {name: "after thirty days", age: 30*24*time.Hour + time.Nanosecond, want: 60}, + {name: "one hundred eighty days", age: 180 * 24 * time.Hour, want: 60}, + {name: "after one hundred eighty days", age: 180*24*time.Hour + time.Nanosecond, want: 20}, + {name: "seven hundred thirty days", age: 730 * 24 * time.Hour, want: 20}, + {name: "after seven hundred thirty days", age: 730*24*time.Hour + time.Nanosecond, want: 0}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := resolverTestInput(resolverTestMultiTarget()) + evidence := resolverTestEvidence("system", EvidenceSystem, DirectSelf, AuthorityOrdinary, test.age) + evidence.Input.RecordedTime = input.ResolvedAt.Add(365 * 24 * time.Hour) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, evidence)} + + decision := resolverTestDecision(t, resolverTestResolve(t, input), "claim", DecisionApplied) + assert.Equal(t, test.want, decision.Score.Freshness) + }) + } +} + +func TestResolverV1CorroborationUsesIndependentSources(t *testing.T) { + first := resolverTestEvidence("first-span", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + first.Input.SourceRef = "message-1" + secondSpan := resolverTestEvidence("second-span", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + secondSpan.Input.SourceRef = "message-1" + independent := resolverTestEvidence("second-message", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + independent.Input.SourceRef = "message-2" + + input := resolverTestInput(resolverTestMultiTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("duplicate-source", "one", 0, first, secondSpan)} + decision := resolverTestDecision(t, resolverTestResolve(t, input), "duplicate-source", DecisionApplied) + assert.Equal(t, 0, decision.Score.Corroboration) + + input.Claims[0].Evidence = append(input.Claims[0].Evidence, independent) + decision = resolverTestDecision(t, resolverTestResolve(t, input), "duplicate-source", DecisionApplied) + assert.Equal(t, 50, decision.Score.Corroboration) + + for index := 3; index <= 7; index++ { + extra := resolverTestEvidence("source-"+string(rune('0'+index)), EvidenceArchive, + DirectSelf, AuthorityOrdinary, 0) + extra.Input.SourceRef = "message-" + string(rune('0'+index)) + input.Claims[0].Evidence = append(input.Claims[0].Evidence, extra) + } + decision = resolverTestDecision(t, resolverTestResolve(t, input), "duplicate-source", DecisionApplied) + assert.Equal(t, 150, decision.Score.Corroboration) +} + +func TestResolverV1PinAndExplicitUnpin(t *testing.T) { + target := resolverTestSingleTarget() + current := resolverTestCurrent("old", true, 11) + claim := resolverTestClaim("new", "new", 1000, + resolverTestEvidence("new", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)) + + t.Run("declared current is initially pinned", func(t *testing.T) { + input := resolverTestInput(target) + input.Current = []CurrentProjection{current} + input.Claims = []ResolvedClaim{claim} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "new", DecisionRetained) + assert.Equal(t, ReasonPinRetained, decision.Reason) + assert.Empty(t, resolution.Projections) + }) + + t.Run("explicit pin short circuits projection", func(t *testing.T) { + input := resolverTestInput(target) + input.Current = []CurrentProjection{resolverTestCurrent("old", false, 11)} + input.Claims = []ResolvedClaim{claim} + eventID := int64(21) + input.Pin = PinState{Target: resolverTestTargetRef(target), Pinned: true, Actor: "user", EventID: &eventID} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "new", DecisionRetained) + assert.Equal(t, ReasonPinRetained, decision.Reason) + assert.Empty(t, resolution.Projections) + }) + + t.Run("explicit unpin overrides declared bootstrap pin", func(t *testing.T) { + input := resolverTestInput(target) + input.Current = []CurrentProjection{current} + input.Claims = []ResolvedClaim{claim} + eventID := int64(22) + input.Pin = PinState{Target: resolverTestTargetRef(target), Pinned: false, Actor: "user", EventID: &eventID} + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "new", DecisionApplied) + require.Len(t, resolution.Projections, 1) + assert.Equal(t, current.Ref, *resolution.Projections[0].CurrentRef) + }) +} + +func TestResolverV1SingleValueThresholdTieAndMargins(t *testing.T) { + t.Run("exact threshold applies and one point below is retained", func(t *testing.T) { + for _, test := range []struct { + name string + confidence int + action DecisionAction + reason DecisionReason + }{ + {name: "threshold", confidence: 1000, action: DecisionApplied, reason: ReasonAppliedProjection}, + {name: "below", confidence: 990, action: DecisionRetained, reason: ReasonBelowThreshold}, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", test.confidence, + resolverTestEvidence("claim", EvidencePublic, DirectOther, AuthorityOrdinary, 0))} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", test.action) + assert.Equal(test.reason, decision.Reason) + assert.Equal(650+test.confidence/10, decision.Score.Total) + }) + } + }) + + t.Run("equal winners are ambiguous", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("alpha", "alpha", 1000, + resolverTestEvidence("alpha", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaim("beta", "beta", 1000, + resolverTestEvidence("beta", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + resolution := resolverTestResolve(t, input) + alpha := resolverTestDecision(t, resolution, "alpha", DecisionAmbiguousRetained) + beta := resolverTestDecision(t, resolution, "beta", DecisionAmbiguousRetained) + assert.Equal(ReasonCompetingTie, alpha.Reason) + assert.Equal("beta", alpha.CompetingClaimKey) + assert.Equal("alpha", beta.CompetingClaimKey) + assert.Empty(resolution.Projections) + }) + + t.Run("winner must clear replacement margin over runner up", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("winner", "winner", 800, + resolverTestEvidence("winner", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaim("runner", "runner", 400, + resolverTestEvidence("runner", EvidencePublic, DirectOther, AuthorityAuthoritative, 0)), + } + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "winner", DecisionRetained) + assert.Equal(980, decision.Score.Total) + assert.Equal(ReasonInsufficientMargin, decision.Reason) + assert.Equal("runner", decision.CompetingClaimKey) + assert.Empty(resolution.Projections) + + input.Claims[1].Claim.Confidence.ReportedScore = 300 + resolution = resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "winner", DecisionApplied) + require.Len(t, resolution.Projections, 1) + }) + + t.Run("different current requires replacement and hysteresis margins", func(t *testing.T) { + for _, test := range []struct { + name string + confidence int + action DecisionAction + }{ + {name: "one point short", confidence: 740, action: DecisionRetained}, + {name: "exact combined margin", confidence: 750, action: DecisionApplied}, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("old", false, 31)} + input.Claims = []ResolvedClaim{resolverTestClaim("new", "new", test.confidence, + resolverTestEvidence("new", EvidencePublic, DirectSelf, AuthorityOrdinary, 0))} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "new", test.action) + assert.Equal(850+test.confidence/10, decision.Score.Total) + if test.action == DecisionRetained { + assert.Equal(ReasonInsufficientMargin, decision.Reason) + assert.Empty(resolution.Projections) + } else { + require.Len(t, resolution.Projections, 1) + } + }) + } + }) +} + +func TestResolverV1MultiValueAddsAndExplicitRetirements(t *testing.T) { + target := resolverTestMultiTarget() + input := resolverTestInput(target) + input.Current = []CurrentProjection{ + resolverTestCurrent("alpha", false, 41), + resolverTestCurrent("beta", false, 42), + } + input.Claims = []ResolvedClaim{ + resolverTestClaim("zeta-add", "zeta", 1000, + resolverTestEvidence("zeta", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaim("alpha-existing", "alpha", 1000, + resolverTestEvidence("alpha", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("beta-retire", "beta", RelationContradict, 1000, + resolverTestEvidence("beta-retire", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("omega-add", "omega", RelationSupport, 1000, + resolverTestEvidence("omega", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "alpha-existing", DecisionApplied) + resolverTestDecision(t, resolution, "beta-retire", DecisionApplied) + assert.Equal(t, []ProjectionPlan{ + { + Operation: ProjectionRetire, Target: resolverTestTargetRef(target), ClaimKey: "beta-retire", + CurrentRef: new(input.Current[1].Ref), ActiveFrom: resolverTestNow, + }, + {Operation: ProjectionSet, Target: resolverTestTargetRef(target), ClaimKey: "omega-add", ActiveFrom: resolverTestNow}, + {Operation: ProjectionSet, Target: resolverTestTargetRef(target), ClaimKey: "zeta-add", ActiveFrom: resolverTestNow}, + }, resolution.Projections) +} + +func TestResolverV1MultiValueNeverRetiresByOmission(t *testing.T) { + assert := assert.New(t) + + target := resolverTestMultiTarget() + input := resolverTestInput(target) + input.Current = []CurrentProjection{ + resolverTestCurrent("alpha", false, 51), + resolverTestCurrent("beta", false, 52), + } + input.Claims = []ResolvedClaim{resolverTestClaim("alpha", "alpha", 1000, + resolverTestEvidence("alpha", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0))} + + resolution := resolverTestResolve(t, input) + assert.Empty(resolution.Projections) + + weak := resolverTestClaimWithRelation("weak-retire", "beta", RelationSupersede, 0, + resolverTestEvidence("weak-retire", EvidencePublic, Indirect, AuthorityAggregator, 0)) + input.Claims = append(input.Claims, weak) + resolution = resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "weak-retire", DecisionRetained) + assert.Equal(ReasonBelowThreshold, decision.Reason) + assert.Empty(resolution.Projections) + + input.Claims[1] = resolverTestClaimWithRelation("strong-retire", "beta", RelationSupersede, 1000, + resolverTestEvidence("strong-retire", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)) + resolution = resolverTestResolve(t, input) + decision = resolverTestDecision(t, resolution, "strong-retire", DecisionApplied) + assert.Equal(ReasonExplicitSupersession, decision.Reason) + require.Len(t, resolution.Projections, 1) + assert.Equal(ProjectionRetire, resolution.Projections[0].Operation) +} + +func TestResolverV1MultiValueCompetesDirectionsPerFingerprint(t *testing.T) { + strongEvidence := func(key string) Evidence { + return resolverTestEvidence(key, EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + } + weakEvidence := func(key string) Evidence { + return resolverTestEvidence(key, EvidencePublic, Indirect, AuthorityAggregator, 0) + } + + for _, current := range []bool{false, true} { + state := "absent" + if current { + state = "current" + } + t.Run("equal directions are ambiguous when "+state, func(t *testing.T) { + input := resolverTestInput(resolverTestMultiTarget()) + if current { + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 61)} + } + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 800, strongEvidence("support")), + resolverTestClaimWithRelation( + "negative", "same", RelationContradict, 800, strongEvidence("negative")), + } + + resolution := resolverTestResolve(t, input) + support := resolverTestDecision(t, resolution, "support", DecisionAmbiguousRetained) + negative := resolverTestDecision(t, resolution, "negative", DecisionAmbiguousRetained) + assert.Equal(t, ReasonCompetingTie, support.Reason) + assert.Equal(t, ReasonCompetingTie, negative.Reason) + assert.Empty(t, resolution.Projections) + }) + + t.Run("support winner is state independent when "+state, func(t *testing.T) { + input := resolverTestInput(resolverTestMultiTarget()) + if current { + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 62)} + } + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 1000, strongEvidence("support")), + resolverTestClaimWithRelation( + "negative", "same", RelationContradict, 0, weakEvidence("negative")), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "support", DecisionApplied) + resolverTestDecision(t, resolution, "negative", DecisionSuperseded) + if current { + assert.Empty(t, resolution.Projections) + } else { + require.Len(t, resolution.Projections, 1) + assert.Equal(t, ProjectionSet, resolution.Projections[0].Operation) + } + }) + + t.Run("negative winner is state independent when "+state, func(t *testing.T) { + input := resolverTestInput(resolverTestMultiTarget()) + if current { + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 63)} + } + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 0, weakEvidence("support")), + resolverTestClaimWithRelation( + "negative", "same", RelationSupersede, 1000, strongEvidence("negative")), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "negative", DecisionApplied) + resolverTestDecision(t, resolution, "support", DecisionSuperseded) + if current { + require.Len(t, resolution.Projections, 1) + assert.Equal(t, ProjectionRetire, resolution.Projections[0].Operation) + } else { + assert.Empty(t, resolution.Projections) + } + }) + } +} + +func TestResolverV1MultiValueAggregatesDirectionsAndKeepsFingerprintsIndependent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + input := resolverTestInput(resolverTestMultiTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("retire", false, 64)} + input.Claims = []ResolvedClaim{ + resolverTestClaim("support-first", "add", 500, + resolverTestEvidence("support-first", EvidencePublic, Indirect, AuthorityAuthoritative, 0)), + resolverTestClaim("support-second", "add", 500, + resolverTestEvidence("support-second", EvidencePublic, Indirect, AuthorityAuthoritative, 0)), + resolverTestClaimWithRelation("add-negative", "add", RelationContradict, 0, + resolverTestEvidence("add-negative", EvidencePublic, Indirect, AuthorityAuthoritative, 0)), + resolverTestClaimWithRelation("retire", "retire", RelationSupersede, 1000, + resolverTestEvidence("retire", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + first := resolverTestDecision(t, resolution, "support-first", DecisionApplied) + second := resolverTestDecision(t, resolution, "support-second", DecisionSuperseded) + assert.Equal(50, first.Score.Corroboration) + assert.Equal(750, first.Score.Total) + assert.Equal(first.Score, second.Score) + resolverTestDecision(t, resolution, "add-negative", DecisionSuperseded) + resolverTestDecision(t, resolution, "retire", DecisionApplied) + require.Len(resolution.Projections, 2) + assert.ElementsMatch([]ProjectionOperation{ProjectionSet, ProjectionRetire}, []ProjectionOperation{ + resolution.Projections[0].Operation, resolution.Projections[1].Operation, + }) +} + +func TestResolverV1ValidityWindowUsesInclusiveStartAndExclusiveEnd(t *testing.T) { + tests := []struct { + name string + validFrom *time.Time + validUntil *time.Time + action DecisionAction + reason DecisionReason + }{ + { + name: "future start is outside validity", validFrom: new(resolverTestNow.Add(time.Microsecond)), + action: DecisionRetained, reason: ReasonOutsideValidity, + }, + { + name: "exact start is eligible", validFrom: new(resolverTestNow), + action: DecisionApplied, reason: ReasonAppliedProjection, + }, + { + name: "exact end is expired", validUntil: new(resolverTestNow), + action: DecisionRetained, reason: ReasonOutsideValidity, + }, + { + name: "past end is expired", validUntil: new(resolverTestNow.Add(-time.Microsecond)), + action: DecisionRetained, reason: ReasonOutsideValidity, + }, + { + name: "before end is eligible", validUntil: new(resolverTestNow.Add(time.Microsecond)), + action: DecisionApplied, reason: ReasonAppliedProjection, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := resolverTestInput(resolverTestMultiTarget()) + claim := resolverTestClaim("claim", "value", 1000, + resolverTestEvidence("claim", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)) + claim.Claim.ValidFrom = test.validFrom + claim.Claim.ValidUntil = test.validUntil + input.Claims = []ResolvedClaim{claim} + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", test.action) + assert.Equal(t, test.reason, decision.Reason) + if test.action == DecisionRetained { + assert.Empty(t, resolution.Projections) + } else { + require.Len(t, resolution.Projections, 1) + } + }) + } +} + +func TestResolverV1AllowsOnlyRejectionOnlyUnsupportedTargets(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + target := resolverTestSingleTarget() + target.Kind = TargetKind("candidate") + target.Key = "system:candidate" + target.UniversalID = target.Key + claim := resolverTestClaim("unsupported", "candidate", 1000) + claim.Claim.Target = target + claim.Normalized = nil + claim.Failure = &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonUnsupportedTarget, Detail: "unsupported target", + } + input := resolverTestInput(target) + input.Pin = PinState{} + input.Claims = []ResolvedClaim{claim} + resolver, err := NewResolver(DefaultPolicyV1()) + require.NoError(err) + + resolution, err := resolver.Resolve(input) + require.NoError(err) + require.Len(resolution.Decisions, 1) + assert.Equal(DecisionInvalid, resolution.Decisions[0].Action) + assert.Equal(ReasonUnsupportedTarget, resolution.Decisions[0].Reason) + assert.Empty(resolution.Projections) + + for _, mutate := range []func(*ResolutionInput){ + func(value *ResolutionInput) { value.Current = []CurrentProjection{{}} }, + func(value *ResolutionInput) { value.Pin = PinState{Pinned: true} }, + func(value *ResolutionInput) { value.Claims[0].Failure = nil }, + func(value *ResolutionInput) { value.Claims[0].Failure.Reason = ReasonMalformedValue }, + } { + invalid := copyResolutionInput(input) + mutate(&invalid) + _, err := resolver.Resolve(invalid) + require.ErrorContains(err, "unsupported resolution target kind") + } +} + +func TestResolverV1RejectsInvalidIdentityAndSensitiveClaims(t *testing.T) { + tests := []struct { + name string + mutate func(*ResolutionInput) + action DecisionAction + reason DecisionReason + }{ + { + name: "missing subject", + mutate: func(input *ResolutionInput) { + input.Claims[0].Evidence[0].Input.SubjectPersonID = nil + }, + action: DecisionIdentityRejected, reason: ReasonIdentityMismatch, + }, + { + name: "wrong subject", + mutate: func(input *ResolutionInput) { + input.Claims[0].Evidence[0].Input.SubjectPersonID = new(int64(99)) + }, + action: DecisionIdentityRejected, reason: ReasonIdentityMismatch, + }, + { + name: "identity below policy minimum", + mutate: func(input *ResolutionInput) { + input.Claims[0].Evidence[0].Input.IdentityScore = 899 + }, + action: DecisionIdentityRejected, reason: ReasonIdentityMismatch, + }, + { + name: "sensitive policy", + mutate: func(input *ResolutionInput) { + input.Target.Sensitive = true + input.Claims[0].Claim.Target.Sensitive = true + input.Policy.AllowSensitive = false + }, + action: DecisionPolicyRejected, reason: ReasonSensitivePolicy, + }, + { + name: "prepared invalid failure remains durable", + mutate: func(input *ResolutionInput) { + input.Claims[0].Claim.SubmittedValue = json.RawMessage(`{"broken"`) + input.Claims[0].Normalized = nil + input.Claims[0].Failure = &ValidationFailure{ + Action: DecisionInvalid, Reason: ReasonMalformedValue, Detail: "fixture failure", + } + }, + action: DecisionInvalid, reason: ReasonMalformedValue, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, + resolverTestEvidence("claim", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0))} + test.mutate(&input) + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", test.action) + assert.Equal(t, test.reason, decision.Reason) + assert.Empty(t, resolution.Projections) + }) + } +} + +func TestResolverV1EffectiveEvidenceSupport(t *testing.T) { + baseEvidence := resolverTestEvidence("evidence", EvidencePublic, DirectOther, AuthorityOrdinary, 0) + baseEvidence.Supported = false + + t.Run("insertion is supported without a status event", func(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, baseEvidence)} + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "claim", DecisionApplied) + }) + + t.Run("currently unsupported evidence is excluded", func(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + evidence := baseEvidence + evidence.LatestStatus = resolverTestStatus(evidence, 61, false, EvidenceStatusSourceDeleted) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, evidence)} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", DecisionRetained) + assert.Equal(t, ReasonEvidenceUnsupported, decision.Reason) + assert.Equal(t, ScoreBreakdown{}, decision.Score) + assert.Empty(t, resolution.Projections) + }) + + t.Run("later event reactivates the immutable evidence version", func(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + older := baseEvidence + older.LatestStatus = resolverTestStatus(older, 61, false, EvidenceStatusSourceDeleted) + later := baseEvidence + later.LatestStatus = resolverTestStatus(later, 62, true, EvidenceStatusSourceReimported) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, older, later)} + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "claim", DecisionApplied) + assert.Equal(t, 750, decision.Score.Total) + }) +} + +func TestResolverV1MateriallyNewEvidenceReconsidersClaim(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + first := resolverTestEvidence("first", EvidencePublic, Indirect, AuthorityAuthoritative, 0) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 500, first)} + + before := resolverTestResolve(t, input) + decision := resolverTestDecision(t, before, "claim", DecisionRetained) + assert.Equal(700, decision.Score.Total) + + second := resolverTestEvidence("second", EvidencePublic, Indirect, AuthorityAuthoritative, 0) + input.Claims[0].Evidence = append(input.Claims[0].Evidence, second) + after := resolverTestResolve(t, input) + decision = resolverTestDecision(t, after, "claim", DecisionApplied) + assert.Equal(50, decision.Score.Corroboration) + assert.Equal(750, decision.Score.Total) + assert.NotEqual(before.InputFingerprint, after.InputFingerprint) +} + +func TestResolverV1FingerprintChangesWithEffectiveStatusAndProjectionContext(t *testing.T) { + evidence := resolverTestEvidence("evidence", EvidencePublic, DirectOther, AuthorityOrdinary, 0) + input := resolverTestInput(resolverTestSingleTarget()) + input.ProjectionContextFingerprint = `{"organization_matches":[{"id":2,"status":"active"}]}` + evidence.LatestStatus = resolverTestStatus(evidence, 71, false, EvidenceStatusScopeUnlinked) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, evidence)} + unsupported := resolverTestResolve(t, input) + + input.Claims[0].Evidence[0].LatestStatus = resolverTestStatus(evidence, 72, true, EvidenceStatusScopeRelinked) + reactivated := resolverTestResolve(t, input) + assert.NotEqual(t, unsupported.InputFingerprint, reactivated.InputFingerprint) + + input.ProjectionContextFingerprint = `{"organization_matches":[{"id":3,"status":"active"}]}` + changedProjectionContext := resolverTestResolve(t, input) + assert.NotEqual(t, reactivated.InputFingerprint, changedProjectionContext.InputFingerprint) +} + +func TestResolverV1ShuffledInputProducesByteIdenticalOutput(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + target := resolverTestMultiTarget() + input := resolverTestInput(target) + input.Current = []CurrentProjection{ + resolverTestCurrent("beta", false, 82), + resolverTestCurrent("alpha", false, 81), + } + firstEvidence := resolverTestEvidence("one-a", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + secondEvidence := resolverTestEvidence("one-b", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0) + input.Claims = []ResolvedClaim{ + resolverTestClaim("zeta", "zeta", 900, + resolverTestEvidence("zeta", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaim("one", "one", 900, firstEvidence, secondEvidence), + resolverTestClaimWithRelation("beta-retire", "beta", RelationContradict, 900, + resolverTestEvidence("beta-retire", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + first := resolverTestResolve(t, input) + shuffled := copyResolutionInput(input) + shuffled.Current[0], shuffled.Current[1] = shuffled.Current[1], shuffled.Current[0] + shuffled.Claims[0], shuffled.Claims[2] = shuffled.Claims[2], shuffled.Claims[0] + shuffled.Claims[1].Evidence[0], shuffled.Claims[1].Evidence[1] = + shuffled.Claims[1].Evidence[1], shuffled.Claims[1].Evidence[0] + second := resolverTestResolve(t, shuffled) + + firstJSON, err := json.Marshal(first) + require.NoError(err) + secondJSON, err := json.Marshal(second) + require.NoError(err) + assert.True(bytes.Equal(firstJSON, secondJSON), "shuffled output bytes differ") + assert.NotNil(first.Decisions) + assert.NotNil(first.Projections) +} + +func TestResolverV1ValidatesClosedPolicyAndInputRanges(t *testing.T) { + invalidPolicies := []func(*Policy){ + func(policy *Policy) { policy.Version = "" }, + func(policy *Policy) { policy.ApplyThreshold = -1 }, + func(policy *Policy) { policy.MinimumIdentityScore = 1001 }, + func(policy *Policy) { delete(policy.SourceClassWeights, EvidenceArchive) }, + func(policy *Policy) { policy.DirectnessWeights[EvidenceDirectness("unknown")] = 1 }, + func(policy *Policy) { policy.AuthorityWeights[AuthorityOrdinary] = -1 }, + } + for index, mutate := range invalidPolicies { + policy := DefaultPolicyV1() + mutate(&policy) + _, err := NewResolver(policy) + require.Error(t, err, index) + } + + resolver, err := NewResolver(DefaultPolicyV1()) + require.NoError(t, err) + invalidInputs := []func(*ResolutionInput){ + func(input *ResolutionInput) { input.PersonID = 0 }, + func(input *ResolutionInput) { input.ResolvedAt = time.Time{} }, + func(input *ResolutionInput) { input.ResolvedAt = input.ResolvedAt.In(time.FixedZone("fixture", 3600)) }, + func(input *ResolutionInput) { input.Target.Cardinality = Cardinality("unknown") }, + func(input *ResolutionInput) { input.Target.Kind = TargetKind("unknown") }, + func(input *ResolutionInput) { input.Target.ValueType = ValueType("unknown") }, + } + for index, mutate := range invalidInputs { + input := resolverTestInput(resolverTestSingleTarget()) + mutate(&input) + _, err := resolver.Resolve(input) + require.Error(t, err, index) + } +} + +func TestResolverV1GroupsSameValueClaimsBeforeSingleValueCompetition(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("same-direct", "same", 800, + resolverTestEvidence("same-direct", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaim("same-authoritative", "same", 800, + resolverTestEvidence("same-authoritative", EvidencePublic, DirectOther, AuthorityAuthoritative, 0)), + resolverTestClaim("other", "other", 0, + resolverTestEvidence("other", EvidencePublic, DirectOther, AuthorityAuthoritative, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "same-direct", DecisionApplied) + resolverTestDecision(t, resolution, "same-authoritative", DecisionSuperseded) + require.Len(t, resolution.Projections, 1) + assert.Equal("same-direct", resolution.Projections[0].ClaimKey) + + input = resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("corroborated-first", "corroborated", 500, + resolverTestEvidence("corroborated-first", EvidencePublic, Indirect, AuthorityAuthoritative, 0)), + resolverTestClaim("corroborated-second", "corroborated", 500, + resolverTestEvidence("corroborated-second", EvidencePublic, Indirect, AuthorityAuthoritative, 0)), + } + resolution = resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "corroborated-first", DecisionApplied) + assert.Equal(50, decision.Score.Corroboration) + assert.Equal(750, decision.Score.Total) + resolverTestDecision(t, resolution, "corroborated-second", DecisionSuperseded) +} + +func TestResolverV1SingleValueSupportAndNegativeUseOneCompetitionPolicy(t *testing.T) { + t.Run("negative cannot retire current below combined replacement and hysteresis margin", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 101)} + input.Claims = []ResolvedClaim{resolverTestClaimWithRelation( + "negative", "same", RelationContradict, 740, + resolverTestEvidence("negative", EvidencePublic, DirectSelf, AuthorityOrdinary, 0), + )} + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "negative", DecisionRetained) + assert.Equal(924, decision.Score.Total) + assert.Equal(ReasonExplicitContradiction, decision.Reason) + assert.Empty(resolution.Projections) + }) + + t.Run("equal positive and negative evidence retains current as ambiguous", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 102)} + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 800, + resolverTestEvidence("support", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative", "same", RelationContradict, 800, + resolverTestEvidence("negative", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "support", DecisionAmbiguousRetained) + resolverTestDecision(t, resolution, "negative", DecisionAmbiguousRetained) + assert.Empty(resolution.Projections) + }) + + t.Run("near negative evidence blocks positive projection", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 800, + resolverTestEvidence("support", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative", "same", RelationSupersede, 400, + resolverTestEvidence("negative", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "support", DecisionRetained) + assert.Equal(ReasonInsufficientMargin, decision.Reason) + assert.Empty(resolution.Projections) + }) + + t.Run("strong negative retires current after clearing every margin", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 103)} + input.Claims = []ResolvedClaim{ + resolverTestClaim("support", "same", 0, + resolverTestEvidence("support", EvidencePublic, DirectOther, AuthorityAuthoritative, 0)), + resolverTestClaimWithRelation("negative", "same", RelationSupersede, 800, + resolverTestEvidence("negative", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "negative", DecisionApplied) + resolverTestDecision(t, resolution, "support", DecisionSuperseded) + require.Len(t, resolution.Projections, 1) + assert.Equal(ProjectionRetire, resolution.Projections[0].Operation) + }) + + t.Run("corroborated negative claims aggregate before retirement", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 105)} + input.Claims = []ResolvedClaim{ + resolverTestClaimWithRelation("negative-first", "same", RelationContradict, 250, + resolverTestEvidence("negative-first", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative-second", "same", RelationContradict, 250, + resolverTestEvidence("negative-second", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + decision := resolverTestDecision(t, resolution, "negative-first", DecisionApplied) + assert.Equal(50, decision.Score.Corroboration) + assert.Equal(925, decision.Score.Total) + resolverTestDecision(t, resolution, "negative-second", DecisionSuperseded) + require.Len(t, resolution.Projections, 1) + assert.Equal(ProjectionRetire, resolution.Projections[0].Operation) + }) + + t.Run("mixed negative relations aggregate with relation-correct reasons", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("same", false, 106)} + input.Claims = []ResolvedClaim{ + resolverTestClaimWithRelation("negative-contradict", "same", RelationContradict, 250, + resolverTestEvidence("negative-contradict", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative-supersede", "same", RelationSupersede, 250, + resolverTestEvidence("negative-supersede", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + contradiction := resolverTestDecision(t, resolution, "negative-contradict", DecisionApplied) + assert.Equal(ReasonExplicitContradiction, contradiction.Reason) + assert.Equal(50, contradiction.Score.Corroboration) + assert.Equal(925, contradiction.Score.Total) + supersession := resolverTestDecision(t, resolution, "negative-supersede", DecisionSuperseded) + assert.Equal(ReasonExplicitSupersession, supersession.Reason) + require.Len(t, resolution.Projections, 1) + assert.Equal(ProjectionRetire, resolution.Projections[0].Operation) + }) + + t.Run("positive winner preserves losing negative relation reasons", func(t *testing.T) { + assert := assert.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("positive", "positive", 0, + resolverTestEvidence("positive", EvidenceSystem, DirectSelf, AuthorityAuthoritative, 0)), + resolverTestClaimWithRelation("negative-contradict", "negative", RelationContradict, 250, + resolverTestEvidence("negative-contradict", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative-supersede", "negative", RelationSupersede, 250, + resolverTestEvidence("negative-supersede", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "positive", DecisionApplied) + contradiction := resolverTestDecision(t, resolution, "negative-contradict", DecisionApplied) + assert.Equal(ReasonExplicitContradiction, contradiction.Reason) + assert.Equal(925, contradiction.Score.Total) + supersession := resolverTestDecision(t, resolution, "negative-supersede", DecisionSuperseded) + assert.Equal(ReasonExplicitSupersession, supersession.Reason) + assert.Equal(925, supersession.Score.Total) + require.Len(t, resolution.Projections, 1) + assert.Equal("positive", resolution.Projections[0].ClaimKey) + }) + + t.Run("unrelated negative cannot suppress supported value", func(t *testing.T) { + assert := assert.New(t) + input := resolverTestInput(resolverTestSingleTarget()) + input.Claims = []ResolvedClaim{ + resolverTestClaim("support-a", "a", 0, + resolverTestEvidence("support-a", EvidencePublic, DirectSelf, AuthorityOrdinary, 0)), + resolverTestClaimWithRelation("negative-b", "b", RelationContradict, 1000, + resolverTestEvidence("negative-b", EvidenceArchive, DirectSelf, AuthorityAuthoritative, 0)), + } + + resolution := resolverTestResolve(t, input) + resolverTestDecision(t, resolution, "support-a", DecisionApplied) + negative := resolverTestDecision(t, resolution, "negative-b", DecisionApplied) + assert.Equal(ReasonExplicitContradiction, negative.Reason) + require.Len(t, resolution.Projections, 1) + assert.Equal("support-a", resolution.Projections[0].ClaimKey) + assert.Equal(ProjectionSet, resolution.Projections[0].Operation) + }) +} + +func TestResolverV1FingerprintIncludesEvidenceStatusPersonID(t *testing.T) { + input := resolverTestInput(resolverTestSingleTarget()) + evidence := resolverTestEvidence("evidence", EvidencePublic, DirectOther, AuthorityOrdinary, 0) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, evidence)} + canonical := resolverTestResolve(t, input) + + input.Claims[0].Evidence[0].PersonID = 8 + differentStatusIdentity := resolverTestResolve(t, input) + assert.NotEqual(t, canonical.InputFingerprint, differentStatusIdentity.InputFingerprint) +} + +func TestResolverV1CorroborationUsesRequiredRefWhenPresent(t *testing.T) { + tests := []struct { + name string + sourceClass EvidenceSourceClass + action DecisionAction + }{ + {name: "archive", sourceClass: EvidenceArchive, action: DecisionApplied}, + {name: "provider assertion", sourceClass: EvidenceProviderAssertion, action: DecisionRetained}, + {name: "system", sourceClass: EvidenceSystem, action: DecisionApplied}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + first := resolverTestEvidence("first", test.sourceClass, Indirect, AuthorityOrdinary, 0) + second := resolverTestEvidence("second", test.sourceClass, Indirect, AuthorityOrdinary, 0) + first.Input.SourceRef = "same-required-ref" + second.Input.SourceRef = "same-required-ref" + first.Input.SourceURL = "https://example.com/first" + second.Input.SourceURL = "https://example.com/second" + if test.sourceClass == EvidenceArchive { + first.Input.Directness = DirectSelf + second.Input.Directness = DirectSelf + } + + input := resolverTestInput(resolverTestMultiTarget()) + input.Claims = []ResolvedClaim{resolverTestClaim("claim", "value", 1000, first, second)} + decision := resolverTestDecision(t, resolverTestResolve(t, input), "claim", test.action) + assert.Zero(t, decision.Score.Corroboration) + }) + } +} + +func TestResolverV1RejectsMutatedFrozenV1Policy(t *testing.T) { + tests := []struct { + name string + mutate func(*Policy) + }{ + {name: "apply threshold", mutate: func(policy *Policy) { policy.ApplyThreshold++ }}, + {name: "replacement margin", mutate: func(policy *Policy) { policy.ReplacementMargin++ }}, + {name: "hysteresis margin", mutate: func(policy *Policy) { policy.HysteresisMargin++ }}, + {name: "minimum identity", mutate: func(policy *Policy) { policy.MinimumIdentityScore-- }}, + {name: "corroboration cap", mutate: func(policy *Policy) { policy.MaxCorroborationBonus++ }}, + {name: "archive weight", mutate: func(policy *Policy) { policy.SourceClassWeights[EvidenceArchive]++ }}, + {name: "public weight", mutate: func(policy *Policy) { policy.SourceClassWeights[EvidencePublic]++ }}, + {name: "system weight", mutate: func(policy *Policy) { policy.SourceClassWeights[EvidenceSystem]++ }}, + {name: "provider weight", mutate: func(policy *Policy) { policy.SourceClassWeights[EvidenceProviderAssertion]++ }}, + {name: "direct self weight", mutate: func(policy *Policy) { policy.DirectnessWeights[DirectSelf]++ }}, + {name: "direct other weight", mutate: func(policy *Policy) { policy.DirectnessWeights[DirectOther]++ }}, + {name: "indirect weight", mutate: func(policy *Policy) { policy.DirectnessWeights[Indirect]++ }}, + {name: "authoritative weight", mutate: func(policy *Policy) { policy.AuthorityWeights[AuthorityAuthoritative]++ }}, + {name: "ordinary weight", mutate: func(policy *Policy) { policy.AuthorityWeights[AuthorityOrdinary]++ }}, + {name: "aggregator weight", mutate: func(policy *Policy) { policy.AuthorityWeights[AuthorityAggregator]++ }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + policy := DefaultPolicyV1() + test.mutate(&policy) + _, err := NewResolver(policy) + require.Error(t, err) + assert.Contains(t, err.Error(), "frozen") + }) + } + + custom := DefaultPolicyV1() + custom.Version = "person-fact-resolver-custom" + custom.ApplyThreshold++ + _, err := NewResolver(custom) + require.NoError(t, err) +} + +func TestResolverV1RejectsInvalidPreparedFailurePairsWithoutPanic(t *testing.T) { + tests := []ValidationFailure{ + {Action: DecisionApplied, Reason: ReasonAppliedProjection, Detail: "must reject"}, + {Action: DecisionRetained, Reason: ReasonBelowThreshold, Detail: "must reject"}, + {Action: DecisionIdentityRejected, Reason: ReasonMalformedValue, Detail: "must reject"}, + {Action: DecisionPolicyRejected, Reason: ReasonIdentityMismatch, Detail: "must reject"}, + {Action: DecisionInvalid, Reason: ReasonPinRetained, Detail: "must reject"}, + } + for _, failure := range tests { + t.Run(string(failure.Action)+"_"+string(failure.Reason), func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + input := resolverTestInput(resolverTestSingleTarget()) + input.Current = []CurrentProjection{resolverTestCurrent("current", false, 104)} + claim := resolverTestClaim("claim", "value", 1000, + resolverTestEvidence("claim", EvidenceArchive, DirectSelf, AuthorityOrdinary, 0)) + claim.Normalized = nil + claim.Failure = &failure + input.Claims = []ResolvedClaim{claim} + resolver, err := NewResolver(DefaultPolicyV1()) + require.NoError(err) + + assert.NotPanics(func() { + _, err = resolver.Resolve(input) + }) + require.Error(err) + assert.Contains(err.Error(), "rejection") + }) + } +} + +func resolverTestInput(target TargetDescriptor) ResolutionInput { + return ResolutionInput{ + PersonID: 7, Target: target, ResolvedAt: resolverTestNow, + Policy: PolicyContext{AllowSensitive: true, ProviderPolicyFingerprint: "policy-context-v1"}, + ProjectionContextFingerprint: "projection-context-v1", + Pin: PinState{Target: resolverTestTargetRef(target)}, + } +} + +func resolverTestSingleTarget() TargetDescriptor { + return TargetDescriptor{ + Kind: TargetAttribute, Key: "attribute:favorite", Revision: "revision-1", + UniversalID: "attribute:favorite", Slug: "favorite", Description: "Fixture favorite", + ValueType: ValueText, Cardinality: CardinalitySingle, + } +} + +func resolverTestMultiTarget() TargetDescriptor { + target := resolverTestSingleTarget() + target.Key = "attribute:aliases" + target.UniversalID = target.Key + target.Slug = "aliases" + target.Cardinality = CardinalityMulti + return target +} + +func resolverTestClaim(key, value string, confidence int, evidence ...Evidence) ResolvedClaim { + return resolverTestClaimWithRelation(key, value, RelationSupport, confidence, evidence...) +} + +func resolverTestClaimWithRelation( + key, value string, relation ClaimRelation, confidence int, evidence ...Evidence, +) ResolvedClaim { + target := resolverTestSingleTarget() + normalized := &NormalizedValue{JSON: json.RawMessage(`"` + value + `"`), Fingerprint: value} + return ResolvedClaim{ + ClaimKey: key, + Claim: ProposedClaim{ + Target: target, Relation: relation, SubmittedValue: append(json.RawMessage(nil), normalized.JSON...), + Origin: OriginExtraction, Confidence: ConfidenceInputs{ReportedScore: confidence}, + }, + Normalized: normalized, + Evidence: append([]Evidence(nil), evidence...), + } +} + +func resolverTestEvidence( + key string, sourceClass EvidenceSourceClass, directness EvidenceDirectness, + authority EvidenceAuthority, age time.Duration, +) Evidence { + subject := int64(7) + start, end := int64(0), int64(8) + input := EvidenceInput{ + PersonID: 7, SourceClass: sourceClass, Directness: directness, Authority: authority, + SubjectPersonID: &subject, SubjectRef: "person-7", Excerpt: "fixture evidence", + SourceVersion: "v1", EventTime: resolverTestNow.Add(-age), RecordedTime: resolverTestNow, + IdentityScore: 950, + } + switch sourceClass { + case EvidenceArchive: + input.SourceRef = key + input.SpanStart = &start + input.SpanEnd = &end + input.ContentSHA256 = strings.Repeat("a", 64) + case EvidencePublic: + input.SourceURL = "https://example.com/" + key + case EvidenceSystem: + input.SourceRef = key + case EvidenceProviderAssertion: + input.SourceRef = key + } + return Evidence{ID: int64(len(key) + 1), PersonID: 7, Key: "evidence-" + key, Input: input, Supported: true} +} + +func resolverTestStatus( + evidence Evidence, id int64, supported bool, reason EvidenceStatusReason, +) *EvidenceStatusEvent { + return &EvidenceStatusEvent{ + ID: id, PersonID: evidence.PersonID, EvidenceID: evidence.ID, + EvidenceKey: evidence.Key, SourceVersion: evidence.Input.SourceVersion, + Supported: supported, Reason: reason, CreatedAt: resolverTestNow, + } +} + +func resolverTestCurrent(fingerprint string, declared bool, rowID int64) CurrentProjection { + return CurrentProjection{ + Ref: ProjectionRef{Kind: "attribute", RowID: rowID}, + Normalized: NormalizedValue{JSON: json.RawMessage(`"` + fingerprint + `"`), Fingerprint: fingerprint}, + ActiveFrom: resolverTestNow.AddDate(-1, 0, 0), TransactionTime: resolverTestNow.Add(-time.Hour), + Declared: declared, + } +} + +func resolverTestTargetRef(target TargetDescriptor) TargetRef { + return TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision} +} + +func resolverTestResolve(t *testing.T, input ResolutionInput) Resolution { + t.Helper() + for index := range input.Claims { + input.Claims[index].Claim.Target = input.Target + } + resolver, err := NewResolver(DefaultPolicyV1()) + require.NoError(t, err) + resolution, err := resolver.Resolve(input) + require.NoError(t, err) + return resolution +} + +func resolverTestDecision( + t *testing.T, resolution Resolution, claimKey string, action DecisionAction, +) Decision { + t.Helper() + for _, decision := range resolution.Decisions { + if decision.ClaimKey == claimKey && decision.Action == action { + return decision + } + } + require.FailNow(t, "decision not found", "claim=%s action=%s decisions=%+v", claimKey, action, resolution.Decisions) + return Decision{} +} diff --git a/internal/personfacts/target_ref.go b/internal/personfacts/target_ref.go new file mode 100644 index 000000000..8db170804 --- /dev/null +++ b/internal/personfacts/target_ref.go @@ -0,0 +1,89 @@ +package personfacts + +import ( + "errors" + "fmt" + "strings" +) + +const targetRevisionPrefix = "sha256:" +const targetRevisionHexLength = 64 + +// EncodeTargetRef returns the canonical diagnostic wire form for a target. +// The revision suffix is fixed-width, so keys may contain any number of colons. +func EncodeTargetRef(target TargetRef) (string, error) { + if err := validateTargetRef(target); err != nil { + return "", err + } + return string(target.Kind) + ":" + target.Key + ":" + target.Revision, nil +} + +// DecodeTargetRef parses the canonical diagnostic wire form. It locates the +// fixed-width descriptor revision from the right instead of splitting the +// colon-bearing key. +func DecodeTargetRef(encoded string) (TargetRef, error) { + if encoded == "" || encoded != strings.TrimSpace(encoded) { + return TargetRef{}, errors.New("target must be kind:key:sha256:<64 lowercase hex characters>") + } + kindValue, remainder, found := strings.Cut(encoded, ":") + if !found { + return TargetRef{}, errors.New("target must be kind:key:sha256:<64 lowercase hex characters>") + } + kind, err := decodeTargetKind(kindValue) + if err != nil { + return TargetRef{}, err + } + + revisionLength := len(targetRevisionPrefix) + targetRevisionHexLength + separatorIndex := len(remainder) - revisionLength - 1 + if separatorIndex < 0 || remainder[separatorIndex] != ':' { + return TargetRef{}, targetRevisionError() + } + target := TargetRef{ + Kind: kind, Key: remainder[:separatorIndex], Revision: remainder[separatorIndex+1:], + } + if err := validateTargetRef(target); err != nil { + return TargetRef{}, err + } + return target, nil +} + +func validateTargetRef(target TargetRef) error { + if _, err := decodeTargetKind(string(target.Kind)); err != nil { + return err + } + if target.Key == "" || target.Key != strings.TrimSpace(target.Key) { + return errors.New("target key must not be empty or contain surrounding whitespace") + } + if !validTargetRevision(target.Revision) { + return targetRevisionError() + } + return nil +} + +func decodeTargetKind(value string) (TargetKind, error) { + kind := TargetKind(value) + switch kind { + case TargetAttribute, TargetEmployment: + return kind, nil + default: + return "", fmt.Errorf("unknown person fact target kind %q", value) + } +} + +func validTargetRevision(revision string) bool { + if len(revision) != len(targetRevisionPrefix)+targetRevisionHexLength || + !strings.HasPrefix(revision, targetRevisionPrefix) { + return false + } + for _, value := range revision[len(targetRevisionPrefix):] { + if (value < '0' || value > '9') && (value < 'a' || value > 'f') { + return false + } + } + return true +} + +func targetRevisionError() error { + return errors.New("target revision must be sha256 followed by 64 lowercase hexadecimal characters") +} diff --git a/internal/personfacts/target_ref_test.go b/internal/personfacts/target_ref_test.go new file mode 100644 index 000000000..bfd009860 --- /dev/null +++ b/internal/personfacts/target_ref_test.go @@ -0,0 +1,56 @@ +package personfacts + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestPersonFactTargetRefCodecRoundTripsColonKeyAndSHA256Revision(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + revision := "sha256:" + strings.Repeat("a", 64) + want := TargetRef{ + Kind: TargetEmployment, Key: "system:employment", Revision: revision, + } + + encoded, err := EncodeTargetRef(want) + requirements.NoError(err) + assertions.Equal("employment:system:employment:"+revision, encoded) + + decoded, err := DecodeTargetRef(encoded) + requirements.NoError(err) + assertions.Equal(want, decoded) +} + +func TestPersonFactTargetRefCodecRejectsMalformedKindKeyAndRevision(t *testing.T) { + revision := "sha256:" + strings.Repeat("a", 64) + for _, test := range []struct { + name string + encoded string + message string + }{ + {name: "unknown kind", encoded: "candidate:system:employment:" + revision, + message: "unknown person fact target kind"}, + {name: "empty key", encoded: "employment::" + revision, + message: "target key must not be empty"}, + {name: "legacy revision", encoded: "employment:system:employment:revision-1", + message: "target revision must be sha256"}, + {name: "short digest", encoded: "employment:system:employment:sha256:abc", + message: "target revision must be sha256"}, + {name: "uppercase digest", encoded: "employment:system:employment:sha256:" + strings.Repeat("A", 64), + message: "target revision must be sha256"}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := DecodeTargetRef(test.encoded) + require.ErrorContains(t, err, test.message) + }) + } + + _, err := EncodeTargetRef(TargetRef{ + Kind: TargetEmployment, Key: "system:employment", Revision: "revision-1", + }) + require.ErrorContains(t, err, "target revision must be sha256") +} diff --git a/internal/personfacts/types.go b/internal/personfacts/types.go new file mode 100644 index 000000000..2789e5e58 --- /dev/null +++ b/internal/personfacts/types.go @@ -0,0 +1,509 @@ +// Package personfacts defines provider-neutral person fact contracts. +package personfacts + +import ( + "context" + "encoding/json" + "time" +) + +type TargetKind string +type ValueType string +type Cardinality string + +const ( + TargetAttribute TargetKind = "attribute" + TargetEmployment TargetKind = "employment" + ValueText ValueType = "text" + ValueInteger ValueType = "integer" + ValueReal ValueType = "real" + ValueBoolean ValueType = "boolean" + ValueDate ValueType = "date" + ValueTimestamp ValueType = "timestamp" + ValueEmployment ValueType = "employment" + ValueOrganization ValueType = "organization" + ValuePartialDate ValueType = "partial-date" + CardinalitySingle Cardinality = "single" + CardinalityMulti Cardinality = "multi" +) + +type ChoiceDescriptor struct { + Value string `json:"value"` + Label string `json:"label"` +} + +type FieldDescriptor struct { + Name string `json:"name"` + ValueType ValueType `json:"value_type"` + Cardinality Cardinality `json:"cardinality"` + Required bool `json:"required"` +} + +type TargetDescriptor struct { + Kind TargetKind `json:"kind"` + Key string `json:"key"` + Revision string `json:"revision"` + UniversalID string `json:"universal_id"` + Slug string `json:"slug"` + Description string `json:"description"` + ValueType ValueType `json:"value_type"` + Cardinality Cardinality `json:"cardinality"` + RecordTarget string `json:"record_target,omitempty"` + MaxLength int `json:"max_length,omitempty"` + Choices []ChoiceDescriptor `json:"choices"` + Fields []FieldDescriptor `json:"fields"` + Sensitive bool `json:"sensitive"` +} + +type Catalog struct { + Version string `json:"version"` + Fingerprint string `json:"fingerprint"` + Targets []TargetDescriptor `json:"targets"` +} + +type CatalogOptions struct{ IncludeSensitive bool } + +type Definition struct { + UniversalID string + Slug string + Description string + ValueType ValueType + Cardinality Cardinality + RecordTarget string + MaxLength int + Choices []ChoiceDescriptor + Sensitive bool + Active bool + APIMutable bool + Derived bool +} + +type TargetRef struct { + Kind TargetKind `json:"kind"` + Key string `json:"key"` + Revision string `json:"revision"` +} + +type EvidenceFilter struct { + Target *TargetRef + Limit int + Offset int +} + +type EvidenceStatusFilter struct { + EvidenceKey string + Supported *bool + Limit int + Offset int +} + +type ClaimFilter struct { + Target *TargetRef + Limit int + Offset int +} + +type DecisionFilter struct { + Target *TargetRef + Limit int + Offset int +} + +type ProjectionRef struct { + Kind string `json:"kind"` + RowID int64 `json:"row_id"` +} + +const MaxEvidenceExcerptRunes = 2000 + +type EvidenceSourceClass string +type EvidenceDirectness string +type EvidenceAuthority string +type ClaimRelation string +type ClaimOrigin string +type DecisionAction string +type DecisionReason string +type EvidenceStatusReason string + +const ( + EvidenceArchive EvidenceSourceClass = "archive" + EvidencePublic EvidenceSourceClass = "public" + EvidenceSystem EvidenceSourceClass = "system" + EvidenceProviderAssertion EvidenceSourceClass = "provider_assertion" + DirectSelf EvidenceDirectness = "direct-self" + DirectOther EvidenceDirectness = "direct-other" + Indirect EvidenceDirectness = "indirect" + AuthorityAuthoritative EvidenceAuthority = "authoritative" + AuthorityOrdinary EvidenceAuthority = "ordinary" + AuthorityAggregator EvidenceAuthority = "aggregator" + RelationSupport ClaimRelation = "support" + RelationContradict ClaimRelation = "contradict" + RelationSupersede ClaimRelation = "supersede" + RelationInvalid ClaimRelation = "invalid" + OriginExtraction ClaimOrigin = "extraction" + OriginEnrichment ClaimOrigin = "enrichment" + OriginSystem ClaimOrigin = "system" + OriginInvalid ClaimOrigin = "invalid" + DecisionApplied DecisionAction = "applied" + DecisionRetained DecisionAction = "retained" + DecisionSuperseded DecisionAction = "superseded" + DecisionInvalid DecisionAction = "invalid" + DecisionIdentityRejected DecisionAction = "identity-rejected" + DecisionPolicyRejected DecisionAction = "policy-rejected" + DecisionConflictRejected DecisionAction = "conflict-rejected" + DecisionAmbiguousRetained DecisionAction = "ambiguous-retained" + ReasonMalformedValue DecisionReason = "malformed-value" + ReasonUnsupportedTarget DecisionReason = "unsupported-target" + ReasonStaleTargetRevision DecisionReason = "stale-target-revision" + ReasonUnalignedEvidence DecisionReason = "unaligned-evidence" + ReasonIdentityMismatch DecisionReason = "identity-mismatch" + ReasonSensitivePolicy DecisionReason = "sensitive-policy" + ReasonPinRetained DecisionReason = "pin-retained" + ReasonBelowThreshold DecisionReason = "below-threshold" + ReasonInsufficientMargin DecisionReason = "insufficient-margin" + ReasonCompetingTie DecisionReason = "competing-tie" + ReasonExplicitContradiction DecisionReason = "explicit-contradiction" + ReasonExplicitSupersession DecisionReason = "explicit-supersession" + ReasonOrganizationAmbiguous DecisionReason = "organization-ambiguous" + ReasonAppliedProjection DecisionReason = "applied-projection" + ReasonEvidenceUnsupported DecisionReason = "evidence-unsupported" + ReasonOutsideValidity DecisionReason = "outside-validity" + EvidenceStatusSourceDeleted EvidenceStatusReason = "source-deleted" + EvidenceStatusSourceEdited EvidenceStatusReason = "source-edited" + EvidenceStatusScopeUnlinked EvidenceStatusReason = "scope-unlinked" + EvidenceStatusIdentityReassigned EvidenceStatusReason = "identity-reassigned" + EvidenceStatusSourceReimported EvidenceStatusReason = "source-reimported" + EvidenceStatusScopeRelinked EvidenceStatusReason = "scope-relinked" +) + +type EvidenceInput struct { + PersonID int64 `json:"PersonID"` + SourceClass EvidenceSourceClass `json:"SourceClass"` + Directness EvidenceDirectness `json:"Directness"` + Authority EvidenceAuthority `json:"Authority"` + SourceRef string `json:"SourceRef"` + SourceURL string `json:"SourceURL"` + SubjectPersonID *int64 `json:"SubjectPersonID"` + SubjectRef string `json:"SubjectRef"` + SpanStart *int64 `json:"SpanStart"` + SpanEnd *int64 `json:"SpanEnd"` + Excerpt string `json:"Excerpt"` + ContentSHA256 string `json:"ContentSHA256"` + SourceVersion string `json:"SourceVersion"` + EventTime time.Time `json:"EventTime"` + RecordedTime time.Time `json:"RecordedTime"` + IdentityScore int `json:"IdentityScore"` +} + +type OrganizationReference struct { + ID *int64 `json:"id,omitempty"` + Name string `json:"name"` + Domain string `json:"domain,omitempty"` +} + +type PartialDateValue struct { + Year int `json:"year"` + Month int `json:"month,omitempty"` + Day int `json:"day,omitempty"` +} + +type EmploymentValue struct { + Organization OrganizationReference `json:"organization"` + Title string `json:"title,omitempty"` + Role string `json:"role,omitempty"` + Department string `json:"department,omitempty"` + Location string `json:"location,omitempty"` + StartDate *PartialDateValue `json:"start_date,omitempty"` + EndDate *PartialDateValue `json:"end_date,omitempty"` +} + +type ConfidenceInputs struct { + ReportedScore int `json:"reported_score"` +} + +type PolicyContext struct { + AllowSensitive bool `json:"allow_sensitive"` + ProviderPolicyFingerprint string `json:"provider_policy_fingerprint"` +} + +type ProposedClaim struct { + Target TargetDescriptor `json:"Target"` + Relation ClaimRelation `json:"Relation"` + SubmittedValue json.RawMessage `json:"SubmittedValue"` + Evidence []EvidenceInput `json:"Evidence"` + ValidFrom *time.Time `json:"ValidFrom"` + ValidUntil *time.Time `json:"ValidUntil"` + Origin ClaimOrigin `json:"Origin"` + Confidence ConfidenceInputs `json:"Confidence"` +} + +type SourceCursor struct { + Lane string `json:"lane"` + Start string `json:"start"` + End string `json:"end"` +} + +type GenerationInput struct { + PersonID int64 + SourceCursors []SourceCursor + ProgramID string + ProgramVersion string + ProgramFingerprint string + CatalogFingerprint string + Provider string + ProviderVersion string + Model string + ModelVersion string + ResolvedAt time.Time + Policy PolicyContext + Claims []ProposedClaim + EvidenceStatusChanges []EvidenceStatusChange +} + +type EvidenceStatusChange struct { + EvidenceKey string `json:"evidence_key"` + SourceVersion string `json:"source_version"` + Supported bool `json:"supported"` + Reason EvidenceStatusReason `json:"reason"` +} + +type NormalizedValue struct { + JSON json.RawMessage `json:"JSON"` + Fingerprint string `json:"Fingerprint"` +} + +type ValidationFailure struct { + Action DecisionAction `json:"Action"` + Reason DecisionReason `json:"Reason"` + Detail string `json:"Detail"` +} + +type AlignmentResult struct { + Accepted bool + SourceVersion string + ContentSHA256 string + Failure *ValidationFailure +} + +type EvidenceAligner interface { + Align(ctx context.Context, input EvidenceInput) (AlignmentResult, error) +} + +type PreparedClaim struct { + Target TargetDescriptor + Relation ClaimRelation + SubmittedValue json.RawMessage + SubmittedFingerprint string + SubmittedEvidenceFingerprints []string + Normalized *NormalizedValue + Evidence []EvidenceInput + EvidenceKeys []string + ValidFrom *time.Time + ValidUntil *time.Time + Origin ClaimOrigin + Confidence ConfidenceInputs + Failure *ValidationFailure +} + +type PreparedEvidenceStatusChange struct { + EvidenceKey string + SourceVersion string + Supported bool + Reason EvidenceStatusReason +} + +type PreparedGeneration struct { + canonicalJSON []byte + generationKey string + programFingerprint string + input GenerationInput + claims []PreparedClaim + evidenceStatusChanges []PreparedEvidenceStatusChange +} + +type Evidence struct { + ID int64 `json:"ID"` + PersonID int64 `json:"PersonID"` + Key string `json:"Key"` + CreatedAt time.Time `json:"CreatedAt"` + Input EvidenceInput `json:"Input"` + Supported bool `json:"Supported"` + LatestStatus *EvidenceStatusEvent `json:"LatestStatus"` +} + +type EvidenceStatusEvent struct { + ID int64 `json:"ID"` + PersonID int64 `json:"PersonID"` + GenerationID int64 `json:"GenerationID"` + EvidenceID int64 `json:"EvidenceID"` + EvidenceKey string `json:"EvidenceKey"` + SourceVersion string `json:"SourceVersion"` + Supported bool `json:"Supported"` + Reason EvidenceStatusReason `json:"Reason"` + CreatedAt time.Time `json:"CreatedAt"` +} + +type Generation struct { + ID int64 + PersonID int64 + GenerationKey string + SourceCursors []SourceCursor + ProgramID string + ProgramVersion string + ProgramFingerprint string + CatalogFingerprint string + Provider string + ProviderVersion string + Model string + ModelVersion string + Policy PolicyContext + ResolvedAt time.Time + CreatedAt time.Time +} + +type Claim struct { + ID int64 + PersonID int64 + GenerationID int64 + ClaimKey string + Generation Generation + Target TargetRef + Relation ClaimRelation + SubmittedValue json.RawMessage + Normalized *NormalizedValue + EvidenceIDs []int64 + ValidFrom *time.Time + ValidUntil *time.Time + Origin ClaimOrigin + Confidence ConfidenceInputs + Failure *ValidationFailure + CreatedAt time.Time +} + +// CurrentProjection describes one value visible to fact resolution. +type CurrentProjection struct { + Ref ProjectionRef `json:"Ref"` + Normalized NormalizedValue `json:"Normalized"` + ActiveFrom time.Time `json:"ActiveFrom"` + ActiveUntil *time.Time `json:"ActiveUntil"` + TransactionTime time.Time `json:"TransactionTime"` + Declared bool `json:"Declared"` +} + +type ResolvedClaim struct { + ClaimKey string `json:"ClaimKey"` + Claim ProposedClaim `json:"Claim"` + Normalized *NormalizedValue `json:"Normalized"` + Evidence []Evidence `json:"Evidence"` + Failure *ValidationFailure `json:"Failure"` +} + +type PinState struct { + Target TargetRef `json:"target"` + Pinned bool `json:"pinned"` + Actor string `json:"actor,omitempty"` + EventID *int64 `json:"event_id,omitempty"` +} + +type ResolutionInput struct { + PersonID int64 `json:"PersonID"` + Target TargetDescriptor `json:"Target"` + ResolvedAt time.Time `json:"ResolvedAt"` + Policy PolicyContext `json:"Policy"` + ProjectionContextFingerprint string `json:"ProjectionContextFingerprint"` + Current []CurrentProjection `json:"Current"` + Claims []ResolvedClaim `json:"Claims"` + Pin PinState `json:"Pin"` +} + +type ScoreBreakdown struct { + SourceClass int `json:"source_class"` + Directness int `json:"directness"` + Authority int `json:"authority"` + Confidence int `json:"confidence"` + Freshness int `json:"freshness"` + Corroboration int `json:"corroboration"` + Total int `json:"total"` +} + +type ProjectionOperation string + +const ( + ProjectionSet ProjectionOperation = "set" + ProjectionRetire ProjectionOperation = "retire" +) + +type ProjectionPlan struct { + Operation ProjectionOperation `json:"operation"` + Target TargetRef `json:"target"` + ClaimKey string `json:"claim_key"` + CurrentRef *ProjectionRef `json:"current_ref"` + ActiveFrom time.Time `json:"active_from"` +} + +type Decision struct { + ID int64 `json:"id,omitempty"` + PersonID int64 `json:"person_id,omitempty"` + ResolutionID int64 `json:"resolution_id,omitempty"` + DecisionKey string `json:"decision_key,omitempty"` + ClaimKey string `json:"claim_key"` + Action DecisionAction `json:"action"` + Reason DecisionReason `json:"reason"` + Score ScoreBreakdown `json:"score"` + CompetingClaimKey string `json:"competing_claim_key,omitempty"` + Projection *ProjectionRef `json:"projection,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Resolution struct { + ID int64 `json:"id"` + Target TargetRef `json:"target"` + ResolverVersion string `json:"resolver_version"` + InputFingerprint string `json:"input_fingerprint"` + ResolvedAt time.Time `json:"resolved_at"` + Decisions []Decision `json:"decisions"` + Projections []ProjectionPlan `json:"projections"` +} + +type ResolutionResult struct { + ID int64 `json:"id"` + Target TargetRef `json:"target"` + ResolverVersion string `json:"resolver_version"` + InputFingerprint string `json:"input_fingerprint"` + ResolvedAt time.Time `json:"resolved_at"` + Decisions []Decision `json:"decisions"` + Projections []ProjectionRef `json:"projections"` +} + +// PinWrite describes the effective pin and any atomic re-resolution it caused. +type PinWrite struct { + State PinState `json:"state"` + Resolutions []ResolutionResult `json:"resolutions"` + Projections []ProjectionRef `json:"projections"` +} + +// GenerationResult is the complete durable result of one generation envelope. +type GenerationResult struct { + GenerationID int64 `json:"generation_id"` + GenerationKey string `json:"generation_key"` + EvidenceStatusEvents []EvidenceStatusEvent `json:"evidence_status_events"` + Resolutions []ResolutionResult `json:"resolutions"` + Decisions []Decision `json:"decisions"` + Projections []ProjectionRef `json:"projections"` +} + +type Policy struct { + Version string `json:"Version"` + ApplyThreshold int `json:"ApplyThreshold"` + ReplacementMargin int `json:"ReplacementMargin"` + HysteresisMargin int `json:"HysteresisMargin"` + MinimumIdentityScore int `json:"MinimumIdentityScore"` + MaxCorroborationBonus int `json:"MaxCorroborationBonus"` + SourceClassWeights map[EvidenceSourceClass]int `json:"SourceClassWeights"` + DirectnessWeights map[EvidenceDirectness]int `json:"DirectnessWeights"` + AuthorityWeights map[EvidenceAuthority]int `json:"AuthorityWeights"` +} + +type Resolver struct { + Policy Policy +} diff --git a/internal/store/attribute_definitions.go b/internal/store/attribute_definitions.go index b40f95760..67b45bb65 100644 --- a/internal/store/attribute_definitions.go +++ b/internal/store/attribute_definitions.go @@ -366,17 +366,25 @@ func validateAttributeDefinitionInput( input.UIEditable = false input.DerivedSource = &derived } - if input.Description != nil { - description := strings.TrimSpace(*input.Description) - if description == "" { - input.Description = nil - } else { - input.Description = &description - } + description, present := normalizeAttributeDescription(input.Description) + input.Description = nil + if present { + input.Description = &description } return input, nil } +func normalizeAttributeDescription(value *string) (string, bool) { + if value == nil { + return "", false + } + normalized := strings.TrimSpace(*value) + if normalized == "" { + return "", false + } + return normalized, true +} + func validateAttributeWidget(input AttributeDefinitionInput) error { invalid := func(format string, args ...any) error { return fmt.Errorf("%w: %s", ErrAttributeDefinitionInvalid, @@ -787,17 +795,13 @@ func (s *Store) UpdateAttributeDefinitionContext( args = append(args, label) } if update.Description != nil { - description := *update.Description - if description != nil { - trimmed := strings.TrimSpace(*description) - if trimmed == "" { - description = nil - } else { - description = &trimmed - } - } + description, present := normalizeAttributeDescription(*update.Description) assignments = append(assignments, "description = ?") - args = append(args, description) + if present { + args = append(args, description) + } else { + args = append(args, nil) + } } if update.DisplayOrder != nil { if *update.DisplayOrder < 0 { diff --git a/internal/store/attribute_definitions_test.go b/internal/store/attribute_definitions_test.go index 4b74e4cfd..4b5414755 100644 --- a/internal/store/attribute_definitions_test.go +++ b/internal/store/attribute_definitions_test.go @@ -29,6 +29,60 @@ func personTextDefinition(slug string) store.AttributeDefinitionInput { } } +func TestAttributeDescriptionAcceptsExactly280UnicodeRunes(t *testing.T) { + st := testutil.NewTestStore(t) + description := strings.Repeat("🙂", 280) + input := personTextDefinition("maximum_description") + input.Description = &description + + created, err := st.CreateAttributeDefinitionContext(t.Context(), input) + require.NoError(t, err) + require.NotNil(t, created.Description) + assert.Equal(t, description, *created.Description) +} + +func TestAttributeDescriptionCreateAcceptsMoreThan280UnicodeRunes(t *testing.T) { + st := testutil.NewTestStore(t) + description := strings.Repeat("🙂", 281) + input := personTextDefinition("too_long_description") + input.Description = &description + + created, err := st.CreateAttributeDefinitionContext(t.Context(), input) + require.NoError(t, err) + require.NotNil(t, created.Description) + assert.Equal(t, description, *created.Description) +} + +func TestAttributeDescriptionUpdateAcceptsMoreThan280UnicodeRunes(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st := testutil.NewTestStore(t) + created, err := st.CreateAttributeDefinitionContext( + t.Context(), personTextDefinition("update_description")) + require.NoError(err) + + description := strings.Repeat("🙂", 281) + descriptionUpdate := &description + updated, err := st.UpdateAttributeDefinitionContext(t.Context(), created.ID, created.Revision, + store.AttributeDefinitionUpdate{Description: &descriptionUpdate}) + require.NoError(err) + require.NotNil(updated.Description) + assert.Equal(description, *updated.Description) + assert.Equal(created.Revision+1, updated.Revision) +} + +func TestAttributeDescriptionBlankNormalizesToNil(t *testing.T) { + st := testutil.NewTestStore(t) + description := " \n\t " + input := personTextDefinition("blank_description") + input.Description = &description + + created, err := st.CreateAttributeDefinitionContext(t.Context(), input) + require.NoError(t, err) + assert.Nil(t, created.Description) +} + func onlyTestDefinitions( definitions []store.AttributeDefinition, slugs ...string, ) []store.AttributeDefinition { diff --git a/internal/store/employments.go b/internal/store/employments.go index ec95a6378..6b90b6024 100644 --- a/internal/store/employments.go +++ b/internal/store/employments.go @@ -8,6 +8,8 @@ import ( "slices" "strings" "time" + + "go.kenn.io/msgvault/internal/personfacts" ) var ( @@ -79,16 +81,16 @@ func (s *Store) AddEmploymentContext(ctx context.Context, input EmploymentInput) if err := s.claimEmploymentPeopleTx(ctx, tx, input.PersonID); err != nil { return err } - if err := s.verifyEmploymentReferencesTx(ctx, tx, input); err != nil { - return err - } - current := employmentCurrent(input) - primary, err := s.resolveEmploymentPrimaryTx(ctx, tx, input, current, 0) + var err error + employment, err = s.addEmploymentTx(ctx, tx, input) if err != nil { return err } - employment, err = s.insertEmploymentTx(ctx, tx, input, current, primary) - return err + if input.Source.IsDeclared() { + return s.appendManualPersonFactEmploymentPinTx( + ctx, tx, input.PersonID, string(input.Source)) + } + return nil }) if err != nil { return nil, err @@ -127,56 +129,24 @@ func (s *Store) UpdateEmploymentContext(ctx context.Context, id, expectedRevisio ctx, tx, snapshot.PersonID, input.PersonID); err != nil { return err } - if err := s.verifyEmploymentReferencesTx(ctx, tx, input); err != nil { + projection := personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: id} + ownedBefore, err := ownsPersonFactProjectionTx( + ctx, tx, snapshot.PersonID, projection) + if err != nil { return err } - currentEmployment, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, id) + employment, err = s.reviseEmploymentTx(ctx, tx, id, expectedRevision, input) if err != nil { return err } - if currentEmployment.Revision != expectedRevision { - return ErrEmploymentRevisionConflict - } - current := employmentCurrent(input) - if input.IsCurrent == nil && input.EndDate == nil { - // Preserve the stored state: an update that mentions neither - // is_current nor an end date must not resurrect a historical - // employment. - current = currentEmployment.IsCurrent - } - var primary bool - if input.IsPrimary == nil { - // Preserve the stored flag: an update that omits is_primary must - // not promote as a side effect. Demote when it stops being - // current, matching EndEmploymentContext. - primary = currentEmployment.IsPrimary && current - } else { - primary, err = s.resolveEmploymentPrimaryTx(ctx, tx, input, current, id) - if err != nil { - return err + if input.Source.IsDeclared() || ownedBefore { + for _, personID := range sortedUniqueInt64s(snapshot.PersonID, input.PersonID) { + if err := s.appendManualPersonFactEmploymentPinTx( + ctx, tx, personID, string(input.Source)); err != nil { + return err + } } } - args := employmentWriteArgs(input, current, primary) - args = append(args, id, expectedRevision) - employment, err = s.employmentWriteWithConflictSavepointTx( - ctx, tx, input, current, id, func() (*Employment, error) { - return scanEmployment(tx.QueryRowContext(ctx, fmt.Sprintf(` - UPDATE employments SET person_id = ?, organization_id = ?, title = ?, title_normalized = ?, - role = ?, department = ?, location = ?, address_id = ?, description = ?, - start_year = ?, start_month = ?, start_day = ?, end_year = ?, end_month = ?, end_day = ?, - is_current = ?, is_primary = ?, source = ?, source_ref = ?, confidence = ?, - revision = revision + 1, updated_at = %s - WHERE id = ? AND revision = ? RETURNING %s`, s.dialect.Now(), employmentColumns), args...)) - }) - if errors.Is(err, sql.ErrNoRows) { - return s.employmentCASMissTx(ctx, tx, id) - } - if err != nil { - if errors.Is(err, ErrEmploymentDuplicateActive) || errors.Is(err, ErrEmploymentPrimaryConflict) { - return err - } - return fmt.Errorf("update employment %d: %w", id, err) - } return nil }) if err != nil { @@ -203,27 +173,12 @@ func (s *Store) EndEmploymentContext(ctx context.Context, id, expectedRevision i if err := s.claimEmploymentPeopleTx(ctx, tx, snapshot.PersonID); err != nil { return err } - current, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, id) + employment, err = s.endEmploymentTx(ctx, tx, id, expectedRevision, endDate) if err != nil { return err } - if current.Revision != expectedRevision { - return ErrEmploymentRevisionConflict - } - if current.StartDate != nil && CompareAtSharedPrecision(endDate, *current.StartDate) < 0 { - return fmt.Errorf("%w: end date must not precede start date", ErrEmploymentInvalid) - } - args := append(PartialDateArgs(endDate), false, false, id, expectedRevision) - employment, err = scanEmployment(tx.QueryRowContext(ctx, fmt.Sprintf(` - UPDATE employments SET end_year = ?, end_month = ?, end_day = ?, is_current = ?, is_primary = ?, - revision = revision + 1, updated_at = %s WHERE id = ? AND revision = ? RETURNING %s`, s.dialect.Now(), employmentColumns), args...)) - if errors.Is(err, sql.ErrNoRows) { - return s.employmentCASMissTx(ctx, tx, id) - } - if err != nil { - return fmt.Errorf("end employment %d: %w", id, err) - } - return nil + return s.appendManualPersonFactEmploymentPinTx( + ctx, tx, snapshot.PersonID, string(ProvenanceUser)) }) if err != nil { return nil, err @@ -271,7 +226,8 @@ func (s *Store) setPrimaryEmploymentOnce(ctx context.Context, id, expectedRevisi if err != nil { return fmt.Errorf("promote employment %d: %w", id, err) } - return nil + return s.appendManualPersonFactEmploymentPinTx( + ctx, tx, current.PersonID, string(ProvenanceUser)) }) if err != nil { return nil, err @@ -307,7 +263,8 @@ func (s *Store) deleteEmploymentOnce(ctx context.Context, id, expectedRevision i if err != nil { return fmt.Errorf("delete employment %d: %w", id, err) } - return nil + return s.appendManualPersonFactEmploymentPinTx( + ctx, tx, snapshot.PersonID, string(ProvenanceUser)) }) } @@ -461,6 +418,143 @@ func employmentCurrent(input EmploymentInput) bool { return input.IsCurrent == nil && input.EndDate == nil || input.IsCurrent != nil && *input.IsCurrent } +func (s *Store) addEmploymentTx( + ctx context.Context, tx *loggedTx, input EmploymentInput, +) (*Employment, error) { + input, err := validateEmploymentInput(input) + if err != nil { + return nil, err + } + if err := s.verifyEmploymentReferencesTx(ctx, tx, input); err != nil { + return nil, err + } + current := employmentCurrent(input) + primary, err := s.resolveEmploymentPrimaryTx(ctx, tx, input, current, 0) + if err != nil { + return nil, err + } + return s.insertEmploymentTx(ctx, tx, input, current, primary) +} + +func (s *Store) reviseEmploymentTx( + ctx context.Context, tx *loggedTx, id, expectedRevision int64, input EmploymentInput, +) (*Employment, error) { + input, err := validateEmploymentInput(input) + if err != nil { + return nil, err + } + if err := s.verifyEmploymentReferencesTx(ctx, tx, input); err != nil { + return nil, err + } + currentEmployment, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, id) + if err != nil { + return nil, err + } + if currentEmployment.Revision != expectedRevision { + return nil, ErrEmploymentRevisionConflict + } + current := employmentCurrent(input) + if input.IsCurrent == nil && input.EndDate == nil { + // Preserve the stored state: an update that mentions neither + // is_current nor an end date must not resurrect a historical row. + current = currentEmployment.IsCurrent + } + var primary bool + if input.IsPrimary == nil { + // Preserve the stored flag and demote only when the row stops being + // current, matching endEmploymentTx. + primary = currentEmployment.IsPrimary && current + } else { + primary, err = s.resolveEmploymentPrimaryTx(ctx, tx, input, current, id) + if err != nil { + return nil, err + } + } + args := employmentWriteArgs(input, current, primary) + args = append(args, id, expectedRevision) + employment, err := s.employmentWriteWithConflictSavepointTx( + ctx, tx, input, current, id, func() (*Employment, error) { + return scanEmployment(tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE employments SET person_id = ?, organization_id = ?, title = ?, title_normalized = ?, + role = ?, department = ?, location = ?, address_id = ?, description = ?, + start_year = ?, start_month = ?, start_day = ?, end_year = ?, end_month = ?, end_day = ?, + is_current = ?, is_primary = ?, source = ?, source_ref = ?, confidence = ?, + revision = revision + 1, updated_at = %s + WHERE id = ? AND revision = ? RETURNING %s`, s.dialect.Now(), employmentColumns), args...)) + }) + if errors.Is(err, sql.ErrNoRows) { + return nil, s.employmentCASMissTx(ctx, tx, id) + } + if err != nil { + if errors.Is(err, ErrEmploymentDuplicateActive) || errors.Is(err, ErrEmploymentPrimaryConflict) { + return nil, err + } + return nil, fmt.Errorf("update employment %d: %w", id, err) + } + return employment, nil +} + +func (s *Store) endEmploymentTx( + ctx context.Context, tx *loggedTx, id, expectedRevision int64, endDate PartialDate, +) (*Employment, error) { + if err := validateEmploymentDate(endDate, "end"); err != nil { + return nil, err + } + current, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, id) + if err != nil { + return nil, err + } + if current.Revision != expectedRevision { + return nil, ErrEmploymentRevisionConflict + } + return s.endEmploymentLockedTx(ctx, tx, current, endDate, nil) +} + +type employmentProjectionEnd struct { + Source Provenance + SourceRef string + Confidence float64 +} + +func (s *Store) endEmploymentProjectionTx( + ctx context.Context, tx *loggedTx, current *Employment, endDate PartialDate, + source Provenance, sourceRef string, confidence float64, +) (*Employment, error) { + if err := validateEmploymentDate(endDate, "end"); err != nil { + return nil, err + } + return s.endEmploymentLockedTx(ctx, tx, current, endDate, &employmentProjectionEnd{ + Source: source, SourceRef: sourceRef, Confidence: confidence, + }) +} + +func (s *Store) endEmploymentLockedTx( + ctx context.Context, tx *loggedTx, current *Employment, endDate PartialDate, + projection *employmentProjectionEnd, +) (*Employment, error) { + if current.StartDate != nil && CompareAtSharedPrecision(endDate, *current.StartDate) < 0 { + return nil, fmt.Errorf("%w: end date must not precede start date", ErrEmploymentInvalid) + } + args := append(PartialDateArgs(endDate), false, false) + setProjection := "" + if projection != nil { + setProjection = ", source = ?, source_ref = ?, confidence = ?" + args = append(args, projection.Source, projection.SourceRef, projection.Confidence) + } + args = append(args, current.ID, current.Revision) + employment, err := scanEmployment(tx.QueryRowContext(ctx, fmt.Sprintf(` + UPDATE employments SET end_year = ?, end_month = ?, end_day = ?, is_current = ?, is_primary = ?%s, + revision = revision + 1, updated_at = %s WHERE id = ? AND revision = ? RETURNING %s`, + setProjection, s.dialect.Now(), employmentColumns), args...)) + if errors.Is(err, sql.ErrNoRows) { + return nil, s.employmentCASMissTx(ctx, tx, current.ID) + } + if err != nil { + return nil, fmt.Errorf("end employment %d: %w", current.ID, err) + } + return employment, nil +} + func (s *Store) verifyEmploymentReferencesTx(ctx context.Context, tx *loggedTx, input EmploymentInput) error { var exists bool organization, err := getOrganizationForUpdateTx( @@ -483,16 +577,24 @@ func (s *Store) verifyEmploymentReferencesTx(ctx context.Context, tx *loggedTx, return nil } -// claimEmploymentPeopleTx is the single gate every employment mutation passes -// through before touching a row. It locks each affected person in ascending ID -// order so two writes over the same pair cannot deadlock, and bumps their -// vCard projection revision: a person's snapshot carries their employments and -// each employer's full profile, so any employment write changes their -// projection and must be visible to a concurrent envelope commit. Doing both -// here rather than at the five call sites means a sixth employment mutation -// cannot acquire the lock and forget the bump. +// claimEmploymentPeopleTx is the gate for public employment mutations. It +// locks each affected person in ascending ID order so two writes over the same +// pair cannot deadlock, and bumps their vCard projection revision: a person's +// snapshot carries their employments and each employer's full profile, so any +// employment write changes their projection. Automatic fact projection uses +// lockEmploymentPeopleTx directly because the outer fact transaction owns its +// single vCard bump. func (s *Store) claimEmploymentPeopleTx( ctx context.Context, tx *loggedTx, personIDs ...int64, +) error { + if err := s.lockEmploymentPeopleTx(ctx, tx, personIDs...); err != nil { + return err + } + return s.bumpPersonVCardProjectionsTx(ctx, tx, personIDs...) +} + +func (s *Store) lockEmploymentPeopleTx( + ctx context.Context, tx *loggedTx, personIDs ...int64, ) error { ids := append([]int64(nil), personIDs...) slices.Sort(ids) @@ -513,7 +615,39 @@ func (s *Store) claimEmploymentPeopleTx( } previous = personID } - return s.bumpPersonVCardProjectionsTx(ctx, tx, ids...) + return nil +} + +func sortedUniqueInt64s(values ...int64) []int64 { + result := append([]int64(nil), values...) + slices.Sort(result) + return slices.Compact(result) +} + +func (s *Store) appendManualPersonFactEmploymentPinTx( + ctx context.Context, tx *loggedTx, personID int64, actor string, +) error { + target, err := personFactEmploymentTargetRef() + if err != nil { + return err + } + _, err = s.appendPersonFactPinEventTx(ctx, tx, personID, target, true, actor) + return err +} + +func personFactEmploymentTargetRef() (personfacts.TargetRef, error) { + catalog, err := personfacts.BuildCatalog(nil, personfacts.CatalogOptions{}) + if err != nil { + return personfacts.TargetRef{}, err + } + for _, target := range catalog.Targets { + if target.Kind == personfacts.TargetEmployment { + return personfacts.TargetRef{ + Kind: target.Kind, Key: target.Key, Revision: target.Revision, + }, nil + } + } + return personfacts.TargetRef{}, errors.New("employment fact target is unavailable") } func (s *Store) resolveEmploymentPrimaryTx(ctx context.Context, tx *loggedTx, input EmploymentInput, current bool, excludeID int64) (bool, error) { diff --git a/internal/store/employments_test.go b/internal/store/employments_test.go index 4dde30663..80f755a81 100644 --- a/internal/store/employments_test.go +++ b/internal/store/employments_test.go @@ -660,3 +660,69 @@ func TestUpdateEmploymentPreservesHistoricalStateWhenFlagsOmitted(t *testing.T) "an update that omits is_current and end_date must not resurrect a historical employment") assert.False(updated.IsPrimary) } + +func TestEmploymentManualWritesRepinTarget(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + ctx := context.Background() + st := testutil.NewTestStore(t) + person := mustPromotedPerson(t, st, "manual-pin@example.com", "manual-pin") + firstOrganization := mustOrganization(t, st, "Manual Pin One") + secondOrganization := mustOrganization(t, st, "Manual Pin Two") + + first, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: person.ID, OrganizationID: firstOrganization.ID, + Title: new("Engineer"), Source: store.ProvenanceUser, IsPrimary: new(false), + }) + require.NoError(err) + assert.Equal(int64(1), employmentPinEventCount(t, st, person.ID)) + + first, err = st.UpdateEmploymentContext(ctx, first.ID, first.Revision, store.EmploymentInput{ + PersonID: person.ID, OrganizationID: firstOrganization.ID, + Title: new("Senior Engineer"), Source: store.ProvenanceExtraction, + Confidence: new(0.9), IsPrimary: new(false), + }) + require.NoError(err) + assert.Equal(int64(1), employmentPinEventCount(t, st, person.ID)) + + first, err = st.UpdateEmploymentContext(ctx, first.ID, first.Revision, store.EmploymentInput{ + PersonID: person.ID, OrganizationID: firstOrganization.ID, + Title: new("Principal Engineer"), Source: store.ProvenanceUser, + IsPrimary: new(false), + }) + require.NoError(err) + assert.Equal(int64(2), employmentPinEventCount(t, st, person.ID)) + + second, err := st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: person.ID, OrganizationID: secondOrganization.ID, + Title: new("Advisor"), Source: store.ProvenanceEnrichment, + Confidence: new(0.8), IsPrimary: new(false), + }) + require.NoError(err) + assert.Equal(int64(2), employmentPinEventCount(t, st, person.ID)) + + second, err = st.SetPrimaryEmploymentContext(ctx, second.ID, second.Revision) + require.NoError(err) + assert.Equal(int64(3), employmentPinEventCount(t, st, person.ID)) + + second, err = st.EndEmploymentContext(ctx, second.ID, second.Revision, + *mustPartialDate(t, "2026-01-15")) + require.NoError(err) + assert.Equal(int64(4), employmentPinEventCount(t, st, person.ID)) + + require.NoError(st.DeleteEmploymentContext(ctx, second.ID, second.Revision)) + assert.Equal(int64(5), employmentPinEventCount(t, st, person.ID)) + require.NoError(st.DeleteEmploymentContext(ctx, first.ID, first.Revision)) + assert.Equal(int64(6), employmentPinEventCount(t, st, person.ID)) +} + +func employmentPinEventCount(t *testing.T, st *store.Store, personID int64) int64 { + t.Helper() + var count int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + SELECT COUNT(*) FROM person_fact_pin_events + WHERE person_id = ? AND target_kind = ? AND target_key = ? + `), personID, "employment", "system:employment").Scan(&count) + require.NoError(t, err) + return count +} diff --git a/internal/store/identity_match_apply.go b/internal/store/identity_match_apply.go index bd07872f8..b2883d7e2 100644 --- a/internal/store/identity_match_apply.go +++ b/internal/store/identity_match_apply.go @@ -174,7 +174,7 @@ func (s *Store) ResumeAcceptedIdentityMatchCandidateContext( } return candidate, revision, false, nil } - decidedBy := "system" + decidedBy := string(AttributeOwnershipSystem) if candidate.DecidedBy != nil { decidedBy = *candidate.DecidedBy } @@ -313,7 +313,7 @@ func (s *Store) ApplyAcceptedIdentityMatchesContext(ctx context.Context, limit i applied := 0 for i := range candidates { candidate := &candidates[i] - decidedBy := "system" + decidedBy := string(AttributeOwnershipSystem) if candidate.DecidedBy != nil { decidedBy = *candidate.DecidedBy } diff --git a/internal/store/identity_match_candidates.go b/internal/store/identity_match_candidates.go index d41d8e320..15250ce69 100644 --- a/internal/store/identity_match_candidates.go +++ b/internal/store/identity_match_candidates.go @@ -1273,7 +1273,7 @@ func reconcileIdentityMatchCandidateMergeState( generatedConflict := state != IdentityMatchStateConflict && hasAccepted && hasRejected if generatedConflict { return IdentityMatchStateConflict, - sql.NullString{String: "system", Valid: true}, + sql.NullString{String: string(AttributeOwnershipSystem), Valid: true}, sql.NullTime{Time: time.Now().UTC(), Valid: true}, sql.NullString{ String: conflictNote, diff --git a/internal/store/migrations.go b/internal/store/migrations.go index 460a09d4f..76e151292 100644 --- a/internal/store/migrations.go +++ b/internal/store/migrations.go @@ -8,6 +8,7 @@ import ( "encoding/json" "fmt" "io" + "time" ) // One-time data migrations run by InitSchema and gated on the @@ -48,6 +49,7 @@ const ( migrationEmbeddingChangeJournalTriggers = "embedding_change_journal_triggers_v7" migrationIdentityMatchSourceSupport = "identity_match_source_support_v1" migrationVCardSourceResourceIdentity = "vcard_source_resource_identity_v1" + migrationOrganizationDomainIDNA = "organization_domain_idna_v1" // v3: the SQLite conversation trigger narrowed from a blanket // AFTER UPDATE to conversation_type changes only; archives that // installed the blanket trigger need the repair to re-run. @@ -57,6 +59,177 @@ const ( migrationActivityProjectionTriggers = "activity_projection_triggers_v4" ) +type legacyOrganizationDomainIdentifier struct { + id, organizationID int64 + stored, canonical string + active bool +} + +func (s *Store) canonicalizeLegacyOrganizationDomains( + ctx context.Context, tx *loggedTx, +) error { + affectedOrganizations := make(map[int64]struct{}) + rows, err := tx.QueryContext(ctx, ` + SELECT id, primary_domain + FROM organizations + WHERE primary_domain IS NOT NULL + ORDER BY id + `) + if err != nil { + return fmt.Errorf("list legacy organization primary domains: %w", err) + } + type primaryDomainUpdate struct { + id int64 + canonical string + } + var primaryUpdates []primaryDomainUpdate + for rows.Next() { + var id int64 + var stored string + if err := rows.Scan(&id, &stored); err != nil { + _ = rows.Close() + return fmt.Errorf("scan legacy organization primary domain: %w", err) + } + canonical := NormalizeDomain(stored) + if canonical != "" && canonical != stored { + primaryUpdates = append(primaryUpdates, primaryDomainUpdate{id, canonical}) + } + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate legacy organization primary domains: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close legacy organization primary domains: %w", err) + } + + for _, update := range primaryUpdates { + if _, err := tx.ExecContext(ctx, ` + UPDATE organizations + SET primary_domain = ? + WHERE id = ? + `, update.canonical, update.id); err != nil { + return fmt.Errorf("canonicalize organization %d primary domain: %w", update.id, err) + } + affectedOrganizations[update.id] = struct{}{} + } + + rows, err = tx.QueryContext(ctx, ` + SELECT id, organization_id, normalized_value, + active_until IS NULL AND superseded_at IS NULL AS active + FROM organization_identifiers + WHERE identifier_kind = 'domain' + ORDER BY organization_id, id + `) + if err != nil { + return fmt.Errorf("list legacy organization domain identifiers: %w", err) + } + var identifiers []legacyOrganizationDomainIdentifier + for rows.Next() { + var identifier legacyOrganizationDomainIdentifier + if err := rows.Scan( + &identifier.id, &identifier.organizationID, &identifier.stored, + &identifier.active, + ); err != nil { + _ = rows.Close() + return fmt.Errorf("scan legacy organization domain identifier: %w", err) + } + identifier.canonical = NormalizeDomain(identifier.stored) + if identifier.canonical == "" { + continue + } + identifiers = append(identifiers, identifier) + } + if err := rows.Err(); err != nil { + _ = rows.Close() + return fmt.Errorf("iterate legacy organization domain identifiers: %w", err) + } + if err := rows.Close(); err != nil { + return fmt.Errorf("close legacy organization domain identifiers: %w", err) + } + + type collisionKey struct { + organizationID int64 + canonical string + } + winners := make(map[collisionKey]int) + var losers []int + for index, identifier := range identifiers { + if !identifier.active { + continue + } + key := collisionKey{identifier.organizationID, identifier.canonical} + winnerIndex, found := winners[key] + if !found { + winners[key] = index + continue + } + winner := identifiers[winnerIndex] + if identifier.stored == identifier.canonical && winner.stored != winner.canonical { + losers = append(losers, winnerIndex) + winners[key] = index + continue + } + losers = append(losers, index) + } + + now := time.Now().UTC() + for _, index := range losers { + identifier := identifiers[index] + if _, err := tx.ExecContext(ctx, ` + UPDATE organization_identifiers + SET active_until = CASE + WHEN active_from IS NULL OR active_from <= ? THEN ? + ELSE active_until + END, + superseded_at = ?, + updated_at = `+s.dialect.Now()+` + WHERE id = ? + AND active_until IS NULL AND superseded_at IS NULL + `, now, now, now, identifier.id); err != nil { + return fmt.Errorf("retire colliding organization domain identifier %d: %w", + identifier.id, err) + } + affectedOrganizations[identifier.organizationID] = struct{}{} + } + + for _, identifier := range identifiers { + if identifier.canonical == identifier.stored { + continue + } + if _, err := tx.ExecContext(ctx, ` + UPDATE organization_identifiers + SET normalized_value = ? + WHERE id = ? + `, identifier.canonical, identifier.id); err != nil { + return fmt.Errorf("canonicalize organization domain identifier %d: %w", + identifier.id, err) + } + if identifier.active { + affectedOrganizations[identifier.organizationID] = struct{}{} + } + } + organizationIDs := make([]int64, 0, len(affectedOrganizations)) + for organizationID := range affectedOrganizations { + organizationIDs = append(organizationIDs, organizationID) + } + organizationPlaceholders, args := sortedIDPlaceholders(organizationIDs) + if organizationPlaceholders == "" { + return nil + } + if _, err := tx.ExecContext(ctx, ` + UPDATE organizations + SET revision = revision + 1, updated_at = `+s.dialect.Now()+` + WHERE id IN (`+organizationPlaceholders+`) + `, args...); err != nil { + return fmt.Errorf("bump canonicalized organization revisions: %w", err) + } + if err := s.bumpEmployedPersonVCardProjectionsTx(ctx, tx, organizationIDs...); err != nil { + return err + } + return nil +} + // backfillLegacyIdentityMatchSourceSupport gives pre-support-table generated // candidates and evidence a conservative source marker. Their exact source is // not recoverable from the old rows, so each row that has no support records is diff --git a/internal/store/organization_domain_migration_test.go b/internal/store/organization_domain_migration_test.go new file mode 100644 index 000000000..555d466a4 --- /dev/null +++ b/internal/store/organization_domain_migration_test.go @@ -0,0 +1,161 @@ +package store_test + +import ( + "database/sql" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil/storetest" +) + +func TestInitSchemaCanonicalizesLegacyOrganizationDomains(t *testing.T) { + require := require.New(t) + assert := assert.New(t) + st := storetest.New(t).Store + ctx := t.Context() + + first, err := st.CreateOrganizationContext(ctx, store.OrganizationInput{ + Name: "Unicode Domain", Kind: store.OrganizationKindCompany, + }) + require.NoError(err) + second, err := st.CreateOrganizationContext(ctx, store.OrganizationInput{ + Name: "Shared Domain", Kind: store.OrganizationKindCompany, + }) + require.NoError(err) + var personID int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + INSERT INTO persons (vcard_uid) VALUES (?) RETURNING id + `), "organization-domain-migration-person").Scan(&personID)) + _, err = st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: personID, OrganizationID: first.ID, Source: store.ProvenanceUser, + }) + require.NoError(err) + _, err = st.AddEmploymentContext(ctx, store.EmploymentInput{ + PersonID: personID, OrganizationID: second.ID, Source: store.ProvenanceUser, + }) + require.NoError(err) + var vcardRevisionBefore int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT vcard_projection_revision FROM persons WHERE id = ? + `), personID).Scan(&vcardRevisionBefore)) + legacyUpdatedAt := time.Date(2001, time.January, 1, 0, 0, 0, 0, time.UTC) + + _, err = st.DB().ExecContext(ctx, st.Rebind(` + UPDATE organizations + SET primary_domain = ?, updated_at = ? + WHERE id IN (?, ?) + `), "bücher.example", legacyUpdatedAt, first.ID, second.ID) + require.NoError(err, "seed legacy Unicode primary domains") + + insertIdentifier := func(organizationID int64, value string) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(ctx, st.Rebind(` + INSERT INTO organization_identifiers ( + organization_id, identifier_kind, identifier_value, + normalized_value, source + ) VALUES (?, 'domain', ?, ?, 'user') + RETURNING id + `), organizationID, value, value).Scan(&id) + require.NoError(err) + return id + } + + unicodeID := insertIdentifier(first.ID, "bücher.example") + asciiID := insertIdentifier(first.ID, "xn--bcher-kva.example") + sharedUnicodeID := insertIdentifier(second.ID, "bücher.example") + + _, err = st.DB().ExecContext(ctx, st.Rebind(` + DELETE FROM applied_migrations + WHERE name = ? + `), "organization_domain_idna_v1") + require.NoError(err, "reset organization domain migration sentinel") + + require.NoError(st.InitSchemaContext(ctx), "upgrade legacy organization domains") + var migrationApplied bool + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT EXISTS ( + SELECT 1 FROM applied_migrations WHERE name = ? + ) + `), "organization_domain_idna_v1").Scan(&migrationApplied)) + assert.True(migrationApplied) + + for _, input := range []struct { + id int64 + revision int64 + }{{first.ID, first.Revision}, {second.ID, second.Revision}} { + organizationID := input.id + organization, getErr := st.GetOrganizationContext(ctx, organizationID) + require.NoError(getErr) + require.NotNil(organization.PrimaryDomain) + assert.Equal("xn--bcher-kva.example", *organization.PrimaryDomain) + assert.Equal(input.revision+1, organization.Revision) + assert.True(organization.UpdatedAt.After(legacyUpdatedAt)) + } + var vcardRevisionAfter int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT vcard_projection_revision FROM persons WHERE id = ? + `), personID).Scan(&vcardRevisionAfter)) + assert.Equal(vcardRevisionBefore+1, vcardRevisionAfter, + "one person employed by two affected organizations must be bumped once") + + type identifierState struct { + normalizedValue string + activeUntil sql.NullTime + supersededAt sql.NullTime + } + readState := func(id int64) identifierState { + t.Helper() + var state identifierState + err := st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT normalized_value, active_until, superseded_at + FROM organization_identifiers + WHERE id = ? + `), id).Scan(&state.normalizedValue, &state.activeUntil, &state.supersededAt) + require.NoError(err) + return state + } + + unicodeState := readState(unicodeID) + asciiState := readState(asciiID) + sharedState := readState(sharedUnicodeID) + assert.Equal("xn--bcher-kva.example", unicodeState.normalizedValue) + assert.True(unicodeState.activeUntil.Valid) + assert.True(unicodeState.supersededAt.Valid) + assert.Equal("xn--bcher-kva.example", asciiState.normalizedValue) + assert.False(asciiState.activeUntil.Valid) + assert.False(asciiState.supersededAt.Valid, + "the already-canonical active identifier must win an in-organization collision") + assert.Equal("xn--bcher-kva.example", sharedState.normalizedValue) + assert.False(sharedState.activeUntil.Valid) + assert.False(sharedState.supersededAt.Valid, + "the same domain on another organization must remain active") + + var activeFirst, activeSecond int + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT COUNT(*) + FROM organization_identifiers + WHERE organization_id = ? AND identifier_kind = 'domain' + AND active_until IS NULL AND superseded_at IS NULL + `), first.ID).Scan(&activeFirst)) + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT COUNT(*) + FROM organization_identifiers + WHERE organization_id = ? AND identifier_kind = 'domain' + AND active_until IS NULL AND superseded_at IS NULL + `), second.ID).Scan(&activeSecond)) + assert.Equal(1, activeFirst) + assert.Equal(1, activeSecond) + + require.NoError(st.InitSchemaContext(ctx), "repeat schema initialization") + assert.Equal(unicodeState, readState(unicodeID), + "the migration ledger must make the collision retirement idempotent") + var repeatedVCardRevision int64 + require.NoError(st.DB().QueryRowContext(ctx, st.Rebind(` + SELECT vcard_projection_revision FROM persons WHERE id = ? + `), personID).Scan(&repeatedVCardRevision)) + assert.Equal(vcardRevisionAfter, repeatedVCardRevision) +} diff --git a/internal/store/organizations.go b/internal/store/organizations.go index 79fbb16ab..2e2675ba7 100644 --- a/internal/store/organizations.go +++ b/internal/store/organizations.go @@ -5,10 +5,11 @@ import ( "database/sql" "errors" "fmt" - "net/url" "slices" "strings" "time" + + "go.kenn.io/msgvault/internal/personfacts" ) var ( @@ -579,29 +580,8 @@ func NormalizeOrganizationName(raw string) string { // NormalizeDomain reduces a domain, URL, or email to a bare lowercase host. func NormalizeDomain(raw string) string { - candidate := strings.ToLower(strings.TrimSpace(raw)) - if at := strings.LastIndex(candidate, "@"); at >= 0 { - candidate = candidate[at+1:] - } - var host string - if strings.Contains(candidate, "://") { - parsed, err := url.Parse(candidate) - if err != nil { - return "" - } - host = parsed.Hostname() - } else { - if strings.Contains(candidate, "/") { - return "" - } - parsed, err := url.Parse("//" + candidate) - if err != nil { - return "" - } - host = parsed.Hostname() - } - host = strings.TrimSuffix(strings.TrimPrefix(host, "www."), ".") - if !strings.Contains(host, ".") || strings.ContainsAny(host, "/ \t\r\n") { + host, err := personfacts.NormalizeDomain(raw) + if err != nil { return "" } return host diff --git a/internal/store/organizations_test.go b/internal/store/organizations_test.go index 9363ed3db..21f2b3a2c 100644 --- a/internal/store/organizations_test.go +++ b/internal/store/organizations_test.go @@ -8,6 +8,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/testutil" ) @@ -216,6 +217,24 @@ func TestNormalizeOrganizationNameAndDomain(t *testing.T) { } } +func TestNormalizeDomainMatchesStoreNormalization(t *testing.T) { + for _, raw := range []string{ + "Example.COM", "https://www.bücher.example/jobs", "person@BÜCHER.example", + "https://www.Example.com/search?q=user@other.example", + "https://user:secret@www.Example.com/path", + "", "localhost", "example.com/path", "https://", + } { + t.Run(raw, func(t *testing.T) { + shared, err := personfacts.NormalizeDomain(raw) + if err != nil { + assert.Empty(t, store.NormalizeDomain(raw)) + return + } + assert.Equal(t, shared, store.NormalizeDomain(raw)) + }) + } +} + func TestMergeOrganizationsRejectsSelfWithoutChangingTheRoot(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/person_attribute_fact_lock_pg_test.go b/internal/store/person_attribute_fact_lock_pg_test.go new file mode 100644 index 000000000..504f059cf --- /dev/null +++ b/internal/store/person_attribute_fact_lock_pg_test.go @@ -0,0 +1,256 @@ +package store + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPostgreSQLManualPersonAttributeWritesLockBeforeMutation(t *testing.T) { + for _, test := range []struct { + name string + run func(*testing.T, *Store, int64) error + }{ + { + name: "set", + run: func(t *testing.T, st *Store, personID int64) error { + t.Helper() + _, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("email")}, + Source: ProvenanceUser, + }) + return err + }, + }, + { + name: "supersede", + run: func(t *testing.T, st *Store, personID int64) error { + t.Helper() + seed, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("chat")}, + Source: ProvenanceExtraction, + }) + if err != nil { + return err + } + _, err = st.SupersedePersonAttributeValueContext(t.Context(), PersonAttributeSupersedeInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + ExpectedValueID: &seed.Value.ID, + }) + return err + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL advisory-lock concurrency regression") + } + target := targets[AttributeSlugPrimaryChannel] + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + t.Cleanup(cancel) + blocker, err := st.db.BeginTx(ctx, nil) + requirements.NoError(err) + t.Cleanup(func() { _ = blocker.Rollback() }) + var blockerPID int + requirements.NoError(blocker.QueryRowContext(ctx, + `SELECT pg_backend_pid()`).Scan(&blockerPID)) + requirements.NoError(st.lockProfileIdentityKeyTxContext(ctx, blocker, + "person-fact-target", personID, target.Kind, target.Key)) + + writeDone := make(chan error, 1) + go func() { writeDone <- test.run(t, st, personID) }() + waitForManualPersonAttributeTargetLock(t, st, personID, blockerPID, writeDone) + + requirements.NoError(blocker.Commit()) + select { + case writeErr := <-writeDone: + requirements.NoError(writeErr) + case <-ctx.Done(): + requirements.FailNow("manual person attribute write did not finish", ctx.Err()) + } + + pins, err := st.ListPersonFactPinsContext(ctx, personID) + requirements.NoError(err) + requirements.Len(pins, 1) + assertions.Equal(personfacts.TargetAttribute, pins[0].Target.Kind) + assertions.Equal(target.Key, pins[0].Target.Key) + assertions.True(pins[0].Pinned) + }) + } +} + +func TestPostgreSQLSystemPersonAttributeWriteWaitsForFactResolution(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + if !st.IsPostgreSQL() { + t.Skip("PostgreSQL advisory-lock concurrency regression") + } + target := targets[AttributeSlugPrimaryChannel] + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + t.Cleanup(cancel) + blocker, err := st.db.BeginTx(ctx, nil) + requirements.NoError(err) + t.Cleanup(func() { _ = blocker.Rollback() }) + var blockerPID int + requirements.NoError(blocker.QueryRowContext(ctx, + `SELECT pg_backend_pid()`).Scan(&blockerPID)) + requirements.NoError(st.lockProfileIdentityKeyTxContext(ctx, blocker, + "person-fact-target", personID, target.Kind, target.Key)) + + generationInput := personFactProjectionInput(personID, "system-write-lock", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"email"`, "system-write-lock"), + }, nil) + type generationOutcome struct { + result *personfacts.GenerationResult + err error + } + generationDone := make(chan generationOutcome, 1) + go func() { + result, applyErr := st.ApplyPersonFactGenerationContext(ctx, generationInput, nil) + generationDone <- generationOutcome{result: result, err: applyErr} + }() + + var generationPID int + requirements.Eventually(func() bool { + generationPID = personAttributePostgreSQLWaitingWriterPID(t, st, blockerPID) + return generationPID > 0 + }, 5*time.Second, 10*time.Millisecond, + "fact resolution did not wait for the blocked target lock") + + type attributeOutcome struct { + write *PersonAttributeWrite + err error + } + attributeDone := make(chan attributeOutcome, 1) + go func() { + write, writeErr := st.SetPersonAttributeValueContext(ctx, PersonAttributeValueInput{ + PersonID: personID, + DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("phone")}, + Source: ProvenanceSystem, + }) + attributeDone <- attributeOutcome{write: write, err: writeErr} + }() + + var earlyAttribute *attributeOutcome + requirements.Eventually(func() bool { + select { + case outcome := <-attributeDone: + earlyAttribute = &outcome + return true + default: + return personAttributePostgreSQLBlockedWriterPID(t, st, generationPID) > 0 + } + }, 5*time.Second, 10*time.Millisecond, + "system attribute write neither completed nor waited for fact resolution") + if earlyAttribute != nil { + requirements.NoError(earlyAttribute.err) + requirements.NotNil(earlyAttribute.write) + } + assertions.Nil(earlyAttribute, + "system attribute write bypassed the in-flight fact resolution locks") + + requirements.NoError(blocker.Commit()) + select { + case outcome := <-generationDone: + requirements.NoError(outcome.err) + requirements.NotNil(outcome.result) + case <-ctx.Done(): + requirements.FailNow("fact resolution did not finish", ctx.Err()) + } + if earlyAttribute == nil { + select { + case outcome := <-attributeDone: + requirements.NoError(outcome.err) + requirements.NotNil(outcome.write) + case <-ctx.Done(): + requirements.FailNow("system attribute write did not finish", ctx.Err()) + } + } +} + +func waitForManualPersonAttributeTargetLock( + t *testing.T, st *Store, personID int64, blockerPID int, writeDone <-chan error, +) { + t.Helper() + requirements := require.New(t) + deadline := time.NewTimer(5 * time.Second) + t.Cleanup(func() { deadline.Stop() }) + for { + select { + case err := <-writeDone: + requirements.NoError(err) + requirements.FailNow("manual person attribute write bypassed the target lock") + case <-deadline.C: + requirements.FailNow("manual person attribute write did not wait for the target lock") + default: + } + + probeCtx, cancel := context.WithTimeout(t.Context(), 50*time.Millisecond) + probe, err := st.db.BeginTx(probeCtx, nil) + requirements.NoError(err) + lockErr := st.lockProfileIdentityKeyTxContext( + probeCtx, probe, "person-fact-generation", personID) + _ = probe.Rollback() + cancel() + if errors.Is(lockErr, context.DeadlineExceeded) { + requirements.Eventually(func() bool { + return personAttributePostgreSQLWaitingWriterPID(t, st, blockerPID) > 0 + }, time.Second, 10*time.Millisecond, + "manual write held the generation lock but did not wait for the target lock") + return + } + requirements.NoError(lockErr) + } +} + +func personAttributePostgreSQLWaitingWriterPID(t *testing.T, st *Store, blockerPID int) int { + t.Helper() + var writerPID int + require.NoError(t, st.db.QueryRowContext(t.Context(), ` + SELECT COALESCE(MIN(activity.pid), 0) + FROM pg_stat_activity activity + JOIN pg_locks waiting + ON waiting.pid = activity.pid + AND waiting.locktype = 'advisory' + AND NOT waiting.granted + WHERE activity.datname = current_database() + AND activity.wait_event_type = 'Lock' + AND $1 = ANY(pg_blocking_pids(activity.pid)) + AND EXISTS ( + SELECT 1 FROM pg_locks held + WHERE held.pid = activity.pid + AND held.locktype = 'advisory' + AND held.granted + )`, blockerPID).Scan(&writerPID)) + return writerPID +} + +func personAttributePostgreSQLBlockedWriterPID(t *testing.T, st *Store, blockerPID int) int { + t.Helper() + var writerPID int + require.NoError(t, st.db.QueryRowContext(t.Context(), ` + SELECT COALESCE(MIN(activity.pid), 0) + FROM pg_stat_activity activity + JOIN pg_locks waiting + ON waiting.pid = activity.pid + AND waiting.locktype = 'advisory' + AND NOT waiting.granted + WHERE activity.datname = current_database() + AND activity.wait_event_type = 'Lock' + AND $1 = ANY(pg_blocking_pids(activity.pid))`, blockerPID).Scan(&writerPID)) + return writerPID +} diff --git a/internal/store/person_attributes.go b/internal/store/person_attributes.go index e486f2402..cc02439b2 100644 --- a/internal/store/person_attributes.go +++ b/internal/store/person_attributes.go @@ -8,6 +8,8 @@ import ( "fmt" "strings" "time" + + "go.kenn.io/msgvault/internal/personfacts" ) // PersonAttributeValue is one typed value and its history metadata. @@ -162,78 +164,29 @@ func (s *Store) setPersonAttributeValueOnce( write := &PersonAttributeWrite{DryRun: input.DryRun} err := s.withTxContext(ctx, func(tx *loggedTx) error { + transactionTime := time.Now().UTC() + if err := s.lockPersonFactAttributeTx( + ctx, tx, input.PersonID, input.DefinitionSlug); err != nil { + return err + } loaded, err := s.getAttributeDefinitionBySlugTx( ctx, tx, AttributeObjectPerson, input.DefinitionSlug) if err != nil { return err } definition := *loaded - if err := writableAttributeDefinition(definition); err != nil { - return err - } - value, err := normalizeAttributeValue(definition, input.Value) + write, err = s.setPersonAttributeValueTx( + ctx, tx, definition, input, activeFrom, transactionTime) if err != nil { return err } - input.Value = value - if definition.Cardinality == AttributeCardinalitySingle && - input.Ordinal != nil && *input.Ordinal != 0 { - return fmt.Errorf( - "%w: ordinal %d is not allowed on %s, which declares cardinality single", - ErrAttributeValueInvalid, *input.Ordinal, definition.Slug) - } - if input.Value.Type == AttributeValueRecordReference { - if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + write.DryRun = input.DryRun + if input.Source.IsDeclared() { + if err := s.appendManualPersonFactAttributePinTx( + ctx, tx, definition, input.PersonID, personAttributeActor(input.Actor, input.Source)); err != nil { return err } } - var personExists int - if err := tx.QueryRowContext(ctx, - `SELECT COUNT(*) FROM persons WHERE id = ?`, input.PersonID, - ).Scan(&personExists); err != nil { - return fmt.Errorf("verify person %d: %w", input.PersonID, err) - } - if personExists == 0 { - return ErrPersonNotFound - } - if err := s.verifyAttributeRecordTargetTx(ctx, tx, input.Value); err != nil { - return err - } - ordinal, err := s.resolveAttributeOrdinalTx(ctx, tx, definition, input) - if err != nil { - return err - } - current, hasCurrent, err := s.currentPersonAttributeValueTx( - ctx, tx, input.PersonID, definition.ID, ordinal) - if err != nil { - return err - } - if input.ExpectedValueID != nil && - (!hasCurrent || current.ID != *input.ExpectedValueID) { - conflict := &AttributeValueConflictError{} - if hasCurrent { - conflict.CurrentValue = current - } - return conflict - } - if hasCurrent { - if activeFrom.Before(current.ActiveFrom) { - return fmt.Errorf("%w: active_from precedes the current value", - ErrAttributeValueInvalid) - } - closed, err := s.closePersonAttributeValueTx( - ctx, tx, current.ID, activeFrom, time.Now().UTC()) - if err != nil { - return err - } - write.Superseded = closed - } - inserted, err := s.insertPersonAttributeValueTx( - ctx, tx, definition, input, ordinal, activeFrom) - if err != nil { - return err - } - write.Value = inserted if err := s.bumpPersonVCardProjectionsTx( ctx, tx, input.PersonID, ); err != nil { @@ -241,9 +194,6 @@ func (s *Store) setPersonAttributeValueOnce( } if input.DryRun { write.Value.ID = 0 - if write.Superseded != nil { - write.Superseded.ID = current.ID - } return errAttributeDryRun } return nil @@ -254,6 +204,86 @@ func (s *Store) setPersonAttributeValueOnce( return write, nil } +func (s *Store) setPersonAttributeValueTx( + ctx context.Context, tx *loggedTx, definition AttributeDefinition, + input PersonAttributeValueInput, activeFrom time.Time, transactionTime time.Time, +) (*PersonAttributeWrite, error) { + if err := writableAttributeDefinition(definition); err != nil { + return nil, err + } + value, err := normalizeAttributeValue(definition, input.Value) + if err != nil { + return nil, err + } + input.Value = value + if definition.Cardinality == AttributeCardinalitySingle && + input.Ordinal != nil && *input.Ordinal != 0 { + return nil, fmt.Errorf( + "%w: ordinal %d is not allowed on %s, which declares cardinality single", + ErrAttributeValueInvalid, *input.Ordinal, definition.Slug) + } + if input.Value.Type == AttributeValueRecordReference { + if err := s.lockIdentityMutationTxContext(ctx, tx); err != nil { + return nil, err + } + } + var personExists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM persons WHERE id = ?`, input.PersonID, + ).Scan(&personExists); err != nil { + return nil, fmt.Errorf("verify person %d: %w", input.PersonID, err) + } + if personExists == 0 { + return nil, ErrPersonNotFound + } + if err := s.verifyAttributeRecordTargetTx(ctx, tx, input.Value); err != nil { + return nil, err + } + ordinal, err := s.resolveAttributeOrdinalTx(ctx, tx, definition, input) + if err != nil { + return nil, err + } + current, hasCurrent, err := s.currentPersonAttributeValueTx( + ctx, tx, input.PersonID, definition.ID, ordinal) + if err != nil { + return nil, err + } + if input.ExpectedValueID != nil && (!hasCurrent || current.ID != *input.ExpectedValueID) { + conflict := &AttributeValueConflictError{} + if hasCurrent { + conflict.CurrentValue = current + } + return nil, conflict + } + write := &PersonAttributeWrite{DryRun: input.DryRun} + if hasCurrent { + if activeFrom.Before(current.ActiveFrom) { + return nil, fmt.Errorf("%w: active_from precedes the current value", + ErrAttributeValueInvalid) + } + closed, err := s.closePersonAttributeValueTx( + ctx, tx, current.ID, activeFrom, transactionTime) + if err != nil { + return nil, err + } + write.Superseded = closed + } + inserted, err := s.insertPersonAttributeValueTx( + ctx, tx, definition, input, ordinal, activeFrom, transactionTime) + if err != nil { + return nil, err + } + write.Value = inserted + return write, nil +} + +func personAttributeActor(actor *string, source Provenance) string { + if actor != nil && strings.TrimSpace(*actor) != "" { + return strings.TrimSpace(*actor) + } + return string(source) +} + func (s *Store) resolveAttributeOrdinalTx( ctx context.Context, tx *loggedTx, definition AttributeDefinition, input PersonAttributeValueInput, @@ -356,6 +386,7 @@ func (s *Store) closePersonAttributeValueTx( func (s *Store) insertPersonAttributeValueTx( ctx context.Context, tx *loggedTx, definition AttributeDefinition, input PersonAttributeValueInput, ordinal int64, activeFrom time.Time, + transactionTime time.Time, ) (*PersonAttributeValue, error) { var jsonValue any if len(input.Value.JSON) > 0 { @@ -372,15 +403,15 @@ func (s *Store) insertPersonAttributeValueTx( value_text, value_integer, value_real, value_boolean, value_date, value_timestamp, value_json, value_record_type, value_record_id, - active_from, active_until, source, source_ref, confidence, actor - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?, ?, ?, ?, ?, ?) + active_from, active_until, created_at, source, source_ref, confidence, actor + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, %s, ?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id `, s.dialect.JSONBindExpr()), input.PersonID, definition.ID, ordinal, input.Value.Text, input.Value.Integer, input.Value.Real, input.Value.Boolean, input.Value.Date, input.Value.Timestamp, jsonValue, input.Value.RecordType, input.Value.RecordID, - activeFrom, activeUntil, string(input.Source), input.SourceRef, + activeFrom, activeUntil, transactionTime, string(input.Source), input.SourceRef, input.Confidence, input.Actor, ).Scan(&insertedID); err != nil { return nil, fmt.Errorf("insert attribute value for %s: %w", definition.Slug, err) @@ -422,6 +453,11 @@ func (s *Store) supersedePersonAttributeValueOnce( ) (*PersonAttributeWrite, error) { write := &PersonAttributeWrite{DryRun: input.DryRun} err := s.withTxContext(ctx, func(tx *loggedTx) error { + transactionTime := time.Now().UTC() + if err := s.lockPersonFactAttributeTx( + ctx, tx, input.PersonID, input.DefinitionSlug); err != nil { + return err + } definition, err := s.getAttributeDefinitionBySlugTx( ctx, tx, AttributeObjectPerson, input.DefinitionSlug) if err != nil { @@ -460,11 +496,15 @@ func (s *Store) supersedePersonAttributeValueOnce( ErrAttributeValueInvalid) } closed, err := s.closePersonAttributeValueTx( - ctx, tx, current.ID, at, time.Now().UTC()) + ctx, tx, current.ID, at, transactionTime) if err != nil { return err } write.Superseded = closed + if err := s.appendManualPersonFactAttributePinTx( + ctx, tx, *definition, input.PersonID, personAttributeActor(input.Actor, ProvenanceUser)); err != nil { + return err + } if err := s.bumpPersonVCardProjectionsTx( ctx, tx, input.PersonID, ); err != nil { @@ -481,6 +521,42 @@ func (s *Store) supersedePersonAttributeValueOnce( return write, nil } +// lockPersonFactAttributeTx joins attribute writes to the same +// generation-then-target lock order used by automatic fact resolution. The +// initial definition lookup is intentionally unlocked: taking its row lock +// before these advisory locks would invert the automatic resolver's order. +func (s *Store) lockPersonFactAttributeTx( + ctx context.Context, tx *loggedTx, personID int64, definitionSlug string, +) error { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-generation", personID); err != nil { + return err + } + var targetKey string + err := tx.QueryRowContext(ctx, ` + SELECT universal_id FROM attribute_definitions + WHERE object_type = ? AND slug = ? + `, string(AttributeObjectPerson), definitionSlug).Scan(&targetKey) + if errors.Is(err, sql.ErrNoRows) { + return ErrAttributeDefinitionNotFound + } + if err != nil { + return fmt.Errorf("resolve person fact target for attribute %s: %w", definitionSlug, err) + } + return s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-target", personID, personfacts.TargetAttribute, targetKey) +} + +func personFactAttributeTargetRef(definition AttributeDefinition) (personfacts.TargetRef, error) { + descriptor, err := personFactAttributeDescriptor(definition) + if err != nil { + return personfacts.TargetRef{}, err + } + return personfacts.TargetRef{ + Kind: descriptor.Kind, Key: descriptor.Key, Revision: descriptor.Revision, + }, nil +} + func scanPersonAttributeValue(row scanner) (*PersonAttributeValue, error) { var ( value PersonAttributeValue diff --git a/internal/store/person_attributes_test.go b/internal/store/person_attributes_test.go index fa097adee..852ebdce8 100644 --- a/internal/store/person_attributes_test.go +++ b/internal/store/person_attributes_test.go @@ -312,6 +312,44 @@ func TestBackdatedReplacementSeparatesValidityAndAuditTimes(t *testing.T) { assert.Equal(replacementActiveFrom, *replacement.Superseded.ActiveUntil) assert.True(replacement.Superseded.SupersededAt.After(beforeWrite)) assert.True(replacement.Superseded.SupersededAt.Before(afterWrite)) + assert.Equal(replacement.Value.CreatedAt, *replacement.Superseded.SupersededAt, + "one transaction timestamp must bind the old audit close and new audit open") +} + +func TestPersonAttributePublicDryRunAndCASDoNotPersistPins(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st := testutil.NewTestStore(t) + ctx := context.Background() + person := mustAttributePerson(t, st) + + preview, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: person, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, DryRun: true, + }) + require.NoError(err) + assert.Zero(preview.Value.ID) + pins, err := st.ListPersonFactPinsContext(ctx, person) + require.NoError(err) + assert.Empty(pins) + + committed, err := st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: person, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("email")}, + Source: store.ProvenanceUser, + }) + require.NoError(err) + stale := committed.Value.ID + 1000 + _, err = st.SetPersonAttributeValueContext(ctx, store.PersonAttributeValueInput{ + PersonID: person, DefinitionSlug: store.AttributeSlugPrimaryChannel, + Value: store.AttributeValue{Type: store.AttributeValueText, Text: new("chat")}, + Source: store.ProvenanceUser, ExpectedValueID: &stale, + }) + require.ErrorIs(err, store.ErrAttributeValueConflict) + pins, err = st.ListPersonFactPinsContext(ctx, person) + require.NoError(err) + require.Len(pins, 1) } func TestMultiCardinalityAppendsPerOrdinalAndDerivedConfidenceIsAllowed(t *testing.T) { diff --git a/internal/store/person_fact_catalog.go b/internal/store/person_fact_catalog.go new file mode 100644 index 000000000..4ed4c1c5d --- /dev/null +++ b/internal/store/person_fact_catalog.go @@ -0,0 +1,72 @@ +package store + +import ( + "context" + + "go.kenn.io/msgvault/internal/personfacts" +) + +// BuildPersonFactCatalogContext maps person attribute definitions into the +// provider-neutral fact catalog. +func (s *Store) BuildPersonFactCatalogContext( + ctx context.Context, includeSensitive bool, +) (personfacts.Catalog, error) { + return s.buildPersonFactCatalogContext(ctx, s.db, includeSensitive) +} + +func (s *Store) buildPersonFactCatalogContext( + ctx context.Context, queryer contextRowsQuerier, includeSensitive bool, +) (personfacts.Catalog, error) { + definitions, err := s.listAttributeDefinitionsContext(ctx, queryer, AttributeDefinitionFilter{ + ObjectType: AttributeObjectPerson, + IncludeHidden: true, + }) + if err != nil { + return personfacts.Catalog{}, err + } + mapped := make([]personfacts.Definition, 0, len(definitions)) + for _, definition := range definitions { + mapped = append(mapped, personfacts.Definition{ + UniversalID: definition.UniversalID, + Slug: definition.Slug, + Description: personFactStringValue(definition.Description), + ValueType: personfacts.ValueType(definition.ValueType), + Cardinality: personfacts.Cardinality(definition.Cardinality), + RecordTarget: personFactStringValue(definition.RecordTarget), + MaxLength: personFactMaxLength(definition.Options), + Choices: personFactChoices(definition.Options), + Sensitive: definition.IsSensitive, + Active: definition.IsActive, + APIMutable: definition.APIMutable, + Derived: definition.DerivedSource != nil, + }) + } + return personfacts.BuildCatalog(mapped, personfacts.CatalogOptions{ + IncludeSensitive: includeSensitive, + }) +} + +func personFactMaxLength(options *AttributeOptions) int { + if options == nil { + return 0 + } + return options.MaxLength +} + +func personFactChoices(options *AttributeOptions) []personfacts.ChoiceDescriptor { + if options == nil { + return nil + } + choices := make([]personfacts.ChoiceDescriptor, len(options.Choices)) + for i, choice := range options.Choices { + choices[i] = personfacts.ChoiceDescriptor{Value: choice.Value, Label: choice.Label} + } + return choices +} + +func personFactStringValue(value *string) string { + if value == nil { + return "" + } + return *value +} diff --git a/internal/store/person_fact_catalog_test.go b/internal/store/person_fact_catalog_test.go new file mode 100644 index 000000000..33bebdb32 --- /dev/null +++ b/internal/store/person_fact_catalog_test.go @@ -0,0 +1,58 @@ +package store_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestStoreBuildPersonFactCatalogCarriesMaxLength(t *testing.T) { + st := testutil.NewTestStore(t) + input := personTextDefinition("catalog_max_length") + description := "Short biographical note" + input.Description = &description + input.Options = &store.AttributeOptions{MaxLength: 12} + created, err := st.CreateAttributeDefinitionContext(t.Context(), input) + require.NoError(t, err) + + first, err := st.BuildPersonFactCatalogContext(t.Context(), false) + require.NoError(t, err) + firstTarget := personFactCatalogTarget(t, first, created.UniversalID) + assert.Equal(t, 12, firstTarget.MaxLength) +} + +func personFactCatalogTarget( + t *testing.T, catalog personfacts.Catalog, universalID string, +) personfacts.TargetDescriptor { + t.Helper() + for _, target := range catalog.Targets { + if target.Key == universalID { + return target + } + } + require.FailNow(t, "catalog target not found", universalID) + return personfacts.TargetDescriptor{} +} + +func TestStoreBuildPersonFactCatalogExcludesOverLimitDescription(t *testing.T) { + require := require.New(t) + + st := testutil.NewTestStore(t) + input := personTextDefinition("over_limit_catalog_description") + description := strings.Repeat("🙂", 281) + input.Description = &description + created, err := st.CreateAttributeDefinitionContext(t.Context(), input) + require.NoError(err) + + catalog, err := st.BuildPersonFactCatalogContext(t.Context(), false) + require.NoError(err) + for _, target := range catalog.Targets { + assert.NotEqual(t, created.UniversalID, target.Key, + "legacy description must not appear as an inference target") + } +} diff --git a/internal/store/person_fact_employment_test.go b/internal/store/person_fact_employment_test.go new file mode 100644 index 000000000..71d6ff439 --- /dev/null +++ b/internal/store/person_fact_employment_test.go @@ -0,0 +1,2217 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/stdlib" + "github.com/mattn/go-sqlite3" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" + "go.kenn.io/msgvault/internal/sqliteutil" +) + +func TestPersonFactOrganizationFingerprintPreservesLegacyLookupKeyEncoding(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + keys := []personFactOrganizationLookupKey{ + {Kind: "domain", Value: "example.com"}, + {Kind: "name", Value: "example incorporated"}, + } + encodedKey, err := json.Marshal(keys[0]) + requirements.NoError(err) + assertions.True(bytes.Equal([]byte(`{"Kind":"domain","Value":"example.com"}`), encodedKey), + "organization lookup-key encoding changed") + + ref := personfacts.OrganizationReference{Name: "Example Incorporated", Domain: "example.com"} + match := OrganizationMatch{Status: OrganizationAmbiguous, CandidateIDs: []int64{9, 3}} + fingerprint, err := personFactOrganizationFingerprint(ref, keys, match) + requirements.NoError(err) + assertions.Equal("1bf99b4a089f65e5830da6f735673810a38118d47561d11c325c92b85d05cf75", fingerprint) +} + +func TestPersonFactEmploymentResolvesOrganizationReferences(t *testing.T) { + tests := []struct { + name string + seed func(*testing.T, *Store) *Organization + submitted func(*Organization) string + }{ + { + name: "existing id", + seed: func(t *testing.T, st *Store) *Organization { + t.Helper() + return createPersonFactOrganization(t, st, "Existing Company", "existing.example") + }, + submitted: func(organization *Organization) string { + return fmt.Sprintf(`{"organization":{"id":%d,"name":"Existing Company","domain":"existing.example"},"title":"Engineer"}`, organization.ID) + }, + }, + { + name: "exact domain", + seed: func(t *testing.T, st *Store) *Organization { + t.Helper() + return createPersonFactOrganization(t, st, "Domain Company", "domain.example") + }, + submitted: func(*Organization) string { + return `{"organization":{"name":"Domain Company","domain":"domain.example"},"title":"Engineer"}` + }, + }, + { + name: "normalized name without domain", + seed: func(t *testing.T, st *Store) *Organization { + t.Helper() + return createPersonFactOrganization(t, st, "Normalized Company", "") + }, + submitted: func(*Organization) string { + return `{"organization":{"name":" NORMALIZED Company "},"title":"Engineer"}` + }, + }, + { + name: "zero match creates company", + seed: func(*testing.T, *Store) *Organization { return nil }, + submitted: func(*Organization) string { + return `{"organization":{"name":"Created Company","domain":"created.example"},"title":"Engineer"}` + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + seeded := test.seed(t, st) + claim := personFactProjectionClaim(personID, target, test.submitted(seeded), test.name) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, test.name, []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Projections, 1) + assert.Equal("employment", result.Projections[0].Kind) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(employments, 1) + if seeded != nil { + assert.Equal(seeded.ID, employments[0].OrganizationID) + } else { + created, loadErr := st.GetOrganizationContext(t.Context(), employments[0].OrganizationID) + require.NoError(loadErr) + assert.Equal("Created Company", created.Name) + assert.Equal(OrganizationKindCompany, created.Kind) + require.NotNil(created.PrimaryDomain) + assert.Equal("created.example", *created.PrimaryDomain) + } + }) + } +} + +func TestPersonFactEmploymentRequiresEveryOrganizationIdentifierToMatch(t *testing.T) { + type organizationFixture struct { + name string + domain string + } + tests := []struct { + name string + organizations []organizationFixture + reference organizationFixture + wantReuse int + }{ + { + name: "same company matches name and domain", + organizations: []organizationFixture{ + {name: "Exact Company", domain: "exact.complete.example"}, + }, + reference: organizationFixture{name: "Exact Company", domain: "exact.complete.example"}, + wantReuse: 0, + }, + { + name: "matching name does not override missing domain", + organizations: []organizationFixture{ + {name: "Name Match Company", domain: "old-name-match.example"}, + }, + reference: organizationFixture{name: "Name Match Company", domain: "new-name-match.example"}, + wantReuse: -1, + }, + { + name: "matching domain does not override missing name", + organizations: []organizationFixture{ + {name: "Original Domain Company", domain: "domain-match.example"}, + }, + reference: organizationFixture{name: "Different Domain Company", domain: "domain-match.example"}, + wantReuse: -1, + }, + { + name: "name and domain resolving to different companies do not reuse either", + organizations: []organizationFixture{ + {name: "Split Name Company", domain: "split-name.example"}, + {name: "Split Domain Company", domain: "split-domain.example"}, + }, + reference: organizationFixture{name: "Split Name Company", domain: "split-domain.example"}, + wantReuse: -1, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + seededIDs := make([]int64, 0, len(test.organizations)) + for _, fixture := range test.organizations { + organization := createPersonFactOrganization(t, st, fixture.name, fixture.domain) + seededIDs = append(seededIDs, organization.ID) + } + submitted := fmt.Sprintf( + `{"organization":{"name":%q,"domain":%q},"title":"Engineer"}`, + test.reference.name, test.reference.domain) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "complete-reference-"+test.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "complete-reference"), + }, nil), nil) + require.NoError(err) + require.Len(result.Projections, 1) + employment, err := st.GetEmploymentContext(t.Context(), result.Projections[0].RowID) + require.NoError(err) + if test.wantReuse >= 0 { + assert.Equal(seededIDs[test.wantReuse], employment.OrganizationID) + return + } + assert.NotContains(seededIDs, employment.OrganizationID) + created, err := st.GetOrganizationContext(t.Context(), employment.OrganizationID) + require.NoError(err) + assert.Equal(test.reference.name, created.Name) + require.NotNil(created.PrimaryDomain) + assert.Equal(test.reference.domain, *created.PrimaryDomain) + }) + } +} + +func TestPersonFactOrganizationExistingIDRejectsDisagreeingIdentity(t *testing.T) { + st, _, _ := newPersonFactProjectionStore(t) + organization := createPersonFactOrganization(t, st, "Exact Company", "exact.example") + ref := personfacts.OrganizationReference{ + ID: &organization.ID, Name: "Different Company", Domain: "exact.example", + } + + err := st.withTxContext(t.Context(), func(tx *loggedTx) error { + _, prepareErr := st.preparePersonFactOrganizationTx(t.Context(), tx, ref) + return prepareErr + }) + require.ErrorIs(t, err, ErrOrganizationInvalid) +} + +func TestPersonFactOrganizationReferenceFailureClassifier(t *testing.T) { + assert := assert.New(t) + for _, err := range []error{ + ErrOrganizationNotFound, + fmt.Errorf("wrapped missing: %w", ErrOrganizationNotFound), + ErrOrganizationInvalid, + fmt.Errorf("wrapped invalid: %w", ErrOrganizationInvalid), + } { + failure := personFactOrganizationReferenceFailure(err) + if assert.NotNil(failure) { + assert.Equal(personfacts.DecisionInvalid, failure.Action) + assert.Equal(personfacts.ReasonMalformedValue, failure.Reason) + assert.Contains(failure.Detail, err.Error()) + } + } + assert.Nil(personFactOrganizationReferenceFailure(sql.ErrConnDone)) +} + +func TestPersonFactEmploymentInvalidValuesDoNotRollbackValidSibling(t *testing.T) { + tests := []struct { + name string + employment func(*testing.T, *Store) string + }{ + { + name: "missing organization id", + employment: func(t *testing.T, st *Store) string { + t.Helper() + organization := createPersonFactOrganization(t, st, "Existing", "existing.invalid-ref") + return fmt.Sprintf(`{"organization":{"id":%d,"name":"Missing"},"title":"Engineer"}`, organization.ID+9999) + }, + }, + { + name: "mismatched organization name", + employment: func(t *testing.T, st *Store) string { + t.Helper() + organization := createPersonFactOrganization(t, st, "Exact", "exact.invalid-ref") + return fmt.Sprintf(`{"organization":{"id":%d,"name":"Different"},"title":"Engineer"}`, organization.ID) + }, + }, + { + name: "retired organization", + employment: func(t *testing.T, st *Store) string { + t.Helper() + require := require.New(t) + organization := createPersonFactOrganization(t, st, "Retired", "retired.invalid-ref") + _, err := st.db.Exec(`UPDATE organizations SET retired_at = CURRENT_TIMESTAMP WHERE id = ?`, organization.ID) + require.NoError(err) + return fmt.Sprintf(`{"organization":{"id":%d,"name":"Retired"},"title":"Engineer"}`, organization.ID) + }, + }, + { + name: "non-company organization", + employment: func(t *testing.T, st *Store) string { + t.Helper() + require := require.New(t) + organization := createPersonFactOrganization(t, st, "School", "school.invalid-ref") + _, err := st.db.Exec(`UPDATE organizations SET kind = ? WHERE id = ?`, OrganizationKindSchool, organization.ID) + require.NoError(err) + return fmt.Sprintf(`{"organization":{"id":%d,"name":"School"},"title":"Engineer"}`, organization.ID) + }, + }, + { + name: "reversed partial dates", + employment: func(*testing.T, *Store) string { + return `{"organization":{"name":"Dates"},"title":"Engineer","start_date":{"year":2025,"month":7},"end_date":{"year":2025,"month":6}}` + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + employmentTarget := projectionTargetBySlug(t, st, "employment") + input := personFactProjectionInput(personID, "invalid-employment-"+test.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, employmentTarget, test.employment(t, st), "invalid-employment"), + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "valid-sibling"), + }, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Projections, 1) + assert.Equal("person_attribute", result.Projections[0].Kind) + invalid := 0 + for _, decision := range result.Decisions { + if decision.Action == personfacts.DecisionInvalid { + invalid++ + assert.Equal(personfacts.ReasonMalformedValue, decision.Reason) + } + } + assert.Equal(1, invalid) + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + for _, claim := range claims { + if claim.Target.Kind == personfacts.TargetEmployment { + assert.Nil(claim.Normalized) + require.NotNil(claim.Failure) + assert.Equal(personfacts.ReasonMalformedValue, claim.Failure.Reason) + } + } + }) + } +} + +func TestPersonFactOrganizationAmbiguityRetainsWithoutMerge(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + first := createPersonFactOrganization(t, st, "Shared Company", "first.example") + second := createPersonFactOrganization(t, st, "Shared Company", "second.example") + var prepared OrganizationMatch + require.NoError(st.withTxContext(t.Context(), func(tx *loggedTx) error { + var prepareErr error + prepared, prepareErr = st.preparePersonFactOrganizationTx(t.Context(), tx, + personfacts.OrganizationReference{Name: "Shared Company"}) + return prepareErr + })) + assert.Equal(OrganizationAmbiguous, prepared.Status) + assert.Equal([]int64{first.ID, second.ID}, prepared.CandidateIDs) + assert.NotEmpty(prepared.Fingerprint) + claim := personFactProjectionClaim(personID, target, + `{"organization":{"name":"Shared Company"},"title":"Engineer"}`, "ambiguous") + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "ambiguous", []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionAmbiguousRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonOrganizationAmbiguous, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Empty(employments) + for _, organizationID := range []int64{first.ID, second.ID} { + organization, loadErr := st.GetOrganizationContext(t.Context(), organizationID) + require.NoError(loadErr) + assert.Nil(organization.MergedIntoID) + assert.Nil(organization.RetiredAt) + } +} + +func TestPersonFactOrganizationBelowThresholdDoesNotCreate(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + claim := personFactProjectionClaim(personID, target, + `{"organization":{"name":"Weak Company","domain":"weak.example"},"title":"Engineer"}`, "weak") + claim.Confidence.ReportedScore = 0 + claim.Evidence[0].Directness = personfacts.Indirect + claim.Evidence[0].Authority = personfacts.AuthorityAggregator + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "weak", []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonBelowThreshold, result.Decisions[0].Reason) + assert.Empty(result.Projections) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "organizations")) +} + +func TestPersonFactOrganizationConcurrentZeroMatchRequeriesUnderLock(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, _, _ := newPersonFactProjectionStore(t) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + ref := personfacts.OrganizationReference{Name: "Concurrent Company", Domain: "concurrent.example"} + firstPrepared := make(chan struct{}) + releaseFirst := make(chan struct{}) + secondStarted := make(chan struct{}) + type organizationResult struct { + organization *Organization + status OrganizationResolutionStatus + err error + } + results := make(chan organizationResult, 2) + + go func() { + var result organizationResult + result.err = st.withTxContext(ctx, func(tx *loggedTx) error { + prepared, err := st.preparePersonFactOrganizationTx(ctx, tx, ref) + close(firstPrepared) + if err != nil { + return err + } + <-releaseFirst + result.organization, result.status, err = st.materializePersonFactOrganizationTx( + ctx, tx, ref, prepared) + return err + }) + results <- result + }() + <-firstPrepared + + go func() { + var result organizationResult + result.err = st.withTxContext(ctx, func(tx *loggedTx) error { + close(secondStarted) + prepared, err := st.preparePersonFactOrganizationTx(ctx, tx, ref) + if err != nil { + return err + } + result.organization, result.status, err = st.materializePersonFactOrganizationTx( + ctx, tx, ref, prepared) + return err + }) + results <- result + }() + <-secondStarted + close(releaseFirst) + + first := <-results + second := <-results + require.NoError(first.err) + require.NoError(second.err) + require.NotNil(first.organization) + require.NotNil(second.organization) + assert.Equal(first.organization.ID, second.organization.ID) + assert.ElementsMatch( + []OrganizationResolutionStatus{OrganizationCreated, OrganizationReused}, + []OrganizationResolutionStatus{first.status, second.status}) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "organizations")) +} + +func TestPersonFactEmploymentCorrectionRevisesUniqueRowAndPreservesHistory(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Correction Company", "correction.example") + firstJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"Correction Company","domain":"correction.example"},"title":"Engineer","role":"Platform"}`, organization.ID) + secondJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"Correction Company","domain":"correction.example"},"title":" engineer ","role":"Infrastructure","department":"Systems"}`, organization.ID) + + firstResult, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "correction-first", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, firstJSON, "correction-first"), + }, nil), nil) + require.NoError(err) + require.Len(firstResult.Projections, 1) + first, err := st.GetEmploymentContext(t.Context(), firstResult.Projections[0].RowID) + require.NoError(err) + require.True(first.IsPrimary) + + secondResult, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "correction-second", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, secondJSON, "correction-second"), + }, nil), nil) + require.NoError(err) + require.Len(secondResult.Projections, 1) + assert.Equal(first.ID, secondResult.Projections[0].RowID) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(employments, 1) + corrected := employments[0] + assert.Equal(first.ID, corrected.ID) + assert.Equal(first.Revision+1, corrected.Revision) + assert.True(corrected.IsPrimary) + assert.Equal(ProvenanceExtraction, corrected.Source) + require.NotNil(corrected.SourceRef) + assert.Contains(*corrected.SourceRef, "person-fact-decision:") + require.NotNil(corrected.Role) + assert.Equal("Infrastructure", *corrected.Role) + require.NotNil(corrected.Department) + assert.Equal("Systems", *corrected.Department) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + assert.Len(claims, 2) +} + +func TestPersonFactEmploymentSameGenerationCurrentClaimsCompeteByStableIdentity(t *testing.T) { + type decisionSummary struct { + ClaimKey string + Action personfacts.DecisionAction + Reason personfacts.DecisionReason + Score int + CompetingClaimKey string + Projected bool + } + type resultSummary struct { + GenerationKey string + Decisions []decisionSummary + Role string + Revision int64 + } + var summaries []resultSummary + for _, reverse := range []bool{false, true} { + name := "forward" + if reverse { + name = "reverse" + } + t.Run(name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Same Generation Company", "same-generation.example") + platformJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Same Generation Company","domain":"same-generation.example"},"title":"Engineer","role":"Platform"}`, + organization.ID) + infrastructureJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Same Generation Company","domain":"same-generation.example"},"title":" engineer ","role":"Infrastructure","department":"Systems"}`, + organization.ID) + platform := personFactProjectionClaim( + personID, target, platformJSON, "same-generation-platform") + platform.Confidence.ReportedScore = 1000 + infrastructure := personFactProjectionClaim( + personID, target, infrastructureJSON, "same-generation-infrastructure") + infrastructure.Confidence.ReportedScore = 0 + claims := []personfacts.ProposedClaim{platform, infrastructure} + if reverse { + slices.Reverse(claims) + } + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "same-generation-current", claims, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 2) + require.Len(result.Projections, 1) + applied, superseded := 0, 0 + summary := resultSummary{GenerationKey: result.GenerationKey} + for _, decision := range result.Decisions { + summary.Decisions = append(summary.Decisions, decisionSummary{ + ClaimKey: decision.ClaimKey, Action: decision.Action, Reason: decision.Reason, + Score: decision.Score.Total, CompetingClaimKey: decision.CompetingClaimKey, + Projected: decision.Projection != nil, + }) + switch decision.Action { + case personfacts.DecisionApplied: + applied++ + assert.NotNil(decision.Projection) + case personfacts.DecisionSuperseded: + superseded++ + assert.Equal(personfacts.ReasonAppliedProjection, decision.Reason) + assert.NotEmpty(decision.CompetingClaimKey) + assert.Nil(decision.Projection) + default: + assert.Fail("unexpected decision action", "action: %s", decision.Action) + } + } + assert.Equal(1, applied) + assert.Equal(1, superseded) + + employment, err := st.GetEmploymentContext(t.Context(), result.Projections[0].RowID) + require.NoError(err) + require.NotNil(employment.Role) + assert.Equal("Platform", *employment.Role) + assert.Equal(int64(1), employment.Revision) + summary.Role = *employment.Role + summary.Revision = employment.Revision + summaries = append(summaries, summary) + }) + } + require.Len(t, summaries, 2) + assert.Equal(t, summaries[0], summaries[1]) +} + +func TestPersonFactEmploymentSameGenerationCompetitionHonorsTieAndReplacementMargin(t *testing.T) { + type decisionSummary struct { + ClaimKey string + Action personfacts.DecisionAction + Reason personfacts.DecisionReason + Score int + CompetingClaimKey string + } + tests := []struct { + name string + platformConfidence int + infrastructureConfidence int + wantActions []personfacts.DecisionAction + }{ + { + name: "equal scores", platformConfidence: 900, infrastructureConfidence: 900, + wantActions: []personfacts.DecisionAction{ + personfacts.DecisionAmbiguousRetained, + personfacts.DecisionAmbiguousRetained, + }, + }, + { + name: "below replacement margin", platformConfidence: 1000, infrastructureConfidence: 700, + wantActions: []personfacts.DecisionAction{ + personfacts.DecisionRetained, + personfacts.DecisionConflictRejected, + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var orderSummaries [][]decisionSummary + for _, reverse := range []bool{false, true} { + orderName := "forward" + if reverse { + orderName = "reverse" + } + t.Run(orderName, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Policy Company", "policy-company.example") + platformJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Policy Company","domain":"policy-company.example"},"title":"Engineer","role":"Platform"}`, + organization.ID) + infrastructureJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Policy Company","domain":"policy-company.example"},"title":" engineer ","role":"Infrastructure"}`, + organization.ID) + platform := personFactProjectionClaim( + personID, target, platformJSON, "policy-platform") + platform.Confidence.ReportedScore = test.platformConfidence + infrastructure := personFactProjectionClaim( + personID, target, infrastructureJSON, "policy-infrastructure") + infrastructure.Confidence.ReportedScore = test.infrastructureConfidence + claims := []personfacts.ProposedClaim{platform, infrastructure} + if reverse { + slices.Reverse(claims) + } + input := personFactProjectionInput( + personID, "same-generation-policy-"+test.name, claims, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 2) + assert.Empty(result.Projections) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "employments")) + actions := make([]personfacts.DecisionAction, 0, len(result.Decisions)) + summary := make([]decisionSummary, 0, len(result.Decisions)) + for _, decision := range result.Decisions { + actions = append(actions, decision.Action) + assert.NotEmpty(decision.CompetingClaimKey) + assert.Nil(decision.Projection) + summary = append(summary, decisionSummary{ + ClaimKey: decision.ClaimKey, Action: decision.Action, + Reason: decision.Reason, Score: decision.Score.Total, + CompetingClaimKey: decision.CompetingClaimKey, + }) + } + assert.ElementsMatch(test.wantActions, actions) + if test.name == "equal scores" { + for _, decision := range result.Decisions { + assert.Equal(personfacts.ReasonCompetingTie, decision.Reason) + } + } else { + for _, decision := range result.Decisions { + assert.Equal(personfacts.ReasonInsufficientMargin, decision.Reason) + } + } + + input.ResolvedAt = input.ResolvedAt.Add(time.Hour) + replay, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(result.GenerationID, replay.GenerationID) + assert.Equal(result.Decisions, replay.Decisions) + assert.Empty(replay.Projections) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "employments")) + orderSummaries = append(orderSummaries, summary) + }) + } + require.Len(t, orderSummaries, 2) + assert.Equal(t, orderSummaries[0], orderSummaries[1]) + }) + } +} + +func TestPersonFactEmploymentSameGenerationCompetitionRewritesWholeDirections(t *testing.T) { + type decisionSummary struct { + ClaimKey string + Action personfacts.DecisionAction + Reason personfacts.DecisionReason + Score int + CompetingClaimKey string + Projected bool + } + tests := []struct { + name string + platformConfidence int + infrastructureConfidence int + wantActions []personfacts.DecisionAction + wantReason personfacts.DecisionReason + wantProjection bool + }{ + { + name: "tie", platformConfidence: 900, infrastructureConfidence: 900, + wantActions: []personfacts.DecisionAction{ + personfacts.DecisionAmbiguousRetained, + personfacts.DecisionAmbiguousRetained, + personfacts.DecisionAmbiguousRetained, + }, + wantReason: personfacts.ReasonCompetingTie, + }, + { + name: "insufficient margin", platformConfidence: 1000, infrastructureConfidence: 700, + wantActions: []personfacts.DecisionAction{ + personfacts.DecisionRetained, + personfacts.DecisionRetained, + personfacts.DecisionConflictRejected, + }, + wantReason: personfacts.ReasonInsufficientMargin, + }, + { + name: "duplicate direction clearly loses", platformConfidence: 0, infrastructureConfidence: 1000, + wantActions: []personfacts.DecisionAction{ + personfacts.DecisionSuperseded, + personfacts.DecisionSuperseded, + personfacts.DecisionApplied, + }, + wantReason: personfacts.ReasonAppliedProjection, + wantProjection: true, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var orderSummaries [][]decisionSummary + for _, reverse := range []bool{false, true} { + orderName := "forward" + if reverse { + orderName = "reverse" + } + t.Run(orderName, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Direction Company", "direction-company.example") + platformJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Direction Company","domain":"direction-company.example"},"title":"Engineer","role":"Platform"}`, + organization.ID) + infrastructureJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Direction Company","domain":"direction-company.example"},"title":" engineer ","role":"Infrastructure"}`, + organization.ID) + platformA := personFactProjectionClaim( + personID, target, platformJSON, "direction-platform-a") + platformA.Confidence.ReportedScore = test.platformConfidence + platformB := personFactProjectionClaim( + personID, target, platformJSON, "direction-platform-b") + platformB.Confidence.ReportedScore = test.platformConfidence + // Keep the evidence independently addressable while ensuring the + // duplicate direction does not gain a corroboration bonus. + platformB.Evidence[0].SourceURL = platformA.Evidence[0].SourceURL + infrastructure := personFactProjectionClaim( + personID, target, infrastructureJSON, "direction-infrastructure") + infrastructure.Confidence.ReportedScore = test.infrastructureConfidence + claims := []personfacts.ProposedClaim{platformA, platformB, infrastructure} + if reverse { + slices.Reverse(claims) + } + input := personFactProjectionInput( + personID, "same-generation-directions-"+test.name, claims, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 3) + actions := make([]personfacts.DecisionAction, 0, len(result.Decisions)) + summary := make([]decisionSummary, 0, len(result.Decisions)) + winner := "" + for _, decision := range result.Decisions { + actions = append(actions, decision.Action) + assert.Equal(test.wantReason, decision.Reason) + summary = append(summary, decisionSummary{ + ClaimKey: decision.ClaimKey, Action: decision.Action, + Reason: decision.Reason, Score: decision.Score.Total, + CompetingClaimKey: decision.CompetingClaimKey, + Projected: decision.Projection != nil, + }) + if decision.Action == personfacts.DecisionApplied { + winner = decision.ClaimKey + assert.NotNil(decision.Projection) + } + } + assert.ElementsMatch(test.wantActions, actions) + + if test.wantProjection { + require.Len(result.Projections, 1) + assert.NotEmpty(winner) + for _, decision := range result.Decisions { + if decision.Action == personfacts.DecisionSuperseded { + assert.Equal(winner, decision.CompetingClaimKey) + assert.Nil(decision.Projection) + } + } + employment, err := st.GetEmploymentContext( + t.Context(), result.Projections[0].RowID) + require.NoError(err) + require.NotNil(employment.Role) + assert.Equal("Infrastructure", *employment.Role) + assert.Equal(int64(1), employment.Revision) + } else { + assert.Empty(result.Projections) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "employments")) + for _, decision := range result.Decisions { + assert.NotEmpty(decision.CompetingClaimKey) + assert.Nil(decision.Projection) + } + } + + input.ResolvedAt = input.ResolvedAt.Add(time.Hour) + replay, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(result.GenerationID, replay.GenerationID) + assert.Equal(result.Decisions, replay.Decisions) + assert.Equal(result.Projections, replay.Projections) + orderSummaries = append(orderSummaries, summary) + }) + } + require.Len(t, orderSummaries, 2) + assert.Equal(t, orderSummaries[0], orderSummaries[1]) + }) + } +} + +func TestPersonFactEmploymentStrongDirectionSupersedesBelowThresholdDirection(t *testing.T) { + type decisionSummary struct { + ClaimKey string + Action personfacts.DecisionAction + Reason personfacts.DecisionReason + CompetingClaimKey string + Projected bool + } + var orderSummaries [][]decisionSummary + for _, reverse := range []bool{false, true} { + orderName := "forward" + if reverse { + orderName = "reverse" + } + t.Run(orderName, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Threshold Company", "threshold-company.example") + platformJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Threshold Company","domain":"threshold-company.example"},"title":"Engineer","role":"Platform"}`, + organization.ID) + infrastructureJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Threshold Company","domain":"threshold-company.example"},"title":" engineer ","role":"Infrastructure"}`, + organization.ID) + strong := personFactProjectionClaim( + personID, target, platformJSON, "threshold-strong") + strong.Confidence.ReportedScore = 1000 + weakA := personFactProjectionClaim( + personID, target, infrastructureJSON, "threshold-weak-a") + weakB := personFactProjectionClaim( + personID, target, infrastructureJSON, "threshold-weak-b") + for _, weak := range []*personfacts.ProposedClaim{&weakA, &weakB} { + weak.Confidence.ReportedScore = 0 + weak.Evidence[0].SourceClass = personfacts.EvidenceProviderAssertion + weak.Evidence[0].Directness = personfacts.Indirect + weak.Evidence[0].Authority = personfacts.AuthorityAggregator + weak.Evidence[0].SourceRef = "threshold-provider" + weak.Evidence[0].EventTime = personFactLedgerNow.Add(-3 * 365 * 24 * time.Hour) + } + weakB.Evidence[0].SourceURL = weakA.Evidence[0].SourceURL + weakB.Evidence[0].EventTime = weakA.Evidence[0].EventTime.Add(-time.Hour) + claims := []personfacts.ProposedClaim{strong, weakA, weakB} + if reverse { + slices.Reverse(claims) + } + input := personFactProjectionInput( + personID, "same-generation-threshold-directions", claims, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 3) + require.Len(result.Projections, 1) + winner := "" + for _, decision := range result.Decisions { + if decision.Action == personfacts.DecisionApplied { + winner = decision.ClaimKey + } + } + assert.NotEmpty(winner) + actions := make([]personfacts.DecisionAction, 0, len(result.Decisions)) + summary := make([]decisionSummary, 0, len(result.Decisions)) + for _, decision := range result.Decisions { + actions = append(actions, decision.Action) + assert.Equal(personfacts.ReasonAppliedProjection, decision.Reason) + if decision.Action == personfacts.DecisionApplied { + assert.Empty(decision.CompetingClaimKey) + assert.NotNil(decision.Projection) + } else { + assert.Equal(winner, decision.CompetingClaimKey) + assert.Nil(decision.Projection) + } + summary = append(summary, decisionSummary{ + ClaimKey: decision.ClaimKey, Action: decision.Action, Reason: decision.Reason, + CompetingClaimKey: decision.CompetingClaimKey, + Projected: decision.Projection != nil, + }) + } + assert.ElementsMatch([]personfacts.DecisionAction{ + personfacts.DecisionApplied, + personfacts.DecisionSuperseded, + personfacts.DecisionSuperseded, + }, actions) + employment, err := st.GetEmploymentContext(t.Context(), result.Projections[0].RowID) + require.NoError(err) + require.NotNil(employment.Role) + assert.Equal("Platform", *employment.Role) + assert.Equal(int64(1), employment.Revision) + + input.ResolvedAt = input.ResolvedAt.Add(time.Hour) + replay, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(result.GenerationID, replay.GenerationID) + assert.Equal(result.Decisions, replay.Decisions) + assert.Equal(result.Projections, replay.Projections) + orderSummaries = append(orderSummaries, summary) + }) + } + require.Len(t, orderSummaries, 2) + assert.Equal(t, orderSummaries[0], orderSummaries[1]) +} + +func TestPersonFactEmploymentEndedSupportIsNotReinsertedByUnrelatedResolution(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + historicalOrganization := createPersonFactOrganization( + t, st, "Historical Company", "historical.example") + unrelatedOrganization := createPersonFactOrganization( + t, st, "Unrelated Company", "unrelated.example") + historicalJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Historical Company","domain":"historical.example"},"title":"Engineer","end_date":{"year":2024,"month":6}}`, + historicalOrganization.ID) + unrelatedJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Unrelated Company","domain":"unrelated.example"},"title":"Advisor"}`, + unrelatedOrganization.ID) + + firstInput := personFactProjectionInput(personID, "historical", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, historicalJSON, "historical"), + }, nil) + firstInput.ResolvedAt = personFactLedgerNow + first, err := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + require.NoError(err) + require.Len(first.Projections, 1) + historicalID := first.Projections[0].RowID + + secondInput := personFactProjectionInput(personID, "unrelated", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, unrelatedJSON, "unrelated"), + }, nil) + secondInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + _, err = st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + require.NoError(err) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Len(employments, 2) + historicalCount := 0 + for _, employment := range employments { + if employment.OrganizationID == historicalOrganization.ID { + historicalCount++ + assert.Equal(historicalID, employment.ID) + assert.False(employment.IsCurrent) + } + } + assert.Equal(1, historicalCount) +} + +func TestPersonFactEmploymentRepeatedEndedSupportUsesStableEpisodeIdentity(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Ended Company", "ended.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Ended Company","domain":"ended.example"},"title":"Engineer","role":"Platform","end_date":{"year":2024,"month":6}}`, + organization.ID) + + firstInput := personFactProjectionInput(personID, "ended-first", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "ended-first"), + }, nil) + firstInput.ResolvedAt = personFactLedgerNow + first, err := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + require.NoError(err) + require.Len(first.Projections, 1) + firstEmployment, err := st.GetEmploymentContext(t.Context(), first.Projections[0].RowID) + require.NoError(err) + + secondInput := personFactProjectionInput(personID, "ended-second", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "ended-second"), + }, nil) + secondInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + require.NoError(err) + assert.Empty(second.Projections) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + require.Len(employments, 1) + assert.Equal(firstEmployment.ID, employments[0].ID) + assert.Equal(firstEmployment.Revision, employments[0].Revision) +} + +func TestPersonFactEmploymentIncrementalNonOverlappingStintCreatesNewEpisode(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Repeat Company", "repeat.example") + value := func(startYear, endYear int) string { + return fmt.Sprintf( + `{"organization":{"id":%d,"name":"Repeat Company","domain":"repeat.example"},"title":"Engineer","start_date":{"year":%d},"end_date":{"year":%d}}`, + organization.ID, startYear, endYear) + } + + first, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "repeat-first", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, value(2018, 2020), "repeat-first"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(first.Projections, 1) + + secondInput := personFactProjectionInput(personID, "repeat-second", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, value(2022, 2024), "repeat-second"), + }, nil) + secondInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + requirements.NoError(err) + requirements.Len(second.Projections, 1) + assertions.NotEqual(first.Projections[0].RowID, second.Projections[0].RowID) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + requirements.NoError(err) + requirements.Len(employments, 2) + assertions.ElementsMatch([]int{2018, 2022}, []int{ + *employments[0].StartDate.Year, *employments[1].StartDate.Year, + }) +} + +func TestPersonFactEmploymentExplicitUnpinRepairsMissingOrEditedHistoricalProjection(t *testing.T) { + for _, mode := range []string{"deleted", "edited"} { + t.Run(mode, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Repair "+mode, "repair-"+mode+".example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Repair %s","domain":"repair-%s.example"},"title":"Engineer","role":"Platform","end_date":{"year":2024,"month":6}}`, + organization.ID, mode, mode) + input := personFactProjectionInput(personID, "repair-"+mode, []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "repair-"+mode), + }, nil) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Projections, 1) + original, err := st.GetEmploymentContext(t.Context(), result.Projections[0].RowID) + require.NoError(err) + renamedDomain := "renamed-repair-" + mode + ".example" + organization, err = st.ReplaceOrganizationContext( + t.Context(), organization.ID, organization.Revision, OrganizationInput{ + Name: "Renamed Repair " + mode, Kind: OrganizationKindCompany, + PrimaryDomain: &renamedDomain, + }, false) + require.NoError(err) + + if mode == "deleted" { + require.NoError(st.DeleteEmploymentContext(t.Context(), original.ID, original.Revision)) + } else { + _, err = st.UpdateEmploymentContext(t.Context(), original.ID, original.Revision, EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: original.Title, Role: new("Manually edited"), EndDate: original.EndDate, + Source: ProvenanceUser, + }) + require.NoError(err) + } + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + false, "repair-test") + require.NoError(err) + require.NotEmpty(unpinned.Projections) + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + require.Len(employments, 1) + if mode == "deleted" { + assert.NotEqual(original.ID, employments[0].ID) + } else { + assert.Equal(original.ID, employments[0].ID) + } + require.NotNil(employments[0].Role) + assert.Equal("Platform", *employments[0].Role) + assert.Equal(personID, employments[0].PersonID) + assert.Equal(organization.ID, employments[0].OrganizationID) + assert.Equal(ProvenanceExtraction, employments[0].Source) + require.NotNil(employments[0].Confidence) + assert.InDelta(0.9, *employments[0].Confidence, 0.0001) + assert.False(employments[0].IsCurrent) + }) + } +} + +func TestPersonFactEmploymentLaterEndedCorrectionRevisesStableEpisode(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Ended Correction", "ended-correction.example") + firstJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Ended Correction","domain":"ended-correction.example"},"title":"Engineer","role":"Platform","start_date":{"year":2020},"end_date":{"year":2024,"month":6}}`, + organization.ID) + correctedJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Ended Correction","domain":"ended-correction.example"},"title":" engineer ","role":"Infrastructure","department":"Systems","start_date":{"year":2020},"end_date":{"year":2024,"month":7}}`, + organization.ID) + + firstInput := personFactProjectionInput(personID, "ended-correction-first", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, firstJSON, "ended-correction-first"), + }, nil) + firstInput.ResolvedAt = personFactLedgerNow + first, err := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + require.NoError(err) + require.Len(first.Projections, 1) + before, err := st.GetEmploymentContext(t.Context(), first.Projections[0].RowID) + require.NoError(err) + + secondInput := personFactProjectionInput(personID, "ended-correction-second", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, correctedJSON, "ended-correction-second"), + }, nil) + secondInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + require.NoError(err) + require.Len(second.Projections, 1) + assert.Equal(before.ID, second.Projections[0].RowID) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + require.Len(employments, 1) + corrected := employments[0] + assert.Equal(before.ID, corrected.ID) + assert.Equal(before.Revision+1, corrected.Revision) + require.NotNil(corrected.Role) + assert.Equal("Infrastructure", *corrected.Role) + require.NotNil(corrected.Department) + assert.Equal("Systems", *corrected.Department) + require.NotNil(corrected.EndDate) + require.NotNil(corrected.EndDate.Month) + assert.Equal(7, *corrected.EndDate.Month) +} + +func TestPersonFactEmploymentHistoricalCorrectionsPreserveDistinctEpisodesAndCurrentStint(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Episode Company", "episodes.example") + employmentJSON := func(startYear, endYear int, role string) string { + return fmt.Sprintf( + `{"organization":{"id":%d,"name":"Episode Company","domain":"episodes.example"},"title":"Engineer","role":%q,"start_date":{"year":%d},"end_date":{"year":%d}}`, + organization.ID, role, startYear, endYear) + } + + seedInput := personFactProjectionInput(personID, "episode-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, employmentJSON(2018, 2020, "First"), "episode-first"), + personFactProjectionClaim(personID, target, employmentJSON(2021, 2023, "Second"), "episode-second"), + }, nil) + seedInput.ResolvedAt = personFactLedgerNow + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), seedInput, nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 2) + historicalIDs := []int64{seed.Projections[0].RowID, seed.Projections[1].RowID} + + currentJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Episode Company","domain":"episodes.example"},"title":"Engineer","role":"Current","start_date":{"year":2025}}`, + organization.ID) + currentInput := personFactProjectionInput(personID, "episode-current", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, currentJSON, "episode-current"), + }, nil) + currentInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + currentResult, err := st.ApplyPersonFactGenerationContext(t.Context(), currentInput, nil) + requirements.NoError(err) + requirements.Len(currentResult.Projections, 1) + currentBefore, err := st.GetEmploymentContext(t.Context(), currentResult.Projections[0].RowID) + requirements.NoError(err) + + correctionInput := personFactProjectionInput(personID, "episode-corrections", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, employmentJSON(2018, 2019, "First corrected"), "episode-first-correction"), + personFactProjectionClaim(personID, target, employmentJSON(2021, 2024, "Second corrected"), "episode-second-correction"), + }, nil) + correctionInput.ResolvedAt = personFactLedgerNow.Add(2 * time.Hour) + corrected, err := st.ApplyPersonFactGenerationContext(t.Context(), correctionInput, nil) + requirements.NoError(err) + requirements.Len(corrected.Projections, 2) + assertions.ElementsMatch(historicalIDs, + []int64{corrected.Projections[0].RowID, corrected.Projections[1].RowID}) + + currentAfter, err := st.GetEmploymentContext(t.Context(), currentBefore.ID) + requirements.NoError(err) + assertions.Equal(currentBefore.Revision, currentAfter.Revision) + assertions.True(currentAfter.IsCurrent) + requirements.NotNil(currentAfter.Role) + assertions.Equal("Current", *currentAfter.Role) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + requirements.NoError(err) + requirements.Len(employments, 3) + rolesByStart := make(map[int]string) + for _, employment := range employments { + if employment.StartDate == nil || employment.StartDate.Year == nil || employment.Role == nil { + continue + } + rolesByStart[*employment.StartDate.Year] = *employment.Role + } + assertions.Equal("First corrected", rolesByStart[2018]) + assertions.Equal("Second corrected", rolesByStart[2021]) + assertions.Equal("Current", rolesByStart[2025]) +} + +func TestPersonFactEmploymentLaterCurrentSupportCreatesNewEpisodeAfterEndedSupport(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Return Company", "return.example") + historicalJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Return Company","domain":"return.example"},"title":"Engineer","role":"Platform","end_date":{"year":2024,"month":6}}`, + organization.ID) + currentJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Return Company","domain":"return.example"},"title":" engineer ","role":"Platform"}`, + organization.ID) + + firstInput := personFactProjectionInput(personID, "return-historical", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, historicalJSON, "return-historical"), + }, nil) + firstInput.ResolvedAt = personFactLedgerNow + first, err := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + require.NoError(err) + require.Len(first.Projections, 1) + historical, err := st.GetEmploymentContext(t.Context(), first.Projections[0].RowID) + require.NoError(err) + assert.False(historical.IsCurrent) + + secondInput := personFactProjectionInput(personID, "return-current", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, currentJSON, "return-current"), + }, nil) + secondInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + require.NoError(err) + require.Len(second.Projections, 1) + assert.NotEqual(historical.ID, second.Projections[0].RowID) + + unchanged, err := st.GetEmploymentContext(t.Context(), historical.ID) + require.NoError(err) + assert.Equal(historical.Revision, unchanged.Revision) + assert.False(unchanged.IsCurrent) + require.NotNil(unchanged.EndDate) + + current, err := st.GetEmploymentContext(t.Context(), second.Projections[0].RowID) + require.NoError(err) + assert.True(current.IsCurrent) + assert.Nil(current.EndDate) + assert.Equal(organization.ID, current.OrganizationID) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Len(employments, 2) +} + +func TestPersonFactEmploymentOlderCorrectionCannotOverwriteNewerOnLaterResolution(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Chronology Company", "chronology.example") + unrelated := createPersonFactOrganization(t, st, "Later Company", "later.example") + olderJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Chronology Company","domain":"chronology.example"},"title":"Engineer","role":"Platform"}`, + organization.ID) + newerJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Chronology Company","domain":"chronology.example"},"title":" engineer ","role":"Infrastructure","department":"Systems"}`, + organization.ID) + unrelatedJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Later Company","domain":"later.example"},"title":"Advisor"}`, + unrelated.ID) + + olderInput := personFactProjectionInput(personID, "chronology-older", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, olderJSON, "chronology-older"), + }, nil) + olderInput.ResolvedAt = personFactLedgerNow + _, err := st.ApplyPersonFactGenerationContext(t.Context(), olderInput, nil) + require.NoError(err) + + newerInput := personFactProjectionInput(personID, "chronology-newer", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, newerJSON, "chronology-newer"), + }, nil) + newerInput.ResolvedAt = personFactLedgerNow.Add(time.Hour) + newerResult, err := st.ApplyPersonFactGenerationContext(t.Context(), newerInput, nil) + require.NoError(err) + require.Len(newerResult.Projections, 1) + corrected, err := st.GetEmploymentContext(t.Context(), newerResult.Projections[0].RowID) + require.NoError(err) + correctedRevision := corrected.Revision + + unrelatedInput := personFactProjectionInput(personID, "chronology-unrelated", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, unrelatedJSON, "chronology-unrelated"), + }, nil) + unrelatedInput.ResolvedAt = personFactLedgerNow.Add(2 * time.Hour) + unrelatedResult, err := st.ApplyPersonFactGenerationContext(t.Context(), unrelatedInput, nil) + require.NoError(err) + require.Len(unrelatedResult.Projections, 1) + + after, err := st.GetEmploymentContext(t.Context(), corrected.ID) + require.NoError(err) + assert.Equal(correctedRevision, after.Revision) + require.NotNil(after.Role) + assert.Equal("Infrastructure", *after.Role) + require.NotNil(after.Department) + assert.Equal("Systems", *after.Department) +} + +func TestPersonFactEmploymentLaterSupportRehiresAfterRetirement(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Rehire Company", "rehire.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Rehire Company","domain":"rehire.example"},"title":"Engineer"}`, + organization.ID) + + seedInput := personFactProjectionInput(personID, "rehire-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "rehire-seed"), + }, nil) + seedInput.ResolvedAt = personFactLedgerNow + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), seedInput, nil) + require.NoError(err) + require.Len(seed.Projections, 1) + originalID := seed.Projections[0].RowID + + retiredAt := personFactLedgerNow.Add(time.Hour) + retirement := personFactProjectionClaim(personID, target, submitted, "rehire-retire") + retirement.Relation = personfacts.RelationSupersede + retirement.ValidFrom = &retiredAt + retireInput := personFactProjectionInput( + personID, "rehire-retire", []personfacts.ProposedClaim{retirement}, nil) + retireInput.ResolvedAt = retiredAt + retired, err := st.ApplyPersonFactGenerationContext(t.Context(), retireInput, nil) + require.NoError(err) + require.Len(retired.Projections, 1) + assert.Equal(originalID, retired.Projections[0].RowID) + + rehireAt := personFactLedgerNow.Add(2 * time.Hour) + rehire := personFactProjectionClaim(personID, target, submitted, "rehire-support") + rehire.ValidFrom = &rehireAt + rehireInput := personFactProjectionInput( + personID, "rehire-support", []personfacts.ProposedClaim{rehire}, nil) + rehireInput.ResolvedAt = rehireAt + rehired, err := st.ApplyPersonFactGenerationContext(t.Context(), rehireInput, nil) + require.NoError(err) + require.Len(rehired.Projections, 1) + assert.NotEqual(originalID, rehired.Projections[0].RowID) + + current, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(current, 1) + assert.Equal(rehired.Projections[0].RowID, current[0].ID) + assert.Equal(organization.ID, current[0].OrganizationID) +} + +func TestPersonFactEmploymentNewJobRetainsUnrelatedCurrentJob(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + firstOrganization := createPersonFactOrganization(t, st, "First Company", "first-job.example") + secondOrganization := createPersonFactOrganization(t, st, "Second Company", "second-job.example") + firstJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"First Company","domain":"first-job.example"},"title":"Engineer"}`, firstOrganization.ID) + secondJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"Second Company","domain":"second-job.example"},"title":"Advisor"}`, secondOrganization.ID) + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "first-job", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, firstJSON, "first-job"), + }, nil), nil) + require.NoError(err) + _, err = st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "second-job", []personfacts.ProposedClaim{ + func() personfacts.ProposedClaim { + claim := personFactProjectionClaim(personID, target, secondJSON, "second-job") + claim.Origin = personfacts.OriginEnrichment + return claim + }(), + }, nil), nil) + require.NoError(err) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(employments, 2) + organizationIDs := []int64{employments[0].OrganizationID, employments[1].OrganizationID} + slices.Sort(organizationIDs) + assert.Equal([]int64{firstOrganization.ID, secondOrganization.ID}, organizationIDs) + primaryCount := 0 + for _, employment := range employments { + if employment.IsPrimary { + primaryCount++ + } + if employment.OrganizationID == secondOrganization.ID { + assert.Equal(ProvenanceEnrichment, employment.Source) + require.NotNil(employment.SourceRef) + assert.Contains(*employment.SourceRef, "person-fact-decision:") + } + } + assert.Equal(1, primaryCount) +} + +func TestPersonFactEmploymentSupersessionEndsOnlyExactRowAtEffectiveDate(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + firstOrganization := createPersonFactOrganization(t, st, "Ending Company", "ending.example") + secondOrganization := createPersonFactOrganization(t, st, "Retained Company", "retained.example") + endingJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"Ending Company","domain":"ending.example"},"title":"Engineer","start_date":{"year":2020,"month":2}}`, firstOrganization.ID) + retainedJSON := fmt.Sprintf(`{"organization":{"id":%d,"name":"Retained Company","domain":"retained.example"},"title":"Advisor"}`, secondOrganization.ID) + seed := []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, endingJSON, "ending-seed"), + personFactProjectionClaim(personID, target, retainedJSON, "retained-seed"), + } + _, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "employment-seed", seed, nil), nil) + require.NoError(err) + current, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(current, 2) + var endingID, retainedID int64 + for _, employment := range current { + switch employment.OrganizationID { + case firstOrganization.ID: + endingID = employment.ID + case secondOrganization.ID: + retainedID = employment.ID + } + } + require.NotZero(endingID) + require.NotZero(retainedID) + + effectiveAt := time.Date(2026, time.January, 15, 0, 0, 0, 0, time.UTC) + supersession := personFactProjectionClaim(personID, target, endingJSON, "ending-supersession") + supersession.Relation = personfacts.RelationSupersede + supersession.ValidFrom = &effectiveAt + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "employment-supersession", []personfacts.ProposedClaim{supersession}, nil), nil) + require.NoError(err) + require.Len(result.Projections, 1) + assert.Equal(personfacts.ProjectionRef{Kind: "employment", RowID: endingID}, result.Projections[0]) + + ended, err := st.GetEmploymentContext(t.Context(), endingID) + require.NoError(err) + assert.False(ended.IsCurrent) + assert.Equal(ProvenanceExtraction, ended.Source) + require.NotNil(ended.SourceRef) + assert.Contains(*ended.SourceRef, "person-fact-decision:") + require.NotNil(ended.EndDate) + assert.Equal(PartialDate{Year: new(2026), Month: new(1), Day: new(15)}, *ended.EndDate) + retained, err := st.GetEmploymentContext(t.Context(), retainedID) + require.NoError(err) + assert.True(retained.IsCurrent) +} + +func TestPersonFactEmploymentSupersessionClampsEndDateToEmploymentStart(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Backdated Company", "backdated-employment.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Backdated Company","domain":"backdated-employment.example"},"title":"Engineer","start_date":{"year":2020,"month":2}}`, + organization.ID) + + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "backdated-employment-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "backdated-employment-seed"), + }, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + + backdated := time.Date(2019, time.January, 15, 0, 0, 0, 0, time.UTC) + supersession := personFactProjectionClaim( + personID, target, submitted, "backdated-employment-supersession") + supersession.Relation = personfacts.RelationSupersede + supersession.ValidFrom = &backdated + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "backdated-employment-supersession", + []personfacts.ProposedClaim{supersession}, nil), nil) + require.NoError(err) + require.Len(result.Projections, 1) + assert.Equal(seed.Projections[0], result.Projections[0]) + + ended, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + require.NoError(err) + assert.False(ended.IsCurrent) + require.NotNil(ended.EndDate) + assert.Equal(PartialDate{Year: new(2020), Month: new(2)}, *ended.EndDate) +} + +func TestPersonFactEmploymentNegativeClaimRetractsOwnedHistoricalProjection(t *testing.T) { + for _, relation := range []personfacts.ClaimRelation{ + personfacts.RelationContradict, + personfacts.RelationSupersede, + } { + t.Run(string(relation), func(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Retracted Company", "retracted.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Retracted Company","domain":"retracted.example"},"title":"Engineer","start_date":{"year":2020},"end_date":{"year":2022}}`, + organization.ID) + + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "historical-negative-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "historical-negative-seed"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 1) + + negative := personFactProjectionClaim(personID, target, submitted, "historical-negative") + negative.Relation = relation + input := personFactProjectionInput( + personID, "historical-negative", []personfacts.ProposedClaim{negative}, nil) + input.ResolvedAt = personFactLedgerNow.Add(time.Hour) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + requirements.NoError(err) + requirements.Len(result.Projections, 1) + assertions.Equal(seed.Projections[0], result.Projections[0]) + + _, err = st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + requirements.ErrorIs(err, ErrEmploymentNotFound) + employments, err := st.ListEmploymentsContext( + t.Context(), EmploymentFilter{PersonID: personID}) + requirements.NoError(err) + assertions.Empty(employments) + }) + } +} + +func TestPersonFactEmploymentMultipleProjectionsBumpVCardOnce(t *testing.T) { + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + first := createPersonFactOrganization(t, st, "Bump One", "bump-one.example") + second := createPersonFactOrganization(t, st, "Bump Two", "bump-two.example") + before := personFactProjectionRevision(t, st, personID) + claims := []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, + fmt.Sprintf(`{"organization":{"id":%d,"name":"Bump One","domain":"bump-one.example"},"title":"Engineer"}`, first.ID), "bump-one"), + personFactProjectionClaim(personID, target, + fmt.Sprintf(`{"organization":{"id":%d,"name":"Bump Two","domain":"bump-two.example"},"title":"Advisor"}`, second.ID), "bump-two"), + } + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "employment-bump", claims, nil), nil) + require.NoError(t, err) + assert.Len(t, result.Projections, 2) + assert.Equal(t, before+1, personFactProjectionRevision(t, st, personID)) +} + +func TestPersonFactEmploymentExpiresOwnedProjectionAtExclusiveValidityEnd(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + expiringOrganization := createPersonFactOrganization( + t, st, "Expiring Company", "expiring-employment.example") + retainedOrganization := createPersonFactOrganization( + t, st, "Retained Company", "retained-employment.example") + expiringJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Expiring Company","domain":"expiring-employment.example"},"title":"Engineer","start_date":{"year":2020,"month":2}}`, + expiringOrganization.ID) + expiredCorrectionJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Expiring Company","domain":"expiring-employment.example"},"title":"Engineer","role":"Platform","start_date":{"year":2020,"month":2}}`, + expiringOrganization.ID) + retainedJSON := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Retained Company","domain":"retained-employment.example"},"title":"Advisor"}`, + retainedOrganization.ID) + validUntil := personFactLedgerNow.Add(48 * time.Hour) + expiring := personFactProjectionClaim( + personID, target, expiringJSON, "employment-expiry-seed") + expiring.ValidUntil = &validUntil + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), personFactProjectionInput( + personID, "employment-expiry-seed", []personfacts.ProposedClaim{ + expiring, + personFactProjectionClaim(personID, target, retainedJSON, "employment-expiry-retained"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 2) + + boundary := personFactProjectionClaim( + personID, target, expiredCorrectionJSON, "employment-expiry-boundary") + boundary.ValidUntil = &validUntil + boundaryInput := personFactProjectionInput( + personID, "employment-expiry-boundary", []personfacts.ProposedClaim{boundary}, nil) + boundaryInput.ResolvedAt = validUntil + result, err := st.ApplyPersonFactGenerationContext(t.Context(), boundaryInput, nil) + requirements.NoError(err) + foundExpired := false + for _, decision := range result.Decisions { + if decision.Reason == personfacts.ReasonOutsideValidity { + foundExpired = true + } + } + assertions.True(foundExpired) + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), boundaryInput, nil) + requirements.NoError(err) + assertions.Equal(result, replayed) + + employments, err := st.ListEmploymentsContext( + t.Context(), EmploymentFilter{PersonID: personID}) + requirements.NoError(err) + requirements.Len(employments, 2) + for _, employment := range employments { + switch employment.OrganizationID { + case expiringOrganization.ID: + assertions.False(employment.IsCurrent) + requirements.NotNil(employment.EndDate) + assertions.Equal( + PartialDate{Year: new(2026), Month: new(8), Day: new(24)}, *employment.EndDate) + case retainedOrganization.ID: + assertions.True(employment.IsCurrent) + assertions.Nil(employment.EndDate) + default: + assertions.Fail("unexpected employment", "organization ID %d", employment.OrganizationID) + } + } +} + +func TestPersonFactEmploymentUnsupportedEvidenceDoesNotRetire(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Unsupported Company", "unsupported.example") + submitted := fmt.Sprintf(`{"organization":{"id":%d,"name":"Unsupported Company","domain":"unsupported.example"},"title":"Engineer"}`, organization.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unsupported-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "unsupported-seed"), + }, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + require.Len(evidence, 1) + beforeRevision := personFactProjectionRevision(t, st, personID) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unsupported-status", nil, []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidence[0].Key, SourceVersion: evidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}), nil) + require.NoError(err) + assert.Empty(result.Projections) + assert.Equal(beforeRevision, personFactProjectionRevision(t, st, personID)) + employment, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + require.NoError(err) + assert.True(employment.IsCurrent) + assert.Nil(employment.EndDate) +} + +func TestPersonFactEmploymentReplayIsByteIdentical(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + submitted := `{"organization":{"name":"Replay Company","domain":"replay.example"},"title":"Engineer"}` + input := personFactProjectionInput(personID, "employment-replay", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "employment-replay"), + }, nil) + + fresh, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(fresh, replayed) + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + require.NoError(err) + assert.Len(employments, 1) +} + +func TestPersonFactEmploymentAutomaticLockPrecedesCurrentStateSQLite(t *testing.T) { + if IsPostgresURL(os.Getenv("MSGVAULT_TEST_DB")) { + t.Skip("SQLite production-path lock-order observation") + } + assert := assert.New(t) + require := require.New(t) + gate := newPersonFactEmploymentLockOrderGate(personFactEmploymentStagePerson) + defer gate.release() + st := newPersonFactEmploymentSQLiteGateStore(t, gate) + personID, target, organization := newPersonFactEmploymentLockOrderFixture(t, st, "sqlite") + + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + declaredCtx := context.WithValue(ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorDeclared) + automaticCtx := context.WithValue(ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorAutomatic) + type declaredResult struct { + employment *Employment + err error + } + declaredDone := make(chan declaredResult, 1) + go func() { + employment, err := st.AddEmploymentContext(declaredCtx, EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Declared Advisor"), Source: ProvenanceUser, IsPrimary: new(false), + }) + declaredDone <- declaredResult{employment: employment, err: err} + }() + waitPersonFactEmploymentLockSignal(t, gate.declaredPaused, "declared mutation did not reach its person lock") + + automaticDone := make(chan error, 1) + go func() { + submitted := fmt.Sprintf(`{"organization":{"id":%d,"name":"Lock Order Company","domain":"lock-order.example"},"title":"Automatic Engineer"}`, organization.ID) + _, err := st.ApplyPersonFactGenerationContext(automaticCtx, + personFactProjectionInput(personID, "lock-order-sqlite", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "lock-order-sqlite"), + }, nil), nil) + automaticDone <- err + }() + firstAutomatic := waitPersonFactEmploymentLockStage(t, gate.automaticFirst) + require.NoError(waitPersonFactEmploymentLockResult(t, automaticDone, "automatic projection")) + gate.release() + declared := waitPersonFactEmploymentLockResult(t, declaredDone, "declared mutation") + require.NoError(declared.err) + require.NotNil(declared.employment) + assert.Equal(personFactEmploymentStagePerson, firstAutomatic, + "automatic projection must lock the person before reading current employment or organizations") + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + assert.Len(employments, 2) +} + +func TestPersonFactEmploymentAutomaticAndDeclaredMutationSerializePostgres(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + if !IsPostgresURL(dbURL) { + t.Skip("PostgreSQL person/organization lock interleaving requires MSGVAULT_TEST_DB") + } + assert := assert.New(t) + require := require.New(t) + gate := newPersonFactEmploymentLockOrderGate(personFactEmploymentStageOrganization) + defer gate.release() + st := newPersonFactEmploymentPostgresGateStore(t, dbURL, gate) + personID, target, organization := newPersonFactEmploymentLockOrderFixture(t, st, "postgres") + + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + declaredCtx := context.WithValue(ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorDeclared) + automaticCtx := context.WithValue(ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorAutomatic) + type declaredResult struct { + employment *Employment + err error + } + declaredDone := make(chan declaredResult, 1) + go func() { + employment, err := st.AddEmploymentContext(declaredCtx, EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Declared Advisor"), Source: ProvenanceUser, IsPrimary: new(false), + }) + declaredDone <- declaredResult{employment: employment, err: err} + }() + waitPersonFactEmploymentLockSignal(t, gate.declaredPaused, + "declared mutation did not hold the person lock before organization validation") + + automaticDone := make(chan struct { + result *personfacts.GenerationResult + err error + }, 1) + go func() { + submitted := fmt.Sprintf(`{"organization":{"id":%d,"name":"Lock Order Company","domain":"lock-order.example"},"title":"Automatic Engineer"}`, organization.ID) + result, err := st.ApplyPersonFactGenerationContext(automaticCtx, + personFactProjectionInput(personID, "lock-order-postgres", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "lock-order-postgres"), + }, nil), nil) + automaticDone <- struct { + result *personfacts.GenerationResult + err error + }{result: result, err: err} + }() + firstAutomatic := waitPersonFactEmploymentLockStage(t, gate.automaticFirst) + gate.release() + declared := waitPersonFactEmploymentLockResult(t, declaredDone, "declared mutation") + automatic := waitPersonFactEmploymentLockResult(t, automaticDone, "automatic projection") + require.NoError(declared.err) + require.NotNil(declared.employment) + require.NoError(automatic.err) + require.NotNil(automatic.result) + assert.Equal(personFactEmploymentStagePerson, firstAutomatic, + "automatic projection must wait on the person before touching current employment or organizations") + assert.Empty(automatic.result.Projections, + "the automatic projection must observe the committed declared pin after waiting") + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + require.NoError(err) + require.Len(employments, 1) + assert.Equal("Declared Advisor", *employments[0].Title) + assert.Equal(ProvenanceUser, employments[0].Source) +} + +func TestPersonFactEmploymentNonIDTableLockDeadlockRetriesPostgres(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + if !IsPostgresURL(dbURL) { + t.Skip("PostgreSQL table and row locks are required for the retry regression") + } + assertions := assert.New(t) + requirements := require.New(t) + gate := newPersonFactEmploymentLockOrderGate(personFactEmploymentStageOrganization) + t.Cleanup(gate.release) + st := newPersonFactEmploymentPostgresGateStore(t, dbURL, gate) + personID, target, organization := newPersonFactEmploymentLockOrderFixture( + t, st, "non-id-deadlock") + var deadlocksBefore int64 + requirements.NoError(st.DB().QueryRowContext(t.Context(), ` + SELECT deadlocks FROM pg_stat_database WHERE datname = current_database() + `).Scan(&deadlocksBefore)) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + t.Cleanup(cancel) + declaredCtx := context.WithValue( + ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorDeclared) + automaticCtx := context.WithValue( + ctx, personFactEmploymentLockActorKey{}, personFactEmploymentActorAutomatic) + type declaredResult struct { + employment *Employment + err error + } + declaredDone := make(chan declaredResult, 1) + go func() { + employment, err := st.AddEmploymentContext(declaredCtx, EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Declared Advisor"), Source: ProvenanceUser, IsPrimary: new(false), + }) + declaredDone <- declaredResult{employment: employment, err: err} + }() + waitPersonFactEmploymentLockSignal(t, gate.declaredPaused, + "declared mutation did not hold the person before organization validation") + + automaticDone := make(chan struct { + result *personfacts.GenerationResult + err error + }, 1) + go func() { + result, err := st.ApplyPersonFactGenerationContext(automaticCtx, + personFactProjectionInput(personID, "non-id-deadlock", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, + `{"organization":{"name":"Lock Order Company","domain":"lock-order.example"},"title":"Automatic Engineer"}`, + "non-id-deadlock"), + }, nil), nil) + automaticDone <- struct { + result *personfacts.GenerationResult + err error + }{result: result, err: err} + }() + firstAutomatic := waitPersonFactEmploymentLockStage(t, gate.automaticFirst) + gate.release() + declared := waitPersonFactEmploymentLockResult(t, declaredDone, "declared mutation") + automatic := waitPersonFactEmploymentLockResult(t, automaticDone, "automatic projection") + requirements.NoError(declared.err) + requirements.NotNil(declared.employment) + requirements.NoError(automatic.err) + requirements.NotNil(automatic.result) + assertions.Equal(personFactEmploymentStagePerson, firstAutomatic, + "non-ID projection must take its table lock, then preserve person-before-row ordering") + requirements.Eventually(func() bool { + var deadlocksAfter int64 + return st.DB().QueryRowContext(t.Context(), ` + SELECT deadlocks FROM pg_stat_database WHERE datname = current_database() + `).Scan(&deadlocksAfter) == nil && deadlocksAfter > deadlocksBefore + }, 5*time.Second, 10*time.Millisecond, + "the non-ID table/person lock cycle did not exercise PostgreSQL deadlock retry") + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: personID, CurrentOnly: true, + }) + requirements.NoError(err) + requirements.NotEmpty(employments) + assertions.Contains([]string{"Declared Advisor", "Automatic Engineer"}, + *employments[0].Title) +} + +type personFactEmploymentLockActorKey struct{} + +const ( + personFactEmploymentActorAutomatic = "automatic" + personFactEmploymentActorDeclared = "declared" + personFactEmploymentStagePerson = "person" + personFactEmploymentStageCurrent = "current-employment" + personFactEmploymentStageOrganization = "organization" +) + +type personFactEmploymentLockOrderGate struct { + mu sync.Mutex + pauseStage string + armed bool + declaredPaused chan struct{} + releaseDeclared chan struct{} + automaticFirst chan string + declaredSeen bool + automaticSeen bool + releaseOnce sync.Once +} + +func newPersonFactEmploymentLockOrderGate(pauseStage string) *personFactEmploymentLockOrderGate { + return &personFactEmploymentLockOrderGate{ + pauseStage: pauseStage, armed: true, + declaredPaused: make(chan struct{}), releaseDeclared: make(chan struct{}), + automaticFirst: make(chan string, 1), + } +} + +func (g *personFactEmploymentLockOrderGate) release() { + g.releaseOnce.Do(func() { close(g.releaseDeclared) }) +} + +func (g *personFactEmploymentLockOrderGate) beforeStatement( + ctx context.Context, query string, +) error { + actor, _ := ctx.Value(personFactEmploymentLockActorKey{}).(string) + if actor == "" { + return nil + } + stage := personFactEmploymentStatementStage(query) + if stage == "" { + return nil + } + g.mu.Lock() + if !g.armed { + g.mu.Unlock() + return nil + } + if actor == personFactEmploymentActorDeclared && stage == g.pauseStage && !g.declaredSeen { + g.declaredSeen = true + close(g.declaredPaused) + release := g.releaseDeclared + g.mu.Unlock() + select { + case <-release: + return nil + case <-ctx.Done(): + return ctx.Err() + } + } + if actor == personFactEmploymentActorAutomatic && !g.automaticSeen { + g.automaticSeen = true + g.automaticFirst <- stage + } + g.mu.Unlock() + return nil +} + +func personFactEmploymentStatementStage(query string) string { + query = strings.Join(strings.Fields(query), " ") + switch { + case strings.Contains(query, "SELECT id FROM persons WHERE id ="): + return personFactEmploymentStagePerson + case strings.Contains(query, "FROM employments WHERE person_id ="): + return personFactEmploymentStageCurrent + case strings.Contains(query, "FROM organizations WHERE id ="): + return personFactEmploymentStageOrganization + default: + return "" + } +} + +type personFactEmploymentGateConnector struct { + driver.Connector + + gate *personFactEmploymentLockOrderGate +} + +func (c *personFactEmploymentGateConnector) Connect(ctx context.Context) (driver.Conn, error) { + conn, err := c.Connector.Connect(ctx) + if err != nil { + return nil, err + } + return &personFactEmploymentGateConn{Conn: conn, gate: c.gate}, nil +} + +type personFactEmploymentSQLiteConnector struct { + driver driver.Driver + dsn string +} + +func (c *personFactEmploymentSQLiteConnector) Connect(context.Context) (driver.Conn, error) { + return c.driver.Open(c.dsn) +} + +func (c *personFactEmploymentSQLiteConnector) Driver() driver.Driver { return c.driver } + +type personFactEmploymentGateConn struct { + driver.Conn + + gate *personFactEmploymentLockOrderGate +} + +func (c *personFactEmploymentGateConn) QueryContext( + ctx context.Context, query string, args []driver.NamedValue, +) (driver.Rows, error) { + if err := c.gate.beforeStatement(ctx, query); err != nil { + return nil, err + } + queryer, ok := c.Conn.(driver.QueryerContext) + if !ok { + return nil, driver.ErrSkip + } + return queryer.QueryContext(ctx, query, args) +} + +func (c *personFactEmploymentGateConn) ExecContext( + ctx context.Context, query string, args []driver.NamedValue, +) (driver.Result, error) { + if err := c.gate.beforeStatement(ctx, query); err != nil { + return nil, err + } + if execer, ok := c.Conn.(driver.ExecerContext); ok { + return execer.ExecContext(ctx, query, args) + } + return nil, driver.ErrSkip +} + +func (c *personFactEmploymentGateConn) PrepareContext( + ctx context.Context, query string, +) (driver.Stmt, error) { + if preparer, ok := c.Conn.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, query) + } + return c.Prepare(query) +} + +func (c *personFactEmploymentGateConn) BeginTx( + ctx context.Context, opts driver.TxOptions, +) (driver.Tx, error) { + if beginner, ok := c.Conn.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, opts) + } + return nil, errors.New("wrapped employment lock-order connection does not implement ConnBeginTx") +} + +func (c *personFactEmploymentGateConn) Ping(ctx context.Context) error { + if pinger, ok := c.Conn.(driver.Pinger); ok { + return pinger.Ping(ctx) + } + return nil +} + +func (c *personFactEmploymentGateConn) ResetSession(ctx context.Context) error { + if resetter, ok := c.Conn.(driver.SessionResetter); ok { + return resetter.ResetSession(ctx) + } + return nil +} + +func (c *personFactEmploymentGateConn) IsValid() bool { + if validator, ok := c.Conn.(driver.Validator); ok { + return validator.IsValid() + } + return true +} + +func (c *personFactEmploymentGateConn) CheckNamedValue(value *driver.NamedValue) error { + if checker, ok := c.Conn.(driver.NamedValueChecker); ok { + return checker.CheckNamedValue(value) + } + return driver.ErrSkip +} + +func newPersonFactEmploymentSQLiteGateStore( + t *testing.T, gate *personFactEmploymentLockOrderGate, +) *Store { + t.Helper() + require := require.New(t) + sqliteDriver := &sqlite3.SQLiteDriver{ConnectHook: func(conn *sqlite3.SQLiteConn) error { + return conn.RegisterFunc(sqliteutil.UnicodeLowerFunction, strings.ToLower, true) + }} + base := &personFactEmploymentSQLiteConnector{ + driver: sqliteDriver, + dsn: filepath.Join(t.TempDir(), "employment-lock-order.db") + testSQLiteParams, + } + db := sql.OpenDB(&personFactEmploymentGateConnector{Connector: base, gate: gate}) + db.SetMaxOpenConns(4) + db.SetMaxIdleConns(4) + dialect := &SQLiteDialect{} + st := &Store{ + db: newLoggedDB(db, dialect.Rebind), dbPath: base.dsn, dialect: dialect, + } + t.Cleanup(func() { _ = st.Close() }) + require.NoError(st.InitSchemaContext(t.Context())) + return st +} + +func newPersonFactEmploymentPostgresGateStore( + t *testing.T, dbURL string, gate *personFactEmploymentLockOrderGate, +) *Store { + t.Helper() + require := require.New(t) + base := newPGStoreInternal(t, dbURL) + config, err := postgresConnConfig(base.dbPath, false) + require.NoError(err) + db := sql.OpenDB(&personFactEmploymentGateConnector{ + Connector: stdlib.GetConnector(*config), gate: gate, + }) + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(4) + dialect := &PostgreSQLDialect{} + require.NoError(dialect.InitConn(db)) + st := &Store{ + db: newLoggedDB(db, dialect.Rebind), dbPath: base.dbPath, dialect: dialect, + } + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func newPersonFactEmploymentLockOrderFixture( + t *testing.T, st *Store, suffix string, +) (int64, personfacts.TargetDescriptor, *Organization) { + t.Helper() + require := require.New(t) + participantID, err := st.EnsureParticipant( + "employment-lock-order-"+suffix+"@example.invalid", "Lock Order Person", "example.invalid") + require.NoError(err) + person, _, err := st.CreatePersonFromParticipant(participantID) + require.NoError(err) + _, err = st.SetPersonTrackingContext(t.Context(), person.ID, true) + require.NoError(err) + organization := createPersonFactOrganization( + t, st, "Lock Order Company", "lock-order.example") + return person.ID, projectionTargetBySlug(t, st, "employment"), organization +} + +func waitPersonFactEmploymentLockSignal(t *testing.T, signal <-chan struct{}, message string) { + t.Helper() + require := require.New(t) + select { + case <-signal: + case <-time.After(5 * time.Second): + require.FailNow(message) + } +} + +func waitPersonFactEmploymentLockStage(t *testing.T, stages <-chan string) string { + t.Helper() + require := require.New(t) + select { + case stage := <-stages: + return stage + case <-time.After(5 * time.Second): + require.FailNow("automatic projection did not reach employment lock-sensitive state") + return "" + } +} + +func waitPersonFactEmploymentLockResult[T any]( + t *testing.T, results <-chan T, operation string, +) T { + t.Helper() + require := require.New(t) + select { + case result := <-results: + return result + case <-time.After(10 * time.Second): + require.FailNow(operation + " did not finish") + var zero T + return zero + } +} + +func createPersonFactOrganization(t *testing.T, st *Store, name, domain string) *Organization { + t.Helper() + input := OrganizationInput{Name: name, Kind: OrganizationKindCompany} + if domain != "" { + input.PrimaryDomain = &domain + } + organization, err := st.CreateOrganizationContext(t.Context(), input) + require.NoError(t, err) + return organization +} diff --git a/internal/store/person_fact_evidence_version_test.go b/internal/store/person_fact_evidence_version_test.go new file mode 100644 index 000000000..77088e712 --- /dev/null +++ b/internal/store/person_fact_evidence_version_test.go @@ -0,0 +1,61 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestApplyPersonFactGenerationTransitionsEmptySourceVersionEvidence(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + claim := personFactProjectionClaim(personID, target, `"chat"`, "empty-source-version") + claim.Evidence[0].SourceVersion = "" + + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "empty-source-version-seed", + []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + evidence, err := st.ListPersonFactEvidenceContext( + t.Context(), personID, personfacts.EvidenceFilter{}) + require.NoError(err) + require.Len(evidence, 1) + assert.Empty(evidence[0].Input.SourceVersion) + evidenceKey := evidence[0].Key + + unsupported, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "empty-source-version-off", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}), nil) + require.NoError(err) + require.Len(unsupported.Decisions, 1) + assert.Equal(personfacts.ReasonEvidenceUnsupported, unsupported.Decisions[0].Reason) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(values) + + reactivated, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "empty-source-version-on", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "", Supported: true, + Reason: personfacts.EvidenceStatusSourceReimported, + }}), nil) + require.NoError(err) + require.Len(reactivated.Projections, 1) + evidence, err = st.ListPersonFactEvidenceContext( + t.Context(), personID, personfacts.EvidenceFilter{}) + require.NoError(err) + require.Len(evidence, 1) + assert.True(evidence[0].Supported) + require.NotNil(evidence[0].LatestStatus) + assert.Empty(evidence[0].LatestStatus.SourceVersion) + assert.Equal(personfacts.EvidenceStatusSourceReimported, evidence[0].LatestStatus.Reason) +} diff --git a/internal/store/person_fact_fingerprint_test.go b/internal/store/person_fact_fingerprint_test.go new file mode 100644 index 000000000..cf764d6db --- /dev/null +++ b/internal/store/person_fact_fingerprint_test.go @@ -0,0 +1,40 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPersonFactInvalidClaimSubmittedNumberReplayIsLossless(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + prepare := func(submitted string) personfacts.PreparedGeneration { + claim := personFactLedgerClaim(personID, "large-integer", submitted, "large-integer") + claim.Target.ValueType = personfacts.ValueInteger + prepared := preparePersonFactLedgerGeneration( + t, personID, "large-integer", []personfacts.ProposedClaim{claim}, nil) + requirements.Len(prepared.Claims(), 1) + requirements.NotNil(prepared.Claims()[0].Failure) + return prepared + } + + firstPrepared := prepare(`9223372036854775808000`) + first := persistPersonFactLedgerGeneration(t, st, firstPrepared, nil) + + equivalentPrepared := prepare(`9.223372036854775808e21`) + assertions.Equal(firstPrepared.GenerationKey(), equivalentPrepared.GenerationKey()) + equivalent := persistPersonFactLedgerGeneration(t, st, equivalentPrepared, nil) + assertions.Equal(first.Generation.ID, equivalent.Generation.ID, + "equivalent submitted number must replay the generation") + + distinctPrepared := prepare(`9223372036854775808001`) + assertions.NotEqual(firstPrepared.GenerationKey(), distinctPrepared.GenerationKey()) + distinct := persistPersonFactLedgerGeneration(t, st, distinctPrepared, nil) + assertions.NotEqual(first.Generation.ID, distinct.Generation.ID, + "distinct submitted number must create a generation") +} diff --git a/internal/store/person_fact_ledger.go b/internal/store/person_fact_ledger.go new file mode 100644 index 000000000..ab325c5f4 --- /dev/null +++ b/internal/store/person_fact_ledger.go @@ -0,0 +1,1358 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "reflect" + "slices" + "sort" + "time" + + "go.kenn.io/msgvault/internal/personfacts" +) + +// ErrPersonFactKeyCollision reports that a deterministic key already names +// different immutable ledger content. +var ErrPersonFactKeyCollision = errors.New("person fact deterministic key collision") + +type personFactLedgerGeneration struct { + Generation personfacts.Generation + Claims []personfacts.Claim + Evidence []personfacts.Evidence + EvidenceStatusEvents []personfacts.EvidenceStatusEvent + Resolutions []personfacts.ResolutionResult +} + +func (s *Store) insertPersonFactGenerationTx( + ctx context.Context, + tx *loggedTx, + prepared personfacts.PreparedGeneration, +) (personfacts.Generation, bool, error) { + input := prepared.Input() + input.ResolvedAt = personFactPortableTime(input.ResolvedAt) + claims := prepared.Claims() + statuses := prepared.EvidenceStatusChanges() + if input.PersonID <= 0 || len(prepared.CanonicalJSON()) == 0 || prepared.GenerationKey() == "" { + return personfacts.Generation{}, false, errors.New("prepared person fact generation is empty") + } + key, err := personfacts.GenerationKey(input, claims, statuses) + if err != nil { + return personfacts.Generation{}, false, fmt.Errorf("verify prepared person fact generation: %w", err) + } + if key != prepared.GenerationKey() || input.ProgramFingerprint != prepared.ProgramFingerprint() { + return personfacts.Generation{}, false, errors.New("prepared person fact generation failed integrity verification") + } + cursorsJSON, err := json.Marshal(input.SourceCursors) + if err != nil { + return personfacts.Generation{}, false, fmt.Errorf("encode person fact source cursors: %w", err) + } + + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_generations + (person_id, generation_key, source_cursors_json, program_id, program_version, + program_fingerprint, catalog_fingerprint, provider, provider_version, + model, model_version, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, `+s.dialect.JSONBindExpr()+`, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + input.PersonID, prepared.GenerationKey(), string(cursorsJSON), input.ProgramID, + input.ProgramVersion, input.ProgramFingerprint, input.CatalogFingerprint, + input.Provider, input.ProviderVersion, input.Model, input.ModelVersion, + input.Policy.ProviderPolicyFingerprint, input.ResolvedAt, + ).Scan(&insertedID) + replay := false + if errors.Is(err, sql.ErrNoRows) { + replay = true + } else if err != nil { + return personfacts.Generation{}, false, fmt.Errorf("insert person fact generation: %w", err) + } + + generation, err := s.loadPersonFactGenerationRowTx(ctx, tx, input.PersonID, prepared.GenerationKey()) + if err != nil { + return personfacts.Generation{}, false, err + } + if insertedID != 0 && generation.ID != insertedID { + return personfacts.Generation{}, false, errors.New("insert person fact generation returned inconsistent id") + } + if !personFactGenerationMatches(generation, input, prepared.GenerationKey()) { + return personfacts.Generation{}, false, personFactCollision("generation", prepared.GenerationKey()) + } + return generation, replay, nil +} + +func (s *Store) insertPersonFactClaimsTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + preparedClaims []personfacts.PreparedClaim, +) ([]personfacts.Claim, error) { + return s.insertPersonFactClaimsWithFailuresTx(ctx, tx, generation, preparedClaims, nil) +} + +func (s *Store) insertPersonFactClaimsWithFailuresTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + preparedClaims []personfacts.PreparedClaim, + failures map[string]*personfacts.ValidationFailure, +) ([]personfacts.Claim, error) { + claims := make([]personfacts.Claim, 0, len(preparedClaims)) + for _, prepared := range preparedClaims { + claimKey, err := personfacts.ClaimKey(generation.GenerationKey, prepared) + if err != nil { + return nil, fmt.Errorf("compute person fact claim key: %w", err) + } + persisted := prepared + if failure := failures[claimKey]; failure != nil { + persisted.Normalized = nil + persisted.Failure = copyPersonFactFailure(failure) + } + claim, err := s.insertPersonFactClaimWithKeyTx(ctx, tx, generation, claimKey, persisted) + if err != nil { + return nil, err + } + claims = append(claims, claim) + } + sort.Slice(claims, func(i, j int) bool { return claims[i].ClaimKey < claims[j].ClaimKey }) + return claims, nil +} + +func (s *Store) insertPersonFactClaimWithKeyTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + claimKey string, + prepared personfacts.PreparedClaim, +) (personfacts.Claim, error) { + prepared.ValidFrom = personFactPortableTimePointer(prepared.ValidFrom) + prepared.ValidUntil = personFactPortableTimePointer(prepared.ValidUntil) + confidenceJSON, err := json.Marshal(prepared.Confidence) + if err != nil { + return personfacts.Claim{}, fmt.Errorf("encode person fact claim confidence: %w", err) + } + var normalizedJSON, valueFingerprint any + if prepared.Normalized != nil { + normalizedJSON = string(prepared.Normalized.JSON) + valueFingerprint = prepared.Normalized.Fingerprint + } + var rejectionAction, rejectionReason, rejectionDetail any + if prepared.Failure != nil { + rejectionAction = prepared.Failure.Action + rejectionReason = prepared.Failure.Reason + rejectionDetail = prepared.Failure.Detail + } + + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_claims + (person_id, generation_id, claim_key, target_kind, target_key, target_revision, + relation, submitted_value_json, normalized_value_json, value_fingerprint, + valid_from, valid_until, origin, confidence_json, + rejection_action, rejection_reason, rejection_detail) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, `+s.dialect.JSONBindExpr()+`, ?, ?, ?, ?, `+s.dialect.JSONBindExpr()+`, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + generation.PersonID, generation.ID, claimKey, prepared.Target.Kind, prepared.Target.Key, + prepared.Target.Revision, prepared.Relation, string(prepared.SubmittedValue), normalizedJSON, + valueFingerprint, personFactOptionalTime(prepared.ValidFrom), personFactOptionalTime(prepared.ValidUntil), + prepared.Origin, string(confidenceJSON), rejectionAction, rejectionReason, rejectionDetail, + ).Scan(&insertedID) + inserted := true + if errors.Is(err, sql.ErrNoRows) { + inserted = false + } else if err != nil { + return personfacts.Claim{}, fmt.Errorf("insert person fact claim: %w", err) + } + + evidenceIDs := make([]int64, 0, len(prepared.Evidence)) + for index, evidenceInput := range prepared.Evidence { + evidenceKey := prepared.EvidenceKeys[index] + evidence, insertErr := s.insertPersonFactEvidenceTx(ctx, tx, generation.PersonID, + evidenceKey, evidenceInput) + if insertErr != nil { + return personfacts.Claim{}, insertErr + } + evidenceIDs = append(evidenceIDs, evidence.ID) + } + slices.Sort(evidenceIDs) + + claim, err := s.loadPersonFactClaimRowTx(ctx, tx, generation, claimKey) + if err != nil { + return personfacts.Claim{}, err + } + if insertedID != 0 && claim.ID != insertedID { + return personfacts.Claim{}, errors.New("insert person fact claim returned inconsistent id") + } + if !personFactClaimMatches(claim, prepared, generation, claimKey, evidenceIDs) { + return personfacts.Claim{}, personFactCollision("claim", claimKey) + } + if inserted { + for _, evidenceID := range evidenceIDs { + if _, err := tx.ExecContext(ctx, ` + INSERT INTO person_fact_claim_evidence (claim_id, evidence_id) + VALUES (?, ?) ON CONFLICT DO NOTHING`, claim.ID, evidenceID); err != nil { + return personfacts.Claim{}, fmt.Errorf("link person fact claim evidence: %w", err) + } + } + claim.EvidenceIDs = append([]int64(nil), evidenceIDs...) + return claim, nil + } + storedEvidenceIDs, err := loadPersonFactClaimEvidenceIDsTx(ctx, tx, claim.ID) + if err != nil { + return personfacts.Claim{}, err + } + if !reflect.DeepEqual(storedEvidenceIDs, evidenceIDs) { + return personfacts.Claim{}, personFactCollision("claim evidence", claimKey) + } + claim.EvidenceIDs = storedEvidenceIDs + return claim, nil +} + +func (s *Store) insertPersonFactEvidenceTx( + ctx context.Context, + tx *loggedTx, + personID int64, + evidenceKey string, + input personfacts.EvidenceInput, +) (personfacts.Evidence, error) { + input.EventTime = personFactPortableTime(input.EventTime) + input.RecordedTime = personFactPortableTime(input.RecordedTime) + computedKey, err := personfacts.EvidenceKey(input) + if err != nil { + return personfacts.Evidence{}, fmt.Errorf("verify person fact evidence: %w", err) + } + if computedKey != evidenceKey || input.PersonID != personID { + return personfacts.Evidence{}, errors.New("prepared person fact evidence failed integrity verification") + } + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_evidence + (person_id, evidence_key, source_class, directness, authority, source_ref, + source_url, subject_person_id, subject_ref, span_start, span_end, excerpt, + content_sha256, source_version, event_time, recorded_time, identity_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + personID, evidenceKey, input.SourceClass, input.Directness, input.Authority, + input.SourceRef, input.SourceURL, personFactOptionalInt64(input.SubjectPersonID), input.SubjectRef, + personFactOptionalInt64(input.SpanStart), personFactOptionalInt64(input.SpanEnd), input.Excerpt, + input.ContentSHA256, input.SourceVersion, input.EventTime, input.RecordedTime, + input.IdentityScore, + ).Scan(&insertedID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return personfacts.Evidence{}, fmt.Errorf("insert person fact evidence: %w", err) + } + evidence, err := s.loadPersonFactEvidenceRowTx(ctx, tx, personID, evidenceKey) + if err != nil { + return personfacts.Evidence{}, err + } + if insertedID != 0 && evidence.ID != insertedID { + return personfacts.Evidence{}, errors.New("insert person fact evidence returned inconsistent id") + } + if !personFactEvidenceInputMatches(evidence.Input, input) { + return personfacts.Evidence{}, personFactCollision("evidence", evidenceKey) + } + return evidence, nil +} + +func (s *Store) insertPersonFactEvidenceStatusEventsTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + changes []personfacts.PreparedEvidenceStatusChange, +) error { + for _, change := range changes { + evidence, err := s.loadPersonFactEvidenceRowTx(ctx, tx, generation.PersonID, change.EvidenceKey) + if err != nil { + if errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("person fact status references unknown evidence %q", change.EvidenceKey) + } + return err + } + if evidence.Input.SourceVersion != change.SourceVersion { + return fmt.Errorf("person fact status source version does not match evidence %q", change.EvidenceKey) + } + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_evidence_status_events + (person_id, generation_id, evidence_id, evidence_key, source_version, + supported, reason) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + generation.PersonID, generation.ID, evidence.ID, change.EvidenceKey, + change.SourceVersion, change.Supported, change.Reason, + ).Scan(&insertedID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("insert person fact evidence status: %w", err) + } + event, loadErr := loadPersonFactStatusEventByScopedKeyTx(ctx, tx, generation, + change.EvidenceKey, change.SourceVersion, change.Reason) + if loadErr != nil { + return loadErr + } + if insertedID != 0 && event.ID != insertedID { + return errors.New("insert person fact evidence status returned inconsistent id") + } + if event.PersonID != generation.PersonID || event.GenerationID != generation.ID || + event.EvidenceID != evidence.ID || event.EvidenceKey != change.EvidenceKey || + event.SourceVersion != change.SourceVersion || event.Supported != change.Supported || + event.Reason != change.Reason { + return personFactCollision("evidence status", change.EvidenceKey) + } + } + return nil +} + +func (s *Store) insertPersonFactResolutionTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + resolution personfacts.Resolution, + providerPolicyFingerprint string, +) (personfacts.ResolutionResult, error) { + resolution.ResolvedAt = personFactPortableTime(resolution.ResolvedAt) + var insertedID int64 + err := tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_resolutions + (person_id, generation_id, target_kind, target_key, target_revision, + resolver_version, input_fingerprint, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + generation.PersonID, generation.ID, resolution.Target.Kind, resolution.Target.Key, + resolution.Target.Revision, resolution.ResolverVersion, resolution.InputFingerprint, + providerPolicyFingerprint, resolution.ResolvedAt, + ).Scan(&insertedID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return personfacts.ResolutionResult{}, fmt.Errorf("insert person fact resolution: %w", err) + } + stored, storedPolicy, err := s.loadPersonFactResolutionShellTx(ctx, tx, generation.ID, + resolution.Target, resolution.ResolverVersion, resolution.InputFingerprint) + if err != nil { + return personfacts.ResolutionResult{}, err + } + if insertedID != 0 && stored.ID != insertedID { + return personfacts.ResolutionResult{}, errors.New("insert person fact resolution returned inconsistent id") + } + if stored.Target != resolution.Target || stored.ResolverVersion != resolution.ResolverVersion || + stored.InputFingerprint != resolution.InputFingerprint || + !personFactPortableTime(stored.ResolvedAt).Equal(resolution.ResolvedAt) || + storedPolicy != providerPolicyFingerprint { + return personfacts.ResolutionResult{}, personFactCollision("resolution", resolution.InputFingerprint) + } + for _, decision := range resolution.Decisions { + if err := s.insertPersonFactDecisionTx(ctx, tx, generation.PersonID, stored.ID, + resolution.InputFingerprint, decision); err != nil { + return personfacts.ResolutionResult{}, err + } + } + stored, err = s.hydratePersonFactResolutionTx(ctx, tx, stored) + if err != nil { + return personfacts.ResolutionResult{}, err + } + if len(stored.Decisions) != len(resolution.Decisions) { + return personfacts.ResolutionResult{}, personFactCollision("resolution decisions", resolution.InputFingerprint) + } + return stored, nil +} + +func (s *Store) insertPersonFactDecisionTx( + ctx context.Context, + tx *loggedTx, + personID, resolutionID int64, + resolutionFingerprint string, + decision personfacts.Decision, +) error { + if decision.ClaimKey == "" { + return errors.New("person fact decision claim key is required") + } + decisionKey, err := personfacts.DecisionKey(resolutionFingerprint, decision.ClaimKey, decision.Action) + if err != nil { + return fmt.Errorf("compute person fact decision key: %w", err) + } + claimID, err := loadPersonFactClaimIDByKeyTx(ctx, tx, personID, decision.ClaimKey) + if err != nil { + return fmt.Errorf("load person fact decision claim: %w", err) + } + var competingClaimID any + if decision.CompetingClaimKey != "" { + id, loadErr := loadPersonFactClaimIDByKeyTx(ctx, tx, personID, decision.CompetingClaimKey) + if loadErr != nil { + return fmt.Errorf("load competing person fact claim: %w", loadErr) + } + competingClaimID = id + } + scoreJSON, err := json.Marshal(decision.Score) + if err != nil { + return fmt.Errorf("encode person fact decision score: %w", err) + } + var projectionKind, projectionRowID, resolvedOrganizationID any + if decision.Projection != nil { + projectionKind, projectionRowID = decision.Projection.Kind, decision.Projection.RowID + if decision.Projection.Kind == personFactProjectionKindEmployment { + var organizationID int64 + loadErr := tx.QueryRowContext(ctx, ` + SELECT organization_id FROM employments + WHERE id = ? AND person_id = ? + `, decision.Projection.RowID, personID).Scan(&organizationID) + if errors.Is(loadErr, sql.ErrNoRows) { + loadErr = tx.QueryRowContext(ctx, ` + SELECT resolved_organization_id + FROM person_fact_decisions + WHERE person_id = ? AND projection_kind = 'employment' + AND projection_row_id = ? AND resolved_organization_id IS NOT NULL + ORDER BY id DESC LIMIT 1`, + personID, decision.Projection.RowID).Scan(&organizationID) + } + if loadErr != nil { + return fmt.Errorf("load resolved employment organization: %w", loadErr) + } + resolvedOrganizationID = organizationID + } + } + var insertedID int64 + err = tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_decisions + (person_id, resolution_id, claim_id, decision_key, action, reason, score_json, + competing_claim_id, projection_kind, projection_row_id, resolved_organization_id) + VALUES (?, ?, ?, ?, ?, ?, `+s.dialect.JSONBindExpr()+`, ?, ?, ?, ?) + ON CONFLICT DO NOTHING + RETURNING id`, + personID, resolutionID, claimID, decisionKey, decision.Action, decision.Reason, + string(scoreJSON), competingClaimID, projectionKind, projectionRowID, + resolvedOrganizationID, + ).Scan(&insertedID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return fmt.Errorf("insert person fact decision: %w", err) + } + stored, err := loadPersonFactDecisionByKeyTx(ctx, tx, personID, decisionKey) + if err != nil { + return err + } + if insertedID != 0 && stored.ID != insertedID { + return errors.New("insert person fact decision returned inconsistent id") + } + want := decision + want.PersonID, want.ResolutionID, want.DecisionKey = personID, resolutionID, decisionKey + if !personFactDecisionMatches(stored, want) { + return personFactCollision("decision", decisionKey) + } + return nil +} + +func (s *Store) loadPersonFactGenerationTx( + ctx context.Context, + tx *loggedTx, + personID int64, + generationKey string, +) (personFactLedgerGeneration, error) { + generation, err := s.loadPersonFactGenerationRowTx(ctx, tx, personID, generationKey) + if err != nil { + return personFactLedgerGeneration{}, err + } + claims, err := s.listPersonFactClaimsTx(ctx, tx, personID, + personfacts.ClaimFilter{}, "c.generation_id = ?", generation.ID, 0, 0) + if err != nil { + return personFactLedgerGeneration{}, err + } + sort.Slice(claims, func(i, j int) bool { return claims[i].ClaimKey < claims[j].ClaimKey }) + evidence, err := s.listPersonFactGenerationEvidenceTx(ctx, tx, personID, generation.ID) + if err != nil { + return personFactLedgerGeneration{}, err + } + if err := s.hydratePersonFactEvidenceStatusTx(ctx, tx, evidence); err != nil { + return personFactLedgerGeneration{}, err + } + statuses, err := s.listPersonFactStatusEventsTx(ctx, tx, personID, + personfacts.EvidenceStatusFilter{}, "e.generation_id = ?", generation.ID, 0, 0) + if err != nil { + return personFactLedgerGeneration{}, err + } + sort.Slice(statuses, func(i, j int) bool { return statuses[i].ID < statuses[j].ID }) + resolutions, err := s.listPersonFactGenerationResolutionsTx(ctx, tx, generation.ID) + if err != nil { + return personFactLedgerGeneration{}, err + } + return personFactLedgerGeneration{ + Generation: generation, Claims: claims, Evidence: evidence, + EvidenceStatusEvents: statuses, Resolutions: resolutions, + }, nil +} + +// ListPersonFactEvidenceContext returns a bounded newest-first evidence page +// with effective support hydrated from the latest immutable status event. +func (s *Store) ListPersonFactEvidenceContext( + ctx context.Context, + personID int64, + filter personfacts.EvidenceFilter, +) ([]personfacts.Evidence, error) { + limit, offset, err := personFactPage(filter.Limit, filter.Offset) + if err != nil { + return nil, err + } + evidence := make([]personfacts.Evidence, 0) + err = s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + evidence, err = s.queryPersonFactEvidenceTx(ctx, tx, personID, + personfacts.EvidenceFilter{Target: filter.Target, Limit: limit, Offset: offset}) + if err != nil { + return err + } + return s.hydratePersonFactEvidenceStatusTx(ctx, tx, evidence) + }) + if err != nil { + return nil, err + } + return evidence, nil +} + +// ListPersonFactEvidenceStatusEventsContext returns the bounded append-only +// status-event audit stream in newest-first order. +func (s *Store) ListPersonFactEvidenceStatusEventsContext( + ctx context.Context, + personID int64, + filter personfacts.EvidenceStatusFilter, +) ([]personfacts.EvidenceStatusEvent, error) { + limit, offset, err := personFactPage(filter.Limit, filter.Offset) + if err != nil { + return nil, err + } + events := make([]personfacts.EvidenceStatusEvent, 0) + err = s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + events, err = s.listPersonFactStatusEventsTx(ctx, tx, personID, filter, "", nil, limit, offset) + return err + }) + if err != nil { + return nil, err + } + return events, nil +} + +// ListPersonFactClaimsContext returns a bounded newest-first claim page. +func (s *Store) ListPersonFactClaimsContext( + ctx context.Context, + personID int64, + filter personfacts.ClaimFilter, +) ([]personfacts.Claim, error) { + limit, offset, err := personFactPage(filter.Limit, filter.Offset) + if err != nil { + return nil, err + } + claims := make([]personfacts.Claim, 0) + err = s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + claims, err = s.listPersonFactClaimsTx(ctx, tx, personID, filter, "", nil, limit, offset) + return err + }) + if err != nil { + return nil, err + } + return claims, nil +} + +// ListPersonFactDecisionsContext returns a bounded newest-first decision page. +func (s *Store) ListPersonFactDecisionsContext( + ctx context.Context, + personID int64, + filter personfacts.DecisionFilter, +) ([]personfacts.Decision, error) { + limit, offset, err := personFactPage(filter.Limit, filter.Offset) + if err != nil { + return nil, err + } + decisions := make([]personfacts.Decision, 0) + err = s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + decisions, err = s.listPersonFactDecisionsTx(ctx, tx, personID, filter, limit, offset) + return err + }) + if err != nil { + return nil, err + } + return decisions, nil +} + +func (s *Store) queryPersonFactEvidenceTx( + ctx context.Context, + tx *loggedTx, + personID int64, + filter personfacts.EvidenceFilter, +) ([]personfacts.Evidence, error) { + query := `SELECT ` + personFactEvidenceColumns + ` + FROM person_fact_evidence e WHERE e.person_id = ?` + args := []any{personID} + if filter.Target != nil { + query += ` AND EXISTS ( + SELECT 1 FROM person_fact_claim_evidence ce + JOIN person_fact_claims c ON c.id = ce.claim_id + WHERE ce.evidence_id = e.id AND c.person_id = e.person_id + AND c.target_kind = ? AND c.target_key = ? AND c.target_revision = ?)` + args = append(args, filter.Target.Kind, filter.Target.Key, filter.Target.Revision) + } + query += ` ORDER BY e.id DESC LIMIT ? OFFSET ?` + args = append(args, filter.Limit, filter.Offset) + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list person fact evidence: %w", err) + } + defer func() { _ = rows.Close() }() + evidence := make([]personfacts.Evidence, 0) + for rows.Next() { + item, scanErr := scanPersonFactEvidence(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact evidence: %w", scanErr) + } + evidence = append(evidence, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact evidence: %w", err) + } + return evidence, nil +} + +func (s *Store) listPersonFactGenerationEvidenceTx( + ctx context.Context, + tx *loggedTx, + personID, generationID int64, +) ([]personfacts.Evidence, error) { + rows, err := tx.QueryContext(ctx, `SELECT `+personFactEvidenceColumns+` + FROM person_fact_evidence e + WHERE e.person_id = ? AND EXISTS ( + SELECT 1 FROM person_fact_claim_evidence ce + JOIN person_fact_claims c ON c.id = ce.claim_id + WHERE ce.evidence_id = e.id AND c.generation_id = ?) + ORDER BY e.evidence_key, e.id`, personID, generationID) + if err != nil { + return nil, fmt.Errorf("list person fact generation evidence: %w", err) + } + defer func() { _ = rows.Close() }() + evidence := make([]personfacts.Evidence, 0) + for rows.Next() { + item, scanErr := scanPersonFactEvidence(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact generation evidence: %w", scanErr) + } + evidence = append(evidence, item) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact generation evidence: %w", err) + } + return evidence, nil +} + +func (s *Store) hydratePersonFactEvidenceStatusTx( + ctx context.Context, + tx *loggedTx, + evidence []personfacts.Evidence, +) error { + for index := range evidence { + evidence[index].Supported = true + row := tx.QueryRowContext(ctx, ` + SELECT latest.id, latest.person_id, latest.generation_id, latest.evidence_id, + latest.evidence_key, latest.source_version, latest.supported, + latest.reason, latest.created_at + FROM person_fact_evidence_status_events latest + JOIN person_fact_generations generation ON generation.id = latest.generation_id + WHERE latest.person_id = ? AND latest.evidence_key = ? AND latest.source_version = ? + ORDER BY generation.resolved_at DESC, latest.id DESC + LIMIT 1 + `, evidence[index].PersonID, evidence[index].Key, evidence[index].Input.SourceVersion) + event, err := scanPersonFactStatusEvent(row) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return fmt.Errorf("hydrate latest person fact evidence status: %w", err) + } + evidence[index].Supported = event.Supported + evidence[index].LatestStatus = &event + } + return nil +} + +func (s *Store) listPersonFactStatusEventsTx( + ctx context.Context, + tx *loggedTx, + personID int64, + filter personfacts.EvidenceStatusFilter, + extraPredicate string, + extraArg any, + limit, offset int, +) ([]personfacts.EvidenceStatusEvent, error) { + query := `SELECT id, person_id, generation_id, evidence_id, evidence_key, + source_version, supported, reason, created_at + FROM person_fact_evidence_status_events e WHERE e.person_id = ?` + args := []any{personID} + if filter.EvidenceKey != "" { + query += ` AND e.evidence_key = ?` + args = append(args, filter.EvidenceKey) + } + if filter.Supported != nil { + query += ` AND e.supported = ?` + args = append(args, *filter.Supported) + } + if extraPredicate != "" { + query += ` AND ` + extraPredicate + args = append(args, extraArg) + } + query += ` ORDER BY e.id DESC` + if limit > 0 { + query += ` LIMIT ? OFFSET ?` + args = append(args, limit, offset) + } + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list person fact evidence statuses: %w", err) + } + defer func() { _ = rows.Close() }() + events := make([]personfacts.EvidenceStatusEvent, 0) + for rows.Next() { + event, scanErr := scanPersonFactStatusEvent(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact evidence status: %w", scanErr) + } + events = append(events, event) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact evidence statuses: %w", err) + } + return events, nil +} + +func (s *Store) listPersonFactClaimsTx( + ctx context.Context, + tx *loggedTx, + personID int64, + filter personfacts.ClaimFilter, + extraPredicate string, + extraArg any, + limit, offset int, +) ([]personfacts.Claim, error) { + query := `SELECT ` + personFactClaimColumns + ` + FROM person_fact_claims c WHERE c.person_id = ?` + args := []any{personID} + if filter.Target != nil { + query += ` AND c.target_kind = ? AND c.target_key = ? AND c.target_revision = ?` + args = append(args, filter.Target.Kind, filter.Target.Key, filter.Target.Revision) + } + if extraPredicate != "" { + query += ` AND ` + extraPredicate + args = append(args, extraArg) + } + query += ` ORDER BY c.id DESC` + if limit > 0 { + query += ` LIMIT ? OFFSET ?` + args = append(args, limit, offset) + } + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list person fact claims: %w", err) + } + defer func() { _ = rows.Close() }() + claims := make([]personfacts.Claim, 0) + for rows.Next() { + claim, scanErr := scanPersonFactClaim(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact claim: %w", scanErr) + } + claims = append(claims, claim) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact claims: %w", err) + } + for index := range claims { + generation, loadErr := s.loadPersonFactGenerationByIDTx(ctx, tx, claims[index].GenerationID) + if loadErr != nil { + return nil, loadErr + } + claims[index].Generation = generation + claims[index].EvidenceIDs, loadErr = loadPersonFactClaimEvidenceIDsTx(ctx, tx, claims[index].ID) + if loadErr != nil { + return nil, loadErr + } + } + return claims, nil +} + +func (s *Store) listPersonFactDecisionsTx( + ctx context.Context, + tx *loggedTx, + personID int64, + filter personfacts.DecisionFilter, + limit, offset int, +) ([]personfacts.Decision, error) { + query := `SELECT ` + personFactDecisionColumns + ` + FROM person_fact_decisions d + JOIN person_fact_resolutions r ON r.id = d.resolution_id + LEFT JOIN person_fact_claims c ON c.id = d.claim_id + LEFT JOIN person_fact_claims competing ON competing.id = d.competing_claim_id + WHERE d.person_id = ?` + args := []any{personID} + if filter.Target != nil { + query += ` AND r.target_kind = ? AND r.target_key = ? AND r.target_revision = ?` + args = append(args, filter.Target.Kind, filter.Target.Key, filter.Target.Revision) + } + query += ` ORDER BY d.id DESC LIMIT ? OFFSET ?` + args = append(args, limit, offset) + rows, err := tx.QueryContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("list person fact decisions: %w", err) + } + defer func() { _ = rows.Close() }() + decisions := make([]personfacts.Decision, 0) + for rows.Next() { + decision, scanErr := scanPersonFactDecision(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact decision: %w", scanErr) + } + decisions = append(decisions, decision) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact decisions: %w", err) + } + return decisions, nil +} + +const personFactEvidenceColumns = `e.id, e.person_id, e.evidence_key, + e.source_class, e.directness, e.authority, e.source_ref, e.source_url, + e.subject_person_id, e.subject_ref, e.span_start, e.span_end, e.excerpt, + e.content_sha256, e.source_version, e.event_time, e.recorded_time, + e.identity_score, e.created_at` + +func scanPersonFactEvidence(row scanner) (personfacts.Evidence, error) { + return scanPersonFactEvidenceWithPrefix(row) +} + +func scanPersonFactEvidenceWithPrefix(row scanner, prefix ...any) (personfacts.Evidence, error) { + var evidence personfacts.Evidence + var subjectID, spanStart, spanEnd sql.NullInt64 + var eventTime, recordedTime, createdAt personFactTimestamp + destinations := make([]any, len(prefix), len(prefix)+19) + copy(destinations, prefix) + destinations = append(destinations, + &evidence.ID, &evidence.PersonID, &evidence.Key, + &evidence.Input.SourceClass, &evidence.Input.Directness, &evidence.Input.Authority, + &evidence.Input.SourceRef, &evidence.Input.SourceURL, &subjectID, &evidence.Input.SubjectRef, + &spanStart, &spanEnd, &evidence.Input.Excerpt, &evidence.Input.ContentSHA256, + &evidence.Input.SourceVersion, &eventTime, &recordedTime, &evidence.Input.IdentityScore, + &createdAt, + ) + err := row.Scan(destinations...) + if err != nil { + return personfacts.Evidence{}, err + } + evidence.Input.PersonID = evidence.PersonID + evidence.Input.SubjectPersonID = personFactNullInt64(subjectID) + evidence.Input.SpanStart = personFactNullInt64(spanStart) + evidence.Input.SpanEnd = personFactNullInt64(spanEnd) + evidence.Input.EventTime = eventTime.Time + evidence.Input.RecordedTime = recordedTime.Time + evidence.CreatedAt = createdAt.Time + evidence.Supported = true + return evidence, nil +} + +const personFactClaimColumns = `c.id, c.person_id, c.generation_id, c.claim_key, + c.target_kind, c.target_key, c.target_revision, c.relation, + c.submitted_value_json, CAST(c.normalized_value_json AS TEXT), c.value_fingerprint, + c.valid_from, c.valid_until, c.origin, CAST(c.confidence_json AS TEXT), + c.rejection_action, c.rejection_reason, c.rejection_detail, c.created_at` + +func scanPersonFactClaim(row scanner) (personfacts.Claim, error) { + var claim personfacts.Claim + var submitted string + var normalizedJSON, valueFingerprint sql.NullString + var validFrom, validUntil nullableTimestamp + var confidenceJSON string + var rejectionAction, rejectionReason, rejectionDetail sql.NullString + var createdAt personFactTimestamp + err := row.Scan( + &claim.ID, &claim.PersonID, &claim.GenerationID, &claim.ClaimKey, + &claim.Target.Kind, &claim.Target.Key, &claim.Target.Revision, &claim.Relation, + &submitted, &normalizedJSON, &valueFingerprint, &validFrom, &validUntil, + &claim.Origin, &confidenceJSON, &rejectionAction, &rejectionReason, &rejectionDetail, &createdAt, + ) + if err != nil { + return personfacts.Claim{}, err + } + claim.SubmittedValue = append(json.RawMessage(nil), submitted...) + if normalizedJSON.Valid && valueFingerprint.Valid { + claim.Normalized = &personfacts.NormalizedValue{ + JSON: append(json.RawMessage(nil), normalizedJSON.String...), Fingerprint: valueFingerprint.String, + } + } + if validFrom.Valid { + value := validFrom.Time.UTC() + claim.ValidFrom = &value + } + if validUntil.Valid { + value := validUntil.Time.UTC() + claim.ValidUntil = &value + } + if err := json.Unmarshal([]byte(confidenceJSON), &claim.Confidence); err != nil { + return personfacts.Claim{}, fmt.Errorf("decode confidence: %w", err) + } + if rejectionAction.Valid && rejectionReason.Valid && rejectionDetail.Valid { + claim.Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionAction(rejectionAction.String), + Reason: personfacts.DecisionReason(rejectionReason.String), + Detail: rejectionDetail.String, + } + } + claim.CreatedAt = createdAt.Time + claim.EvidenceIDs = make([]int64, 0) + return claim, nil +} + +func scanPersonFactStatusEvent(row scanner) (personfacts.EvidenceStatusEvent, error) { + var event personfacts.EvidenceStatusEvent + var createdAt personFactTimestamp + err := row.Scan(&event.ID, &event.PersonID, &event.GenerationID, &event.EvidenceID, + &event.EvidenceKey, &event.SourceVersion, &event.Supported, &event.Reason, &createdAt) + if err != nil { + return personfacts.EvidenceStatusEvent{}, err + } + event.CreatedAt = createdAt.Time + return event, nil +} + +const personFactDecisionColumns = `d.id, d.person_id, d.resolution_id, d.decision_key, + COALESCE(c.claim_key, ''), d.action, d.reason, CAST(d.score_json AS TEXT), + COALESCE(competing.claim_key, ''), d.projection_kind, d.projection_row_id, d.created_at` + +func scanPersonFactDecision(row scanner) (personfacts.Decision, error) { + var decision personfacts.Decision + var scoreJSON string + var projectionKind sql.NullString + var projectionRowID sql.NullInt64 + var createdAt personFactTimestamp + err := row.Scan( + &decision.ID, &decision.PersonID, &decision.ResolutionID, &decision.DecisionKey, + &decision.ClaimKey, &decision.Action, &decision.Reason, &scoreJSON, + &decision.CompetingClaimKey, &projectionKind, &projectionRowID, &createdAt, + ) + if err != nil { + return personfacts.Decision{}, err + } + if err := json.Unmarshal([]byte(scoreJSON), &decision.Score); err != nil { + return personfacts.Decision{}, fmt.Errorf("decode score: %w", err) + } + if projectionKind.Valid && projectionRowID.Valid { + decision.Projection = &personfacts.ProjectionRef{Kind: projectionKind.String, RowID: projectionRowID.Int64} + } + decision.CreatedAt = createdAt.Time + return decision, nil +} + +func (s *Store) loadPersonFactGenerationRowTx( + ctx context.Context, tx *loggedTx, personID int64, generationKey string, +) (personfacts.Generation, error) { + return scanPersonFactGeneration(tx.QueryRowContext(ctx, ` + SELECT id, person_id, generation_key, CAST(source_cursors_json AS TEXT), + program_id, program_version, program_fingerprint, catalog_fingerprint, + provider, provider_version, model, model_version, + provider_policy_fingerprint, resolved_at, created_at + FROM person_fact_generations WHERE person_id = ? AND generation_key = ?`, + personID, generationKey)) +} + +func (s *Store) loadPersonFactGenerationByIDTx( + ctx context.Context, tx *loggedTx, generationID int64, +) (personfacts.Generation, error) { + generation, err := scanPersonFactGeneration(tx.QueryRowContext(ctx, ` + SELECT id, person_id, generation_key, CAST(source_cursors_json AS TEXT), + program_id, program_version, program_fingerprint, catalog_fingerprint, + provider, provider_version, model, model_version, + provider_policy_fingerprint, resolved_at, created_at + FROM person_fact_generations WHERE id = ?`, generationID)) + if err != nil { + return personfacts.Generation{}, fmt.Errorf("load person fact generation: %w", err) + } + return generation, nil +} + +func scanPersonFactGeneration(row scanner) (personfacts.Generation, error) { + var generation personfacts.Generation + var cursorsJSON string + var resolvedAt, createdAt personFactTimestamp + err := row.Scan( + &generation.ID, &generation.PersonID, &generation.GenerationKey, &cursorsJSON, + &generation.ProgramID, &generation.ProgramVersion, &generation.ProgramFingerprint, + &generation.CatalogFingerprint, &generation.Provider, &generation.ProviderVersion, + &generation.Model, &generation.ModelVersion, &generation.Policy.ProviderPolicyFingerprint, + &resolvedAt, &createdAt, + ) + if err != nil { + return personfacts.Generation{}, err + } + if err := json.Unmarshal([]byte(cursorsJSON), &generation.SourceCursors); err != nil { + return personfacts.Generation{}, fmt.Errorf("decode source cursors: %w", err) + } + if generation.SourceCursors == nil { + generation.SourceCursors = make([]personfacts.SourceCursor, 0) + } + // AllowSensitive is execution input bound by GenerationKey, not durable + // consent state. The ledger intentionally hydrates only the stored policy + // fingerprint; callers must not interpret the zero bool as reconstructed + // consent posture. + generation.ResolvedAt = resolvedAt.Time + generation.CreatedAt = createdAt.Time + return generation, nil +} + +func (s *Store) loadPersonFactEvidenceRowTx( + ctx context.Context, tx *loggedTx, personID int64, evidenceKey string, +) (personfacts.Evidence, error) { + evidence, err := scanPersonFactEvidence(tx.QueryRowContext(ctx, + `SELECT `+personFactEvidenceColumns+` FROM person_fact_evidence e + WHERE e.person_id = ? AND e.evidence_key = ?`, personID, evidenceKey)) + if err != nil { + return personfacts.Evidence{}, fmt.Errorf("load person fact evidence: %w", err) + } + return evidence, nil +} + +func (s *Store) loadPersonFactClaimRowTx( + ctx context.Context, tx *loggedTx, generation personfacts.Generation, claimKey string, +) (personfacts.Claim, error) { + claim, err := scanPersonFactClaim(tx.QueryRowContext(ctx, + `SELECT `+personFactClaimColumns+` FROM person_fact_claims c + WHERE c.person_id = ? AND c.claim_key = ?`, generation.PersonID, claimKey)) + if err != nil { + return personfacts.Claim{}, fmt.Errorf("load person fact claim: %w", err) + } + claim.Generation = generation + return claim, nil +} + +func loadPersonFactStatusEventByScopedKeyTx( + ctx context.Context, + tx *loggedTx, + generation personfacts.Generation, + evidenceKey, sourceVersion string, + reason personfacts.EvidenceStatusReason, +) (personfacts.EvidenceStatusEvent, error) { + event, err := scanPersonFactStatusEvent(tx.QueryRowContext(ctx, ` + SELECT id, person_id, generation_id, evidence_id, evidence_key, + source_version, supported, reason, created_at + FROM person_fact_evidence_status_events + WHERE person_id = ? AND generation_id = ? AND evidence_key = ? + AND source_version = ? AND reason = ?`, generation.PersonID, generation.ID, + evidenceKey, sourceVersion, reason)) + if err != nil { + return personfacts.EvidenceStatusEvent{}, fmt.Errorf("load person fact evidence status: %w", err) + } + return event, nil +} + +func (s *Store) loadPersonFactResolutionShellTx( + ctx context.Context, + tx *loggedTx, + generationID int64, + target personfacts.TargetRef, + resolverVersion, inputFingerprint string, +) (personfacts.ResolutionResult, string, error) { + var result personfacts.ResolutionResult + var policy string + var resolvedAt personFactTimestamp + err := tx.QueryRowContext(ctx, ` + SELECT id, target_kind, target_key, target_revision, resolver_version, + input_fingerprint, provider_policy_fingerprint, resolved_at + FROM person_fact_resolutions + WHERE generation_id = ? AND target_kind = ? AND target_key = ? + AND resolver_version = ? AND input_fingerprint = ?`, + generationID, target.Kind, target.Key, resolverVersion, inputFingerprint, + ).Scan(&result.ID, &result.Target.Kind, &result.Target.Key, &result.Target.Revision, + &result.ResolverVersion, &result.InputFingerprint, &policy, &resolvedAt) + if err != nil { + return personfacts.ResolutionResult{}, "", fmt.Errorf("load person fact resolution: %w", err) + } + result.ResolvedAt = resolvedAt.Time + result.Decisions = make([]personfacts.Decision, 0) + result.Projections = make([]personfacts.ProjectionRef, 0) + return result, policy, nil +} + +func (s *Store) listPersonFactGenerationResolutionsTx( + ctx context.Context, tx *loggedTx, generationID int64, +) ([]personfacts.ResolutionResult, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT id, target_kind, target_key, target_revision, resolver_version, + input_fingerprint, resolved_at + FROM person_fact_resolutions WHERE generation_id = ? + ORDER BY target_kind, target_key, target_revision, id`, generationID) + if err != nil { + return nil, fmt.Errorf("list person fact resolutions: %w", err) + } + defer func() { _ = rows.Close() }() + results := make([]personfacts.ResolutionResult, 0) + for rows.Next() { + var result personfacts.ResolutionResult + var resolvedAt personFactTimestamp + if err := rows.Scan(&result.ID, &result.Target.Kind, &result.Target.Key, + &result.Target.Revision, &result.ResolverVersion, &result.InputFingerprint, + &resolvedAt); err != nil { + return nil, fmt.Errorf("scan person fact resolution: %w", err) + } + result.ResolvedAt = resolvedAt.Time + results = append(results, result) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact resolutions: %w", err) + } + for index := range results { + results[index], err = s.hydratePersonFactResolutionTx(ctx, tx, results[index]) + if err != nil { + return nil, err + } + } + return results, nil +} + +func (s *Store) hydratePersonFactResolutionTx( + ctx context.Context, tx *loggedTx, result personfacts.ResolutionResult, +) (personfacts.ResolutionResult, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT `+personFactDecisionColumns+` + FROM person_fact_decisions d + LEFT JOIN person_fact_claims c ON c.id = d.claim_id + LEFT JOIN person_fact_claims competing ON competing.id = d.competing_claim_id + WHERE d.resolution_id = ? + ORDER BY COALESCE(c.claim_key, ''), d.action, d.id`, result.ID) + if err != nil { + return personfacts.ResolutionResult{}, fmt.Errorf("hydrate person fact resolution decisions: %w", err) + } + defer func() { _ = rows.Close() }() + result.Decisions = make([]personfacts.Decision, 0) + projectionSet := make(map[personfacts.ProjectionRef]struct{}) + for rows.Next() { + decision, scanErr := scanPersonFactDecision(rows) + if scanErr != nil { + return personfacts.ResolutionResult{}, fmt.Errorf("scan person fact resolution decision: %w", scanErr) + } + result.Decisions = append(result.Decisions, decision) + if decision.Projection != nil { + projectionSet[*decision.Projection] = struct{}{} + } + } + if err := rows.Err(); err != nil { + return personfacts.ResolutionResult{}, fmt.Errorf("iterate person fact resolution decisions: %w", err) + } + result.Projections = make([]personfacts.ProjectionRef, 0, len(projectionSet)) + for projection := range projectionSet { + result.Projections = append(result.Projections, projection) + } + sort.Slice(result.Projections, func(i, j int) bool { + if result.Projections[i].Kind != result.Projections[j].Kind { + return result.Projections[i].Kind < result.Projections[j].Kind + } + return result.Projections[i].RowID < result.Projections[j].RowID + }) + return result, nil +} + +func loadPersonFactDecisionByKeyTx( + ctx context.Context, tx *loggedTx, personID int64, decisionKey string, +) (personfacts.Decision, error) { + decision, err := scanPersonFactDecision(tx.QueryRowContext(ctx, ` + SELECT `+personFactDecisionColumns+` + FROM person_fact_decisions d + LEFT JOIN person_fact_claims c ON c.id = d.claim_id + LEFT JOIN person_fact_claims competing ON competing.id = d.competing_claim_id + WHERE d.person_id = ? AND d.decision_key = ?`, personID, decisionKey)) + if err != nil { + return personfacts.Decision{}, fmt.Errorf("load person fact decision: %w", err) + } + return decision, nil +} + +func loadPersonFactClaimIDByKeyTx( + ctx context.Context, tx *loggedTx, personID int64, claimKey string, +) (int64, error) { + var id int64 + err := tx.QueryRowContext(ctx, + `SELECT id FROM person_fact_claims WHERE person_id = ? AND claim_key = ?`, + personID, claimKey).Scan(&id) + return id, err +} + +func loadPersonFactClaimEvidenceIDsTx( + ctx context.Context, tx *loggedTx, claimID int64, +) ([]int64, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT evidence_id FROM person_fact_claim_evidence + WHERE claim_id = ? ORDER BY evidence_id`, claimID) + if err != nil { + return nil, fmt.Errorf("load person fact claim evidence: %w", err) + } + defer func() { _ = rows.Close() }() + ids := make([]int64, 0) + for rows.Next() { + var id int64 + if err := rows.Scan(&id); err != nil { + return nil, fmt.Errorf("scan person fact claim evidence: %w", err) + } + ids = append(ids, id) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact claim evidence: %w", err) + } + return ids, nil +} + +func personFactGenerationMatches( + stored personfacts.Generation, input personfacts.GenerationInput, generationKey string, +) bool { + return stored.PersonID == input.PersonID && stored.GenerationKey == generationKey && + reflect.DeepEqual(stored.SourceCursors, input.SourceCursors) && + stored.ProgramID == input.ProgramID && stored.ProgramVersion == input.ProgramVersion && + stored.ProgramFingerprint == input.ProgramFingerprint && + stored.CatalogFingerprint == input.CatalogFingerprint && + stored.Provider == input.Provider && stored.ProviderVersion == input.ProviderVersion && + stored.Model == input.Model && stored.ModelVersion == input.ModelVersion && + stored.Policy.ProviderPolicyFingerprint == input.Policy.ProviderPolicyFingerprint +} + +func personFactClaimMatches( + stored personfacts.Claim, + prepared personfacts.PreparedClaim, + generation personfacts.Generation, + claimKey string, + evidenceIDs []int64, +) bool { + if stored.PersonID != generation.PersonID || stored.GenerationID != generation.ID || + stored.ClaimKey != claimKey || stored.Target != (personfacts.TargetRef{ + Kind: prepared.Target.Kind, Key: prepared.Target.Key, Revision: prepared.Target.Revision, + }) || stored.Relation != prepared.Relation || stored.Origin != prepared.Origin || + !personFactRawJSONEqual(stored.SubmittedValue, prepared.SubmittedValue) || + !personFactNormalizedEqual(stored.Normalized, prepared.Normalized) || + !personFactTimePointerEqual(stored.ValidFrom, prepared.ValidFrom) || + !personFactTimePointerEqual(stored.ValidUntil, prepared.ValidUntil) || + stored.Confidence != prepared.Confidence || + !reflect.DeepEqual(stored.Failure, prepared.Failure) { + return false + } + if len(stored.EvidenceIDs) > 0 && !reflect.DeepEqual(stored.EvidenceIDs, evidenceIDs) { + return false + } + return true +} + +func personFactEvidenceInputMatches(left, right personfacts.EvidenceInput) bool { + return left.PersonID == right.PersonID && left.SourceClass == right.SourceClass && + left.Directness == right.Directness && left.Authority == right.Authority && + left.SourceRef == right.SourceRef && left.SourceURL == right.SourceURL && + personFactInt64PointerEqual(left.SubjectPersonID, right.SubjectPersonID) && + left.SubjectRef == right.SubjectRef && + personFactInt64PointerEqual(left.SpanStart, right.SpanStart) && + personFactInt64PointerEqual(left.SpanEnd, right.SpanEnd) && + left.Excerpt == right.Excerpt && left.ContentSHA256 == right.ContentSHA256 && + left.SourceVersion == right.SourceVersion && + personFactPortableTime(left.EventTime).Equal(personFactPortableTime(right.EventTime)) && + personFactPortableTime(left.RecordedTime).Equal(personFactPortableTime(right.RecordedTime)) && + left.IdentityScore == right.IdentityScore +} + +func personFactDecisionMatches(left, right personfacts.Decision) bool { + return left.PersonID == right.PersonID && left.ResolutionID == right.ResolutionID && + left.DecisionKey == right.DecisionKey && left.ClaimKey == right.ClaimKey && + left.Action == right.Action && left.Reason == right.Reason && + left.Score == right.Score && left.CompetingClaimKey == right.CompetingClaimKey && + reflect.DeepEqual(left.Projection, right.Projection) +} + +func personFactRawJSONEqual(left, right []byte) bool { + if json.Valid(left) && json.Valid(right) { + return equalJSON(left, right) + } + return bytes.Equal(left, right) +} + +func personFactNormalizedEqual(left, right *personfacts.NormalizedValue) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return left.Fingerprint == right.Fingerprint && personFactRawJSONEqual(left.JSON, right.JSON) +} + +func personFactPage(limit, offset int) (int, int, error) { + if limit == 0 { + limit = 50 + } + if limit < 1 || limit > 200 { + return 0, 0, errors.New("person fact page limit must be between 1 and 200") + } + if offset < 0 { + return 0, 0, errors.New("person fact page offset must not be negative") + } + return limit, offset, nil +} + +func personFactCollision(kind, key string) error { + return fmt.Errorf("%w: %s %q", ErrPersonFactKeyCollision, kind, key) +} + +func personFactOptionalInt64(value *int64) any { + if value == nil { + return nil + } + return *value +} + +func personFactOptionalTime(value *time.Time) any { + if value == nil { + return nil + } + return *value +} + +func personFactNullInt64(value sql.NullInt64) *int64 { + if !value.Valid { + return nil + } + result := value.Int64 + return &result +} + +func personFactInt64PointerEqual(left, right *int64) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return *left == *right +} + +func personFactTimePointerEqual(left, right *time.Time) bool { + if left == nil || right == nil { + return left == nil && right == nil + } + return personFactPortableTime(*left).Equal(personFactPortableTime(*right)) +} + +func personFactPortableTime(input time.Time) time.Time { + return input.UTC().Truncate(time.Microsecond) +} + +func personFactPortableTimePointer(input *time.Time) *time.Time { + if input == nil { + return nil + } + value := personFactPortableTime(*input) + return &value +} + +type personFactTimestamp struct{ Time time.Time } + +func (value *personFactTimestamp) Scan(source any) error { + var scanned nullableTimestamp + if err := scanned.Scan(source); err != nil { + return err + } + if !scanned.Valid { + return errors.New("person fact timestamp is null or invalid") + } + value.Time = scanned.Time.UTC() + return nil +} diff --git a/internal/store/person_fact_ledger_test.go b/internal/store/person_fact_ledger_test.go new file mode 100644 index 000000000..705ed72c2 --- /dev/null +++ b/internal/store/person_fact_ledger_test.go @@ -0,0 +1,749 @@ +package store + +import ( + "encoding/json" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +var personFactLedgerNow = time.Date(2026, time.August, 22, 12, 0, 0, 0, time.UTC) + +func TestPersonFactGenerationEnvelopeOwnsMultipleClaims(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "multi", []personfacts.ProposedClaim{ + personFactLedgerClaim(personID, "favorite-color", `"blue"`, "color"), + personFactLedgerClaim(personID, "favorite-food", `"ramen"`, "food"), + }, nil) + + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + assert.Len(t, stored.Claims, 2) + for _, claim := range stored.Claims { + assert.Equal(t, stored.Generation.ID, claim.GenerationID) + assert.Equal(t, stored.Generation.ID, claim.Generation.ID) + } +} + +func TestPersonFactGenerationPersistsProgramFingerprint(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "fingerprint", nil, nil) + + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + assert.Equal(t, strings.Repeat("a", 64), stored.Generation.ProgramFingerprint) +} + +func TestPersonFactGenerationPortableTimestampReplayIsExact(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + claim := personFactLedgerClaim(personID, "portable-time", `"value"`, "portable-time") + claim.Evidence[0].EventTime = time.Date(2025, time.March, 1, 2, 3, 4, 123456789, time.UTC) + claim.Evidence[0].RecordedTime = time.Date(2025, time.March, 2, 3, 4, 5, 234567891, time.UTC) + validFrom := time.Date(2024, time.March, 1, 0, 0, 0, 345678912, time.UTC) + validUntil := time.Date(2026, time.March, 1, 0, 0, 0, 456789123, time.UTC) + claim.ValidFrom, claim.ValidUntil = &validFrom, &validUntil + input := personFactLedgerGenerationInput(personID, "portable-time", + []personfacts.ProposedClaim{claim}, nil) + input.ResolvedAt = time.Date(2025, time.March, 3, 4, 5, 6, 567891234, time.UTC) + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + first := persistPersonFactLedgerGeneration(t, st, prepared, nil) + + replayInput := prepared.Input() + replayInput.ResolvedAt = replayInput.ResolvedAt.Add(10 * time.Nanosecond) + replayInput.Claims[0].Evidence[0].EventTime = replayInput.Claims[0].Evidence[0].EventTime.Add(10 * time.Nanosecond) + replayInput.Claims[0].Evidence[0].RecordedTime = replayInput.Claims[0].Evidence[0].RecordedTime.Add(10 * time.Nanosecond) + *replayInput.Claims[0].ValidFrom = replayInput.Claims[0].ValidFrom.Add(10 * time.Nanosecond) + *replayInput.Claims[0].ValidUntil = replayInput.Claims[0].ValidUntil.Add(10 * time.Nanosecond) + replayed, err := personfacts.PreparePersonFactGeneration(t.Context(), replayInput, nil) + require.NoError(err) + assert.Equal(prepared.GenerationKey(), replayed.GenerationKey()) + second := persistPersonFactLedgerGeneration(t, st, replayed, nil) + + assert.Equal(first, second) + assert.Equal(input.ResolvedAt.Truncate(time.Microsecond), second.Generation.ResolvedAt) + require.Len(second.Claims, 1) + assert.Equal(validFrom.Truncate(time.Microsecond), *second.Claims[0].ValidFrom) + assert.Equal(validUntil.Truncate(time.Microsecond), *second.Claims[0].ValidUntil) + require.Len(second.Evidence, 1) + assert.Equal(claim.Evidence[0].EventTime.Truncate(time.Microsecond), second.Evidence[0].Input.EventTime) + assert.Equal(claim.Evidence[0].RecordedTime.Truncate(time.Microsecond), second.Evidence[0].Input.RecordedTime) +} + +func TestPersonFactLedgerStoresInvalidSubmittedValueWithNullNormalizedValue(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + claim := personFactLedgerClaim(personID, "favorite-number", `{"broken"`, "invalid") + claim.Target.ValueType = personfacts.ValueInteger + prepared := preparePersonFactLedgerGeneration(t, personID, "invalid", []personfacts.ProposedClaim{claim}, nil) + + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + require.Len(stored.Claims, 1) + assert.Equal(`{"broken"`, string(stored.Claims[0].SubmittedValue)) + assert.Nil(stored.Claims[0].Normalized) + + var normalized any + require.NoError(st.db.QueryRowContext(t.Context(), + `SELECT normalized_value_json FROM person_fact_claims WHERE id = ?`, + stored.Claims[0].ID).Scan(&normalized)) + assert.Nil(normalized) +} + +func TestPersonFactLedgerRejectsDuplicateEnvelopeBeforePersistence(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + baseClaim := personFactLedgerClaim(personID, "duplicate", `"value"`, "duplicate") + duplicateEvidenceClaim := baseClaim + duplicateEvidenceClaim.Evidence = append(duplicateEvidenceClaim.Evidence, + duplicateEvidenceClaim.Evidence[0]) + + for _, test := range []struct { + name string + claims []personfacts.ProposedClaim + }{ + {name: "evidence key", claims: []personfacts.ProposedClaim{duplicateEvidenceClaim}}, + {name: "canonical claim", claims: []personfacts.ProposedClaim{baseClaim, baseClaim}}, + } { + t.Run(test.name, func(t *testing.T) { + _, err := personfacts.PreparePersonFactGeneration(t.Context(), + personFactLedgerGenerationInput(personID, "duplicate-"+test.name, test.claims, nil), nil) + require.Error(t, err) + }) + } + + for _, table := range []string{ + "person_fact_generations", "person_fact_claims", "person_fact_evidence", + } { + var count int + require.NoError(t, st.db.QueryRowContext(t.Context(), + `SELECT COUNT(*) FROM `+table+` WHERE person_id = ?`, personID).Scan(&count)) + assert.Zero(t, count, table) + } +} + +func TestPersonFactLedgerRejectsCanonicalKeyCollision(t *testing.T) { + t.Run("generation", func(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "generation-collision", + []personfacts.ProposedClaim{personFactLedgerClaim(personID, "favorite-color", `"blue"`, "generation")}, nil) + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + _, err := st.db.ExecContext(t.Context(), + `UPDATE person_fact_generations SET program_version = 'tampered' WHERE id = ?`, + stored.Generation.ID) + require.NoError(t, err) + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + _, _, insertErr := st.insertPersonFactGenerationTx(t.Context(), tx, prepared) + return insertErr + }) + assert.ErrorIs(t, err, ErrPersonFactKeyCollision) + }) + + for _, test := range []struct { + name string + column string + value string + }{ + {name: "evidence", column: "excerpt", value: "tampered"}, + {name: "claim", column: "origin", value: "system"}, + } { + t.Run(test.name, func(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, test.name+"-collision", + []personfacts.ProposedClaim{personFactLedgerClaim(personID, "favorite-color", `"blue"`, test.name)}, nil) + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + table := "person_fact_claims" + id := stored.Claims[0].ID + if test.name == "evidence" { + table = "person_fact_evidence" + id = stored.Claims[0].EvidenceIDs[0] + } + _, err := st.db.ExecContext(t.Context(), + `UPDATE `+table+` SET `+test.column+` = ? WHERE id = ?`, test.value, id) + require.NoError(t, err) + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + generation, _, insertErr := st.insertPersonFactGenerationTx(t.Context(), tx, prepared) + if insertErr != nil { + return insertErr + } + _, insertErr = st.insertPersonFactClaimsTx(t.Context(), tx, generation, prepared.Claims()) + return insertErr + }) + assert.ErrorIs(t, err, ErrPersonFactKeyCollision) + }) + } + + for _, test := range []struct { + name string + table string + column string + value string + }{ + {name: "resolution", table: "person_fact_resolutions", column: "provider_policy_fingerprint", value: "tampered"}, + {name: "decision", table: "person_fact_decisions", column: "reason", value: string(personfacts.ReasonInsufficientMargin)}, + } { + t.Run(test.name, func(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, test.name+"-collision", + []personfacts.ProposedClaim{personFactLedgerClaim(personID, "favorite-color", `"blue"`, test.name)}, nil) + claimKey, err := personfacts.ClaimKey(prepared.GenerationKey(), prepared.Claims()[0]) + require.NoError(t, err) + resolution := personfacts.Resolution{ + Target: personfacts.TargetRef{Kind: personfacts.TargetAttribute, Key: "favorite-color", Revision: "revision-1"}, + ResolverVersion: personfacts.ResolverVersionV1, InputFingerprint: strings.Repeat("d", 64), + ResolvedAt: personFactLedgerNow, + Decisions: []personfacts.Decision{{ + ClaimKey: claimKey, Action: personfacts.DecisionRetained, + Reason: personfacts.ReasonBelowThreshold, + }}, + } + stored := persistPersonFactLedgerGeneration(t, st, prepared, []personfacts.Resolution{resolution}) + id := stored.Resolutions[0].ID + if test.name == "decision" { + id = stored.Resolutions[0].Decisions[0].ID + } + _, err = st.db.ExecContext(t.Context(), + `UPDATE `+test.table+` SET `+test.column+` = ? WHERE id = ?`, test.value, id) + require.NoError(t, err) + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + generation, _, insertErr := st.insertPersonFactGenerationTx(t.Context(), tx, prepared) + if insertErr != nil { + return insertErr + } + _, insertErr = st.insertPersonFactResolutionTx(t.Context(), tx, generation, + resolution, prepared.Input().Policy.ProviderPolicyFingerprint) + return insertErr + }) + assert.ErrorIs(t, err, ErrPersonFactKeyCollision) + }) + } +} + +func TestPersonFactLedgerReplayHydratesWholeGeneration(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "replay", []personfacts.ProposedClaim{ + personFactLedgerClaim(personID, "favorite-color", `"blue"`, "first"), + personFactLedgerClaim(personID, "favorite-food", `"ramen"`, "second"), + }, nil) + first := persistPersonFactLedgerGeneration(t, st, prepared, nil) + require.Len(first.Evidence, 2) + statusEvidenceKey := prepared.Claims()[0].EvidenceKeys[0] + persistPersonFactLedgerGeneration(t, st, + preparePersonFactLedgerGeneration(t, personID, "replay-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: statusEvidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceEdited, + }}), nil) + replayInput := prepared.Input() + replayInput.ResolvedAt = replayInput.ResolvedAt.Add(24 * time.Hour) + replayed, err := personfacts.PreparePersonFactGeneration(t.Context(), replayInput, nil) + require.NoError(err) + require.Equal(prepared.GenerationKey(), replayed.GenerationKey()) + second := persistPersonFactLedgerGeneration(t, st, replayed, nil) + + assert.Equal(first.Generation, second.Generation) + assert.Equal(first.Claims, second.Claims) + assert.Equal(first.EvidenceStatusEvents, second.EvidenceStatusEvents) + assert.Equal(first.Resolutions, second.Resolutions) + assert.Equal(personFactLedgerNow, second.Generation.ResolvedAt, + "resolved_at is owned by the first successful writer") + assert.Len(second.Claims, 2) + assert.NotNil(second.EvidenceStatusEvents) + require.Len(second.Evidence, 2) + for _, evidence := range second.Evidence { + if evidence.Key != statusEvidenceKey { + continue + } + assert.False(evidence.Supported) + require.NotNil(evidence.LatestStatus) + assert.Equal(personfacts.EvidenceStatusSourceEdited, evidence.LatestStatus.Reason) + return + } + assert.Fail("generation replay did not hydrate the status-bearing evidence") +} + +func TestPersonFactLedgerReplayHydratesDurableResolutionResults(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "resolution", []personfacts.ProposedClaim{ + personFactLedgerClaim(personID, "favorite-color", `"blue"`, "resolution-color"), + personFactLedgerClaim(personID, "favorite-food", `"ramen"`, "resolution-food"), + }, nil) + colorClaimKey, err := personfacts.ClaimKey(prepared.GenerationKey(), prepared.Claims()[0]) + require.NoError(err) + foodClaimKey, err := personfacts.ClaimKey(prepared.GenerationKey(), prepared.Claims()[1]) + require.NoError(err) + projectionLast := personfacts.ProjectionRef{Kind: "person_employment", RowID: 42} + projectionFirst := personfacts.ProjectionRef{Kind: "person_attribute", RowID: 84} + transientProjection := personfacts.ProjectionRef{Kind: "transient-plan-only", RowID: 126} + resolvedAt := time.Date(2025, time.February, 3, 4, 5, 6, 123456789, time.UTC) + resolution := personfacts.Resolution{ + Target: personfacts.TargetRef{Kind: personfacts.TargetAttribute, Key: "favorite-color", Revision: "revision-1"}, + ResolverVersion: personfacts.ResolverVersionV1, + InputFingerprint: strings.Repeat("b", 64), + ResolvedAt: resolvedAt, + Decisions: []personfacts.Decision{ + {ClaimKey: colorClaimKey, Action: personfacts.DecisionApplied, + Reason: personfacts.ReasonAppliedProjection, + Score: personfacts.ScoreBreakdown{Total: 900}, Projection: &projectionLast}, + {ClaimKey: foodClaimKey, Action: personfacts.DecisionRetained, + Reason: personfacts.ReasonPinRetained, + Score: personfacts.ScoreBreakdown{Total: 850}, Projection: &projectionFirst}, + {ClaimKey: colorClaimKey, Action: personfacts.DecisionRetained, + Reason: personfacts.ReasonBelowThreshold, + Score: personfacts.ScoreBreakdown{Total: 700}, Projection: &projectionFirst}, + }, + Projections: []personfacts.ProjectionPlan{{ + Operation: personfacts.ProjectionSet, Target: personfacts.TargetRef{ + Kind: personfacts.TargetAttribute, Key: "transient", Revision: "revision-1", + }, + ClaimKey: colorClaimKey, CurrentRef: &transientProjection, ActiveFrom: resolvedAt, + }}, + } + + first := persistPersonFactLedgerGeneration(t, st, prepared, []personfacts.Resolution{resolution}) + second := persistPersonFactLedgerGeneration(t, st, prepared, nil) + require.Len(first.Resolutions, 1) + assert.Equal(first, second) + assert.Equal(resolvedAt.Truncate(time.Microsecond), second.Resolutions[0].ResolvedAt) + assert.Equal([]personfacts.ProjectionRef{projectionFirst, projectionLast}, + second.Resolutions[0].Projections) + assert.NotContains(second.Resolutions[0].Projections, transientProjection) + assert.Len(second.Resolutions[0].Decisions, 3) + + replayResolution := resolution + replayResolution.ResolvedAt = replayResolution.ResolvedAt.Add(10 * time.Nanosecond) + var replayed personfacts.ResolutionResult + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + var insertErr error + replayed, insertErr = st.insertPersonFactResolutionTx(t.Context(), tx, first.Generation, + replayResolution, prepared.Input().Policy.ProviderPolicyFingerprint) + return insertErr + }) + require.NoError(err) + assert.Equal(second.Resolutions[0], replayed) +} + +func TestPersonFactEvidenceStatusFalseLatestWins(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, st, personID, "delete") + statuses := []personfacts.EvidenceStatusChange{ + {EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted}, + {EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: true, + Reason: personfacts.EvidenceStatusSourceReimported}, + {EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceEdited}, + } + for index, status := range statuses { + persistPersonFactLedgerGeneration(t, st, + preparePersonFactLedgerGeneration(t, personID, "delete-status-"+string(rune('a'+index)), nil, + []personfacts.EvidenceStatusChange{status}), nil) + } + + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, personfacts.EvidenceFilter{}) + require.NoError(err) + require.Len(evidence, 1) + assert.False(evidence[0].Supported) + require.NotNil(evidence[0].LatestStatus) + assert.Equal(personfacts.EvidenceStatusSourceEdited, evidence[0].LatestStatus.Reason) +} + +func TestPersonFactEvidenceStatusTrueReactivates(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, st, personID, "reactivate") + statuses := []struct { + suffix string + supported bool + reason personfacts.EvidenceStatusReason + }{ + {suffix: "off", supported: false, reason: personfacts.EvidenceStatusScopeUnlinked}, + {suffix: "on", supported: true, reason: personfacts.EvidenceStatusScopeRelinked}, + } + for _, status := range statuses { + persistPersonFactLedgerGeneration(t, st, + preparePersonFactLedgerGeneration(t, personID, "reactivate-"+status.suffix, nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", + Supported: status.supported, Reason: status.reason, + }}), nil) + } + + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, personfacts.EvidenceFilter{}) + require.NoError(err) + require.Len(evidence, 1) + assert.True(evidence[0].Supported) + require.NotNil(evidence[0].LatestStatus) + assert.Equal(personfacts.EvidenceStatusScopeRelinked, evidence[0].LatestStatus.Reason) +} + +func TestPersonFactEvidenceStatusReasonsCoverDeleteEditUnlinkAndIdentityReassignment(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, st, personID, "reasons") + reasons := []personfacts.EvidenceStatusReason{ + personfacts.EvidenceStatusSourceDeleted, + personfacts.EvidenceStatusSourceEdited, + personfacts.EvidenceStatusScopeUnlinked, + personfacts.EvidenceStatusIdentityReassigned, + } + for index, reason := range reasons { + persistPersonFactLedgerGeneration(t, st, + preparePersonFactLedgerGeneration(t, personID, "reason-"+string(rune('a'+index)), nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", + Supported: false, Reason: reason, + }}), nil) + } + + events, err := st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID, + personfacts.EvidenceStatusFilter{EvidenceKey: evidenceKey}) + require.NoError(t, err) + got := make([]personfacts.EvidenceStatusReason, 0, len(events)) + for _, event := range events { + got = append(got, event.Reason) + } + assert.ElementsMatch(t, reasons, got) +} + +func TestPersonFactEvidenceStatusReplayIsIdempotent(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, st, personID, "status-replay") + prepared := preparePersonFactLedgerGeneration(t, personID, "status-replay-generation", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceEdited, + }}) + first := persistPersonFactLedgerGeneration(t, st, prepared, nil) + second := persistPersonFactLedgerGeneration(t, st, prepared, nil) + assert.Equal(t, first, second) + assert.Len(t, second.EvidenceStatusEvents, 1) +} + +func TestPersonFactEvidenceStatusRejectsCanonicalKeyCollision(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, st, personID, "status-collision") + prepared := preparePersonFactLedgerGeneration(t, personID, "status-collision-generation", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + require.Len(t, stored.EvidenceStatusEvents, 1) + _, err := st.db.ExecContext(t.Context(), + `UPDATE person_fact_evidence_status_events SET supported = TRUE WHERE id = ?`, + stored.EvidenceStatusEvents[0].ID) + require.NoError(t, err) + + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + generation, _, insertErr := st.insertPersonFactGenerationTx(t.Context(), tx, prepared) + if insertErr != nil { + return insertErr + } + insertErr = st.insertPersonFactEvidenceStatusEventsTx(t.Context(), tx, generation, + prepared.EvidenceStatusChanges()) + return insertErr + }) + assert.ErrorIs(t, err, ErrPersonFactKeyCollision) +} + +func TestPersonFactLedgerPersonDeletionCascadesAllRows(t *testing.T) { + require := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + prepared := preparePersonFactLedgerGeneration(t, personID, "cascade", []personfacts.ProposedClaim{ + personFactLedgerClaim(personID, "favorite-color", `"blue"`, "cascade"), + }, nil) + claimKey, err := personfacts.ClaimKey(prepared.GenerationKey(), prepared.Claims()[0]) + require.NoError(err) + resolution := personfacts.Resolution{ + Target: personfacts.TargetRef{Kind: personfacts.TargetAttribute, Key: "favorite-color", Revision: "revision-1"}, + ResolverVersion: personfacts.ResolverVersionV1, InputFingerprint: strings.Repeat("c", 64), + ResolvedAt: personFactLedgerNow, + Decisions: []personfacts.Decision{{ + ClaimKey: claimKey, Action: personfacts.DecisionRetained, + Reason: personfacts.ReasonBelowThreshold, + }}, + } + stored := persistPersonFactLedgerGeneration(t, st, prepared, []personfacts.Resolution{resolution}) + evidenceKey := stored.Claims[0].EvidenceIDs[0] + _ = evidenceKey + statusPrepared := preparePersonFactLedgerGeneration(t, personID, "cascade-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: prepared.Claims()[0].EvidenceKeys[0], SourceVersion: "source-v1", + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + persistPersonFactLedgerGeneration(t, st, statusPrepared, nil) + require.NoError(st.withTxContext(t.Context(), func(tx *loggedTx) error { + _, insertErr := tx.ExecContext(t.Context(), ` + INSERT INTO person_fact_pin_events + (person_id, target_kind, target_key, target_revision, pinned, actor) + VALUES (?, 'attribute', 'favorite-color', 'revision-1', TRUE, 'fixture')`, personID) + return insertErr + })) + + var revision int64 + require.NoError(st.db.QueryRowContext(t.Context(), + `SELECT revision FROM persons WHERE id = ?`, personID).Scan(&revision)) + require.NoError(st.DeletePersonContext(t.Context(), personID, revision)) + for _, table := range []string{ + "person_fact_generations", "person_fact_claims", "person_fact_claim_evidence", + "person_fact_evidence", "person_fact_evidence_status_events", + "person_fact_resolutions", "person_fact_decisions", "person_fact_pin_events", + } { + var count int + require.NoError(st.db.QueryRowContext(t.Context(), + `SELECT COUNT(*) FROM `+table).Scan(&count), table) + assert.Zero(t, count, table) + } +} + +func TestPersonFactLedgerPaginationValidationAndOrdering(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID := newPersonFactLedgerStore(t) + for _, suffix := range []string{"a", "b", "c"} { + prepared := preparePersonFactLedgerGeneration(t, personID, "page-"+suffix, + []personfacts.ProposedClaim{personFactLedgerClaim(personID, "target-"+suffix, `"value"`, suffix)}, nil) + claimKey, err := personfacts.ClaimKey(prepared.GenerationKey(), prepared.Claims()[0]) + requirements.NoError(err) + persistPersonFactLedgerGeneration(t, st, prepared, []personfacts.Resolution{{ + Target: personfacts.TargetRef{Kind: personfacts.TargetAttribute, Key: "target-" + suffix, Revision: "revision-1"}, + ResolverVersion: personfacts.ResolverVersionV1, InputFingerprint: strings.Repeat(suffix, 64), + ResolvedAt: personFactLedgerNow, + Decisions: []personfacts.Decision{{ + ClaimKey: claimKey, Action: personfacts.DecisionRetained, + Reason: personfacts.ReasonBelowThreshold, + }}, + }}) + persistPersonFactLedgerGeneration(t, st, + preparePersonFactLedgerGeneration(t, personID, "page-status-"+suffix, nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: prepared.Claims()[0].EvidenceKeys[0], SourceVersion: "source-v1", + Supported: false, Reason: personfacts.EvidenceStatusSourceEdited, + }}), nil) + } + + tests := []struct { + name string + call func() error + }{ + {name: "evidence limit", call: func() error { + _, err := st.ListPersonFactEvidenceContext(t.Context(), personID, personfacts.EvidenceFilter{Limit: 201}) + return err + }}, + {name: "status negative offset", call: func() error { + _, err := st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID, personfacts.EvidenceStatusFilter{Offset: -1}) + return err + }}, + {name: "claim limit", call: func() error { + _, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{Limit: -1}) + return err + }}, + {name: "decision limit", call: func() error { + _, err := st.ListPersonFactDecisionsContext(t.Context(), personID, personfacts.DecisionFilter{Limit: 500}) + return err + }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + assert.Error(test.call()) + }) + } + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, + personfacts.ClaimFilter{Limit: 2, Offset: 1}) + requirements.NoError(err) + requirements.Len(claims, 2) + assertions.Greater(claims[0].ID, claims[1].ID) + decisions, err := st.ListPersonFactDecisionsContext(t.Context(), personID, + personfacts.DecisionFilter{}) + requirements.NoError(err) + requirements.Len(decisions, 3) + assertions.True(sort.SliceIsSorted(decisions, func(i, j int) bool { return decisions[i].ID > decisions[j].ID })) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{}) + requirements.NoError(err) + requirements.Len(evidence, 3) + assertions.True(sort.SliceIsSorted(evidence, func(i, j int) bool { return evidence[i].ID > evidence[j].ID })) + statuses, err := st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID, + personfacts.EvidenceStatusFilter{}) + requirements.NoError(err) + requirements.Len(statuses, 3) + assertions.True(sort.SliceIsSorted(statuses, func(i, j int) bool { return statuses[i].ID > statuses[j].ID })) + unsupported := false + statuses, err = st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID, + personfacts.EvidenceStatusFilter{Supported: &unsupported}) + requirements.NoError(err) + assertions.Len(statuses, 3) + supported := true + statuses, err = st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID, + personfacts.EvidenceStatusFilter{Supported: &supported}) + requirements.NoError(err) + assertions.NotNil(statuses) + assertions.Empty(statuses) + + target := personfacts.TargetRef{ + Kind: personfacts.TargetAttribute, Key: "target-b", Revision: "revision-1", + } + targetEvidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Target: &target}) + requirements.NoError(err) + assertions.Len(targetEvidence, 1) + targetClaims, err := st.ListPersonFactClaimsContext(t.Context(), personID, + personfacts.ClaimFilter{Target: &target}) + requirements.NoError(err) + assertions.Len(targetClaims, 1) + targetDecisions, err := st.ListPersonFactDecisionsContext(t.Context(), personID, + personfacts.DecisionFilter{Target: &target}) + requirements.NoError(err) + assertions.Len(targetDecisions, 1) + + empty, err := st.ListPersonFactEvidenceStatusEventsContext(t.Context(), personID+999, + personfacts.EvidenceStatusFilter{}) + requirements.NoError(err) + assertions.NotNil(empty) + assertions.Empty(empty) +} + +func newPersonFactLedgerStore(t *testing.T) (*Store, int64) { + t.Helper() + var st *Store + testDB := os.Getenv("MSGVAULT_TEST_DB") + if IsPostgresURL(testDB) { + st = newPGStoreInternal(t, testDB) + } else { + var err error + st, err = OpenForTest(filepath.Join(t.TempDir(), "fact-ledger.db")) + require.NoError(t, err) + t.Cleanup(func() { _ = st.Close() }) + require.NoError(t, st.InitSchema()) + } + participantID, err := st.EnsureParticipant("alice@example.com", "Alice", "example.com") + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + return st, person.ID +} + +func preparePersonFactLedgerGeneration( + t *testing.T, + personID int64, + suffix string, + claims []personfacts.ProposedClaim, + statuses []personfacts.EvidenceStatusChange, +) personfacts.PreparedGeneration { + t.Helper() + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), + personFactLedgerGenerationInput(personID, suffix, claims, statuses), nil) + require.NoError(t, err) + return prepared +} + +func personFactLedgerGenerationInput( + personID int64, + suffix string, + claims []personfacts.ProposedClaim, + statuses []personfacts.EvidenceStatusChange, +) personfacts.GenerationInput { + return personfacts.GenerationInput{ + PersonID: personID, + SourceCursors: []personfacts.SourceCursor{{Lane: "fixture", Start: suffix, End: suffix + "-end"}}, + ProgramID: "fact-ledger-fixture", ProgramVersion: "v1", + ProgramFingerprint: strings.Repeat("a", 64), CatalogFingerprint: "catalog-v1", + Provider: "fixture", ProviderVersion: "v1", Model: "fixture", ModelVersion: "v1", + ResolvedAt: personFactLedgerNow, + Policy: personfacts.PolicyContext{ + ProviderPolicyFingerprint: "policy-v1", + }, + Claims: claims, EvidenceStatusChanges: statuses, + } +} + +func personFactLedgerClaim(personID int64, targetKey, submitted, sourceSuffix string) personfacts.ProposedClaim { + subject := personID + return personfacts.ProposedClaim{ + Target: personfacts.TargetDescriptor{ + Kind: personfacts.TargetAttribute, Key: targetKey, Revision: "revision-1", + UniversalID: targetKey, Slug: targetKey, Description: "Synthetic fact target", + ValueType: personfacts.ValueText, Cardinality: personfacts.CardinalitySingle, + }, + Relation: personfacts.RelationSupport, SubmittedValue: json.RawMessage(submitted), + Evidence: []personfacts.EvidenceInput{{ + PersonID: personID, SourceClass: personfacts.EvidencePublic, + Directness: personfacts.DirectOther, Authority: personfacts.AuthorityAuthoritative, + SourceURL: "https://example.com/" + sourceSuffix, SubjectPersonID: &subject, + SubjectRef: "person-fixture", Excerpt: "Synthetic evidence " + sourceSuffix, + SourceVersion: "source-v1", EventTime: personFactLedgerNow.Add(-time.Hour), + RecordedTime: personFactLedgerNow, IdentityScore: 950, + }}, + Origin: personfacts.OriginExtraction, + Confidence: personfacts.ConfidenceInputs{ReportedScore: 900}, + } +} + +func seedPersonFactEvidence(t *testing.T, st *Store, personID int64, suffix string) string { + t.Helper() + prepared := preparePersonFactLedgerGeneration(t, personID, "evidence-"+suffix, + []personfacts.ProposedClaim{personFactLedgerClaim(personID, "target-"+suffix, `"value"`, suffix)}, nil) + stored := persistPersonFactLedgerGeneration(t, st, prepared, nil) + require.Len(t, stored.Claims, 1) + require.Len(t, stored.Claims[0].EvidenceIDs, 1) + return prepared.Claims()[0].EvidenceKeys[0] +} + +func persistPersonFactLedgerGeneration( + t *testing.T, + st *Store, + prepared personfacts.PreparedGeneration, + resolutions []personfacts.Resolution, +) personFactLedgerGeneration { + t.Helper() + var stored personFactLedgerGeneration + err := st.withTxContext(t.Context(), func(tx *loggedTx) error { + generation, replay, insertErr := st.insertPersonFactGenerationTx(t.Context(), tx, prepared) + if insertErr != nil { + return insertErr + } + if !replay { + if _, insertErr = st.insertPersonFactClaimsTx(t.Context(), tx, generation, prepared.Claims()); insertErr != nil { + return insertErr + } + if insertErr = st.insertPersonFactEvidenceStatusEventsTx( + t.Context(), tx, generation, prepared.EvidenceStatusChanges()); insertErr != nil { + return insertErr + } + for _, resolution := range resolutions { + if _, insertErr = st.insertPersonFactResolutionTx(t.Context(), tx, generation, + resolution, prepared.Input().Policy.ProviderPolicyFingerprint); insertErr != nil { + return insertErr + } + } + } + stored, insertErr = st.loadPersonFactGenerationTx( + t.Context(), tx, generation.PersonID, generation.GenerationKey) + return insertErr + }) + require.NoError(t, err) + return stored +} diff --git a/internal/store/person_fact_organization.go b/internal/store/person_fact_organization.go new file mode 100644 index 000000000..33e9e9269 --- /dev/null +++ b/internal/store/person_fact_organization.go @@ -0,0 +1,637 @@ +package store + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "slices" + "sort" + "strconv" + "strings" + + "go.kenn.io/msgvault/internal/personfacts" +) + +type OrganizationResolutionStatus string + +const ( + OrganizationReused OrganizationResolutionStatus = "reused" + OrganizationCreated OrganizationResolutionStatus = "created" + OrganizationAmbiguous OrganizationResolutionStatus = "ambiguous" +) + +type OrganizationMatch struct { + Organization *Organization + Status OrganizationResolutionStatus + CandidateIDs []int64 + Fingerprint string +} + +type personFactOrganizationLookupKey struct { + Kind string `json:"Kind"` + Value string `json:"Value"` +} + +const maxPersonFactOrganizationRedirects = 64 + +type personFactOrganizationLockSet struct { + discovered map[int64]struct{} + locked map[int64]*Organization + candidates map[string][]int64 +} + +func (s *Store) preparePersonFactOrganizationTx( + ctx context.Context, tx *loggedTx, ref personfacts.OrganizationReference, +) (OrganizationMatch, error) { + ref, keys, err := normalizePersonFactOrganizationReference(ref) + if err != nil { + return OrganizationMatch{}, err + } + lockSet, err := s.lockPersonFactOrganizationReferencesTx( + ctx, tx, []personfacts.OrganizationReference{ref}) + if err != nil { + return OrganizationMatch{}, err + } + return s.prepareLockedPersonFactOrganizationReferenceTx( + ctx, tx, ref, keys, lockSet, true) +} + +func (s *Store) prepareLockedPersonFactOrganizationReferenceTx( + ctx context.Context, tx *loggedTx, ref personfacts.OrganizationReference, + keys []personFactOrganizationLookupKey, lockSet *personFactOrganizationLockSet, + verifyReference bool, +) (OrganizationMatch, error) { + if ref.ID != nil { + referenced, organization, err := lockSet.canonicalOrganization(*ref.ID) + if err != nil { + return OrganizationMatch{}, err + } + if verifyReference { + if err := s.verifyPersonFactOrganizationReferenceTx( + ctx, tx, *referenced, ref); err != nil { + return OrganizationMatch{}, err + } + } + match := OrganizationMatch{ + Organization: organization, + Status: OrganizationReused, + CandidateIDs: []int64{organization.ID}, + } + match.Fingerprint, err = personFactOrganizationFingerprint(ref, keys, match) + return match, err + } + + candidateSetKey, err := personFactOrganizationCandidateSetKey(keys) + if err != nil { + return OrganizationMatch{}, err + } + candidateIDs, exists := lockSet.candidates[candidateSetKey] + if !exists { + return OrganizationMatch{}, errors.New( + "person fact organization candidates were not prepared in the transaction lock set") + } + match := OrganizationMatch{CandidateIDs: append([]int64(nil), candidateIDs...)} + switch len(candidateIDs) { + case 0: + // "created" is the durable projection-context status for a currently + // creatable zero match. No row is written until an accepted projection + // is materialized. + match.Status = OrganizationCreated + case 1: + match.Status = OrganizationReused + match.Organization = lockSet.locked[candidateIDs[0]] + if match.Organization == nil { + return OrganizationMatch{}, errors.New( + "person fact organization candidate changed during locked resolution") + } + default: + match.Status = OrganizationAmbiguous + } + fingerprint, err := personFactOrganizationFingerprint(ref, keys, match) + match.Fingerprint = fingerprint + return match, err +} + +func (s *Store) materializePersonFactOrganizationTx( + ctx context.Context, tx *loggedTx, ref personfacts.OrganizationReference, + prepared OrganizationMatch, +) (*Organization, OrganizationResolutionStatus, error) { + return s.materializeLockedPersonFactOrganizationTx(ctx, tx, ref, prepared, nil) +} + +func (s *Store) materializeLockedPersonFactOrganizationTx( + ctx context.Context, tx *loggedTx, ref personfacts.OrganizationReference, + prepared OrganizationMatch, lockSet *personFactOrganizationLockSet, +) (*Organization, OrganizationResolutionStatus, error) { + ref, keys, err := normalizePersonFactOrganizationReference(ref) + if err != nil { + return nil, "", err + } + expectedFingerprint, err := personFactOrganizationFingerprint(ref, keys, prepared) + if err != nil { + return nil, "", err + } + if prepared.Fingerprint == "" || prepared.Fingerprint != expectedFingerprint { + return nil, "", errors.New("person fact organization match failed integrity verification") + } + if prepared.Status == OrganizationAmbiguous { + return nil, OrganizationAmbiguous, nil + } + if prepared.Status == OrganizationReused { + if len(prepared.CandidateIDs) != 1 { + return nil, "", errors.New("reused person fact organization requires one candidate") + } + if ref.ID != nil { + var referenced, organization *Organization + var resolveErr error + if lockSet != nil { + referenced, organization, resolveErr = lockSet.canonicalOrganization(*ref.ID) + } else { + referenced, organization, resolveErr = s.canonicalPersonFactOrganizationTx( + ctx, tx, *ref.ID) + } + if resolveErr != nil { + return nil, "", resolveErr + } + if verifyErr := s.verifyPersonFactOrganizationReferenceTx( + ctx, tx, *referenced, ref); verifyErr != nil { + return nil, "", verifyErr + } + if organization.ID != prepared.CandidateIDs[0] { + return nil, "", errors.New( + "person fact organization redirect changed after match preparation") + } + return organization, OrganizationReused, nil + } + if lockSet != nil { + organization := lockSet.locked[prepared.CandidateIDs[0]] + if organization == nil { + return nil, "", errors.New( + "person fact organization candidate changed after match preparation") + } + return organization, OrganizationReused, nil + } + organization, loadErr := getOrganizationForUpdateTx( + ctx, tx, s.dialect, prepared.CandidateIDs[0]) + if loadErr != nil { + return nil, "", loadErr + } + return organization, OrganizationReused, nil + } + if prepared.Status != OrganizationCreated || len(prepared.CandidateIDs) != 0 { + return nil, "", errors.New("invalid creatable person fact organization match") + } + + // A prior accepted projection in this transaction may already have + // materialized the same zero match. Requery under the lookup locks before + // inserting so every concurrent writer converges on that row. + candidateIDs, err := s.personFactOrganizationCandidateIDsTx(ctx, tx, keys) + if err != nil { + return nil, "", err + } + switch len(candidateIDs) { + case 1: + var organization *Organization + var loadErr error + if lockSet != nil { + // The lookup-key advisory lock is already retained by the generation + // scope. A row found only now was created earlier by this transaction. + organization, loadErr = getOrganizationTx(ctx, tx, candidateIDs[0]) + } else { + organization, loadErr = getOrganizationForUpdateTx(ctx, tx, s.dialect, candidateIDs[0]) + } + if loadErr != nil { + return nil, "", loadErr + } + return organization, OrganizationReused, nil + case 0: + input, validateErr := validateOrganizationInput(OrganizationInput{ + Name: ref.Name, Kind: OrganizationKindCompany, + }) + if validateErr != nil { + return nil, "", validateErr + } + if ref.Domain != "" { + input.PrimaryDomain = &ref.Domain + } + organization, insertErr := scanOrganization(tx.QueryRowContext(ctx, ` + INSERT INTO organizations ( + name, name_normalized, kind, primary_domain, description + ) VALUES (?, ?, ?, ?, ?) + RETURNING `+organizationColumns, + input.Name, NormalizeOrganizationName(input.Name), input.Kind, + input.PrimaryDomain, input.Description)) + if insertErr != nil { + return nil, "", fmt.Errorf("create person fact organization: %w", insertErr) + } + return organization, OrganizationCreated, nil + default: + return nil, OrganizationAmbiguous, nil + } +} + +func (s *Store) canonicalPersonFactOrganizationTx( + ctx context.Context, tx *loggedTx, id int64, +) (*Organization, *Organization, error) { + lockSet, err := s.lockPersonFactOrganizationChainsTx(ctx, tx, + []personfacts.OrganizationReference{{ID: &id}}) + if err != nil { + return nil, nil, err + } + return lockSet.canonicalOrganization(id) +} + +func (s *Store) lockPersonFactOrganizationReferencesTx( + ctx context.Context, tx *loggedTx, refs []personfacts.OrganizationReference, +) (*personFactOrganizationLockSet, error) { + normalized := make([]personfacts.OrganizationReference, 0, len(refs)) + lookupKeys := make(map[personFactOrganizationLookupKey]struct{}) + hasNonIDReference := false + for _, ref := range refs { + ref, keys, err := normalizePersonFactOrganizationReference(ref) + if err != nil { + if errors.Is(err, ErrOrganizationInvalid) { + continue + } + return nil, err + } + normalized = append(normalized, ref) + hasNonIDReference = hasNonIDReference || ref.ID == nil + for _, key := range keys { + lookupKeys[key] = struct{}{} + } + } + if err := s.lockPersonFactOrganizationTableTx(ctx, tx, hasNonIDReference); err != nil { + return nil, err + } + orderedKeys := make([]personFactOrganizationLookupKey, 0, len(lookupKeys)) + for key := range lookupKeys { + orderedKeys = append(orderedKeys, key) + } + sort.Slice(orderedKeys, func(i, j int) bool { + if orderedKeys[i].Kind != orderedKeys[j].Kind { + return orderedKeys[i].Kind < orderedKeys[j].Kind + } + return orderedKeys[i].Value < orderedKeys[j].Value + }) + for _, key := range orderedKeys { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-organization", key.Kind, key.Value); err != nil { + return nil, err + } + } + return s.lockPersonFactOrganizationChainsTx(ctx, tx, normalized) +} + +func (s *Store) lockPersonFactOrganizationTableForReferencesTx( + ctx context.Context, tx *loggedTx, refs []personfacts.OrganizationReference, +) error { + hasNonIDReference := false + for _, ref := range refs { + normalized, _, err := normalizePersonFactOrganizationReference(ref) + if err != nil { + if errors.Is(err, ErrOrganizationInvalid) { + continue + } + return err + } + hasNonIDReference = hasNonIDReference || normalized.ID == nil + } + return s.lockPersonFactOrganizationTableTx(ctx, tx, hasNonIDReference) +} + +func (s *Store) lockPersonFactOrganizationTableTx( + ctx context.Context, tx *loggedTx, exclusive bool, +) error { + if !s.IsPostgreSQL() { + return nil + } + mode := "ROW SHARE" + if exclusive { + mode = "EXCLUSIVE" + } + if _, err := tx.ExecContext(ctx, "LOCK TABLE organizations IN "+mode+" MODE"); err != nil { + return fmt.Errorf("lock person fact organization table in %s mode: %w", mode, err) + } + return nil +} + +func (s *Store) lockPersonFactOrganizationChainsTx( + ctx context.Context, tx *loggedTx, refs []personfacts.OrganizationReference, +) (*personFactOrganizationLockSet, error) { + // Discover the complete batch before the first row lock. Sorting within one + // redirect chain is insufficient: two generations can reference different + // aliases of the same roots and otherwise acquire those roots in reverse. + discovered := make(map[int64]struct{}) + candidates := make(map[string][]int64) + for _, ref := range refs { + if ref.ID == nil { + _, keys, err := normalizePersonFactOrganizationReference(ref) + if err != nil { + return nil, err + } + candidateIDs, err := s.personFactOrganizationCandidateIDsTx(ctx, tx, keys) + if err != nil { + return nil, err + } + candidateSetKey, err := personFactOrganizationCandidateSetKey(keys) + if err != nil { + return nil, err + } + candidates[candidateSetKey] = candidateIDs + for _, id := range candidateIDs { + discovered[id] = struct{}{} + } + continue + } + ids, err := s.discoverPersonFactOrganizationChainTx(ctx, tx, *ref.ID) + for _, id := range ids { + discovered[id] = struct{}{} + } + // Missing and structurally invalid chains are claim-local outcomes. Keep + // their discovered prefix in the global lock set, then classify each + // reference from the locked snapshot after every valid chain is locked. + if err != nil && !errors.Is(err, ErrOrganizationNotFound) && + !errors.Is(err, ErrOrganizationInvalid) { + return nil, err + } + } + lockIDs := make([]int64, 0, len(discovered)) + for id := range discovered { + lockIDs = append(lockIDs, id) + } + slices.Sort(lockIDs) + locked := make(map[int64]*Organization, len(lockIDs)) + for _, id := range lockIDs { + organization, err := getOrganizationForUpdateTx(ctx, tx, s.dialect, id) + if errors.Is(err, ErrOrganizationNotFound) { + continue + } + if err != nil { + return nil, err + } + locked[id] = organization + } + return &personFactOrganizationLockSet{ + discovered: discovered, locked: locked, candidates: candidates, + }, nil +} + +func (s *Store) discoverPersonFactOrganizationChainTx( + ctx context.Context, tx *loggedTx, id int64, +) ([]int64, error) { + ids := make([]int64, 0, 2) + seen := make(map[int64]struct{}, 2) + currentID := id + for len(ids) < maxPersonFactOrganizationRedirects { + if _, exists := seen[currentID]; exists { + return ids, fmt.Errorf("%w: organization merge redirect cycle at %d", + ErrOrganizationInvalid, currentID) + } + seen[currentID] = struct{}{} + ids = append(ids, currentID) + organization, err := getOrganizationTx(ctx, tx, currentID) + if err != nil { + return ids, err + } + if organization.MergedIntoID == nil { + return ids, nil + } + currentID = *organization.MergedIntoID + } + return ids, fmt.Errorf("%w: organization merge redirect exceeds %d hops", + ErrOrganizationInvalid, maxPersonFactOrganizationRedirects-1) +} + +func (l *personFactOrganizationLockSet) canonicalOrganization( + id int64, +) (*Organization, *Organization, error) { + if l == nil { + return nil, nil, errors.New("person fact organization lock set is required") + } + referenced := l.locked[id] + seen := make(map[int64]struct{}, 2) + currentID := id + for len(seen) < maxPersonFactOrganizationRedirects { + if _, exists := seen[currentID]; exists { + return nil, nil, fmt.Errorf("%w: organization merge redirect cycle at %d", + ErrOrganizationInvalid, currentID) + } + seen[currentID] = struct{}{} + if _, exists := l.discovered[currentID]; !exists { + return nil, nil, errors.New( + "person fact organization redirect changed during resolution") + } + organization, exists := l.locked[currentID] + if !exists { + return nil, nil, ErrOrganizationNotFound + } + if organization.MergedIntoID == nil { + if organization.Kind != OrganizationKindCompany || organization.RetiredAt != nil { + return nil, nil, fmt.Errorf("%w: organization %d is not an active company", + ErrOrganizationInvalid, organization.ID) + } + return referenced, organization, nil + } + currentID = *organization.MergedIntoID + } + return nil, nil, fmt.Errorf("%w: organization merge redirect exceeds %d hops", + ErrOrganizationInvalid, maxPersonFactOrganizationRedirects-1) +} + +func normalizePersonFactOrganizationReference( + ref personfacts.OrganizationReference, +) (personfacts.OrganizationReference, []personFactOrganizationLookupKey, error) { + ref.Name = strings.Join(strings.Fields(ref.Name), " ") + if ref.Name == "" { + return personfacts.OrganizationReference{}, nil, + fmt.Errorf("%w: person fact organization name is required", ErrOrganizationInvalid) + } + if ref.ID != nil && *ref.ID <= 0 { + return personfacts.OrganizationReference{}, nil, + fmt.Errorf("%w: person fact organization id must be positive", ErrOrganizationInvalid) + } + if ref.Domain != "" { + ref.Domain = NormalizeDomain(ref.Domain) + if ref.Domain == "" { + return personfacts.OrganizationReference{}, nil, + fmt.Errorf("%w: person fact organization domain is invalid", ErrOrganizationInvalid) + } + } + keys := make([]personFactOrganizationLookupKey, 0, 2) + if ref.ID != nil { + keys = append(keys, personFactOrganizationLookupKey{Kind: "id", Value: strconv.FormatInt(*ref.ID, 10)}) + } else { + if ref.Domain != "" { + keys = append(keys, personFactOrganizationLookupKey{Kind: "domain", Value: ref.Domain}) + } + keys = append(keys, personFactOrganizationLookupKey{ + Kind: "name", Value: NormalizeOrganizationName(ref.Name), + }) + } + sort.Slice(keys, func(i, j int) bool { + if keys[i].Kind != keys[j].Kind { + return keys[i].Kind < keys[j].Kind + } + return keys[i].Value < keys[j].Value + }) + return ref, keys, nil +} + +func personFactOrganizationCandidateSetKey( + keys []personFactOrganizationLookupKey, +) (string, error) { + encoded, err := json.Marshal(keys) + if err != nil { + return "", fmt.Errorf("encode person fact organization candidate set key: %w", err) + } + return string(encoded), nil +} + +func (s *Store) personFactOrganizationCandidateIDsTx( + ctx context.Context, tx *loggedTx, keys []personFactOrganizationLookupKey, +) ([]int64, error) { + var candidates map[int64]struct{} + for _, key := range keys { + if key.Kind == "id" { + continue + } + var condition string + switch key.Kind { + case "domain": + condition = `( + o.primary_domain = ? OR EXISTS ( + SELECT 1 FROM organization_identifiers identifier + WHERE identifier.organization_id = o.id + AND identifier.identifier_kind = 'domain' + AND identifier.normalized_value = ? + AND identifier.active_until IS NULL + AND identifier.superseded_at IS NULL + ) + )` + case "name": + condition = `( + o.name_normalized = ? OR EXISTS ( + SELECT 1 FROM organization_names name + WHERE name.organization_id = o.id + AND name.name_normalized = ? + AND name.active_until IS NULL + AND name.superseded_at IS NULL + ) + )` + default: + return nil, fmt.Errorf("unknown person fact organization lookup kind %q", key.Kind) + } + rows, err := tx.QueryContext(ctx, ` + SELECT o.id FROM organizations o + WHERE o.kind = 'company' AND o.retired_at IS NULL AND o.merged_into_id IS NULL + AND `+condition+` + ORDER BY o.id`, key.Value, key.Value) + if err != nil { + return nil, fmt.Errorf("query person fact organization %s match: %w", key.Kind, err) + } + matched := make(map[int64]struct{}) + for rows.Next() { + var id int64 + if scanErr := rows.Scan(&id); scanErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("scan person fact organization match: %w", scanErr) + } + matched[id] = struct{}{} + } + if rowsErr := rows.Err(); rowsErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("iterate person fact organization matches: %w", rowsErr) + } + if closeErr := rows.Close(); closeErr != nil { + return nil, fmt.Errorf("close person fact organization matches: %w", closeErr) + } + if candidates == nil { + candidates = matched + continue + } + for id := range candidates { + if _, exists := matched[id]; !exists { + delete(candidates, id) + } + } + } + ids := make([]int64, 0, len(candidates)) + for id := range candidates { + ids = append(ids, id) + } + slices.Sort(ids) + return ids, nil +} + +func (s *Store) verifyPersonFactOrganizationReferenceTx( + ctx context.Context, tx *loggedTx, organization Organization, + ref personfacts.OrganizationReference, +) error { + if organization.Kind != OrganizationKindCompany { + return fmt.Errorf("%w: organization %d is not a company", + ErrOrganizationInvalid, organization.ID) + } + nameMatches := NormalizeOrganizationName(organization.Name) == NormalizeOrganizationName(ref.Name) + if !nameMatches { + err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM organization_names + WHERE organization_id = ? AND name_normalized = ? + AND active_until IS NULL AND superseded_at IS NULL + )`, organization.ID, NormalizeOrganizationName(ref.Name)).Scan(&nameMatches) + if err != nil { + return fmt.Errorf("verify person fact organization name: %w", err) + } + } + if !nameMatches { + return fmt.Errorf("%w: organization %d name disagrees with the supplied reference", + ErrOrganizationInvalid, organization.ID) + } + if ref.Domain == "" { + return nil + } + domainMatches := organization.PrimaryDomain != nil && *organization.PrimaryDomain == ref.Domain + if !domainMatches { + err := tx.QueryRowContext(ctx, `SELECT EXISTS ( + SELECT 1 FROM organization_identifiers + WHERE organization_id = ? AND identifier_kind = 'domain' AND normalized_value = ? + AND active_until IS NULL AND superseded_at IS NULL + )`, organization.ID, ref.Domain).Scan(&domainMatches) + if err != nil { + return fmt.Errorf("verify person fact organization domain: %w", err) + } + } + if !domainMatches { + return fmt.Errorf("%w: organization %d domain disagrees with the supplied reference", + ErrOrganizationInvalid, organization.ID) + } + return nil +} + +func personFactOrganizationFingerprint( + ref personfacts.OrganizationReference, keys []personFactOrganizationLookupKey, + match OrganizationMatch, +) (string, error) { + candidateIDs := append([]int64(nil), match.CandidateIDs...) + slices.Sort(candidateIDs) + encoded, err := json.Marshal(struct { + ID *int64 `json:"id,omitempty"` + Name string `json:"name"` + Domain string `json:"domain,omitempty"` + LookupKeys []personFactOrganizationLookupKey `json:"lookup_keys"` + Status OrganizationResolutionStatus `json:"status"` + CandidateIDs []int64 `json:"candidate_ids"` + }{ + ID: ref.ID, Name: NormalizeOrganizationName(ref.Name), Domain: ref.Domain, + LookupKeys: append([]personFactOrganizationLookupKey(nil), keys...), + Status: match.Status, CandidateIDs: candidateIDs, + }) + if err != nil { + return "", fmt.Errorf("encode person fact organization match: %w", err) + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]), nil +} diff --git a/internal/store/person_fact_organization_concurrency_test.go b/internal/store/person_fact_organization_concurrency_test.go new file mode 100644 index 000000000..ca7e81721 --- /dev/null +++ b/internal/store/person_fact_organization_concurrency_test.go @@ -0,0 +1,834 @@ +package store + +import ( + "context" + "database/sql" + "database/sql/driver" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5/stdlib" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPersonFactEmploymentCrossedOrganizationChainsSerialize(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + postgres := IsPostgresURL(dbURL) + var gate *personFactOrganizationCrossedLockGate + var st *Store + if postgres { + gate = newPersonFactOrganizationCrossedLockGate(2) + st = newPersonFactOrganizationCrossedLockStore(t, dbURL, gate) + } else { + st, _, _ = newPersonFactProjectionStore(t) + } + requirements := require.New(t) + assertions := assert.New(t) + + firstPersonID := createTrackedPersonFactPerson(t, st, "crossed-org-first") + secondPersonID := createTrackedPersonFactPerson(t, st, "crossed-org-second") + target := projectionTargetBySlug(t, st, "employment") + firstSource := createPersonFactOrganization(t, st, "First Source", "first-source.example") + firstRoot := createPersonFactOrganization(t, st, "First Root", "first-root.example") + secondSource := createPersonFactOrganization(t, st, "Second Source", "second-source.example") + secondRoot := createPersonFactOrganization(t, st, "Second Root", "second-root.example") + secondAlternate := createPersonFactOrganization( + t, st, "Second Alternate", "second-alternate.example") + firstAlternate := createPersonFactOrganization( + t, st, "First Alternate", "first-alternate.example") + mergedFirst, err := st.MergeOrganizationsContext( + t.Context(), firstRoot.ID, firstRoot.Revision, firstSource.ID, firstSource.Revision) + requirements.NoError(err) + _, err = st.MergeOrganizationsContext( + t.Context(), mergedFirst.ID, mergedFirst.Revision, firstAlternate.ID, firstAlternate.Revision) + requirements.NoError(err) + mergedSecond, err := st.MergeOrganizationsContext( + t.Context(), secondRoot.ID, secondRoot.Revision, secondSource.ID, secondSource.Revision) + requirements.NoError(err) + _, err = st.MergeOrganizationsContext( + t.Context(), mergedSecond.ID, mergedSecond.Revision, + secondAlternate.ID, secondAlternate.Revision) + requirements.NoError(err) + + firstInput := crossedPersonFactOrganizationGeneration( + t, firstPersonID, target, firstSource, secondSource, firstSource.ID, "first") + secondInput := crossedPersonFactOrganizationGeneration( + t, secondPersonID, target, firstAlternate, secondAlternate, + secondAlternate.ID, "second") + if !postgres { + first, firstErr := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + second, secondErr := st.ApplyPersonFactGenerationContext(t.Context(), secondInput, nil) + requirements.NoError(firstErr) + requirements.NoError(secondErr) + requirements.Len(first.Projections, 2) + requirements.Len(second.Projections, 2) + } else { + applyPersonFactCrossedOrganizationGenerations(t, st, gate, firstInput, secondInput) + } + assertions.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) + assertions.Equal(int64(4), personFactProjectionRowCount(t, st, "employments")) + + firstEvidence, err := st.ListPersonFactEvidenceContext(t.Context(), firstPersonID, + personfacts.EvidenceFilter{Limit: 10}) + requirements.NoError(err) + requirements.Len(firstEvidence, 2) + secondEvidence, err := st.ListPersonFactEvidenceContext(t.Context(), secondPersonID, + personfacts.EvidenceFilter{Limit: 10}) + requirements.NoError(err) + requirements.Len(secondEvidence, 2) + firstReplay := personFactProjectionInput(firstPersonID, "crossed-first-replay", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: firstEvidence[0].Key, SourceVersion: firstEvidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + secondReplay := personFactProjectionInput(secondPersonID, "crossed-second-replay", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: secondEvidence[0].Key, SourceVersion: secondEvidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + if !postgres { + _, firstErr := st.ApplyPersonFactGenerationContext(t.Context(), firstReplay, nil) + _, secondErr := st.ApplyPersonFactGenerationContext(t.Context(), secondReplay, nil) + requirements.NoError(firstErr) + requirements.NoError(secondErr) + } else { + gate.reset() + applyPersonFactCrossedOrganizationGenerations(t, st, gate, firstReplay, secondReplay) + } + assertions.Equal(int64(4), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestPersonFactEmploymentIncomingAndHistoricalOrganizationsShareLockOrder(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + postgres := IsPostgresURL(dbURL) + var gate *personFactOrganizationCrossedLockGate + var st *Store + if postgres { + gate = newPersonFactOrganizationCrossedLockGate(1) + st = newPersonFactOrganizationCrossedLockStore(t, dbURL, gate) + } else { + st, _, _ = newPersonFactProjectionStore(t) + } + requirements := require.New(t) + assertions := assert.New(t) + + firstPersonID := createTrackedPersonFactPerson(t, st, "mixed-org-first") + secondPersonID := createTrackedPersonFactPerson(t, st, "mixed-org-second") + target := projectionTargetBySlug(t, st, "employment") + firstOrganization := createPersonFactOrganization( + t, st, "Mixed First Organization", "mixed-first.example") + secondOrganization := createPersonFactOrganization( + t, st, "Mixed Second Organization", "mixed-second.example") + claim := func(personID int64, organization *Organization, title, suffix string) personfacts.ProposedClaim { + return personFactProjectionClaim(personID, target, fmt.Sprintf( + `{"organization":{"id":%d,"name":%q,"domain":%q},"title":%q}`, + organization.ID, organization.Name, *organization.PrimaryDomain, title), suffix) + } + _, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(firstPersonID, "mixed-first-history", []personfacts.ProposedClaim{ + claim(firstPersonID, secondOrganization, "Historical Engineer", "mixed-first-history"), + }, nil), nil) + requirements.NoError(err) + _, err = st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(secondPersonID, "mixed-second-history", []personfacts.ProposedClaim{ + claim(secondPersonID, firstOrganization, "Historical Engineer", "mixed-second-history"), + }, nil), nil) + requirements.NoError(err) + + firstIncoming := personFactProjectionInput( + firstPersonID, "mixed-first-incoming", []personfacts.ProposedClaim{ + claim(firstPersonID, firstOrganization, "Incoming Engineer", "mixed-first-incoming"), + }, nil) + secondIncoming := personFactProjectionInput( + secondPersonID, "mixed-second-incoming", []personfacts.ProposedClaim{ + claim(secondPersonID, secondOrganization, "Incoming Engineer", "mixed-second-incoming"), + }, nil) + if postgres { + applyPersonFactCrossedOrganizationGenerations( + t, st, gate, firstIncoming, secondIncoming) + } else { + _, firstErr := st.ApplyPersonFactGenerationContext(t.Context(), firstIncoming, nil) + _, secondErr := st.ApplyPersonFactGenerationContext(t.Context(), secondIncoming, nil) + requirements.NoError(firstErr) + requirements.NoError(secondErr) + } + assertions.Equal(int64(4), personFactProjectionRowCount(t, st, "person_fact_generations")) + assertions.Equal(int64(4), personFactProjectionRowCount(t, st, "employments")) +} + +func TestPersonFactEmploymentNonIDCandidateSnapshotPrecedesOrganizationMutation(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + if !IsPostgresURL(dbURL) { + t.Skip("PostgreSQL table locks are required for the candidate snapshot race") + } + for _, mode := range []string{"rename", "retire", "newly-matching"} { + t.Run(mode, func(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + gate := newPersonFactOrganizationCandidateGate(2) + st := newPersonFactOrganizationCandidateGateStore(t, dbURL, gate) + personID := createTrackedPersonFactPerson(t, st, "candidate-race-"+mode) + target := projectionTargetBySlug(t, st, "employment") + candidate := createPersonFactOrganization( + t, st, "Candidate Race Company", "candidate-race.example") + var contender *Organization + if mode == "newly-matching" { + contender = createPersonFactOrganization( + t, st, "Candidate Race Contender", "candidate-race-contender.example") + } + claim := personFactProjectionClaim(personID, target, + `{"organization":{"name":"Candidate Race Company","domain":"candidate-race.example"},"title":"Engineer"}`, + "candidate-race-"+mode) + input := personFactProjectionInput(personID, "candidate-race-"+mode, + []personfacts.ProposedClaim{claim}, nil) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + t.Cleanup(cancel) + type candidateGenerationOutcome struct { + result *personfacts.GenerationResult + err error + } + generationDone := make(chan candidateGenerationOutcome, 1) + generationCtx := context.WithValue( + ctx, personFactOrganizationCandidateGateActorKey{}, true) + go func() { + result, applyErr := st.ApplyPersonFactGenerationContext(generationCtx, input, nil) + generationDone <- candidateGenerationOutcome{result: result, err: applyErr} + }() + waitPersonFactEmploymentLockSignal( + t, gate.queried, "generation did not finish its non-ID candidate query") + + type mutationOutcome struct { + organization *Organization + err error + } + mutationDone := make(chan mutationOutcome, 1) + go func() { + mutated := candidate + input := OrganizationInput{ + Name: candidate.Name, Kind: candidate.Kind, + PrimaryDomain: candidate.PrimaryDomain, + } + retired := false + switch mode { + case "rename": + input.Name = "Renamed Candidate Race Company" + domain := "renamed-candidate-race.example" + input.PrimaryDomain = &domain + case "retire": + retired = true + case "newly-matching": + mutated = contender + input.Name = candidate.Name + input.PrimaryDomain = candidate.PrimaryDomain + } + organization, replaceErr := st.ReplaceOrganizationContext( + ctx, mutated.ID, mutated.Revision, input, retired) + mutationDone <- mutationOutcome{organization: organization, err: replaceErr} + }() + + var early *mutationOutcome + requirements.Eventually(func() bool { + select { + case outcome := <-mutationDone: + early = &outcome + return true + default: + return personFactOrganizationPostgreSQLWaitingLockCount(t, st) >= 1 + } + }, 5*time.Second, 10*time.Millisecond, + "organization mutation neither completed nor waited for the fact snapshot") + assertions.Nil(early, + "organization mutation committed before the fact candidate snapshot") + gate.release() + + var generation candidateGenerationOutcome + select { + case generation = <-generationDone: + case <-ctx.Done(): + requirements.FailNow("fact generation did not finish", ctx.Err()) + } + requirements.NoError(generation.err) + requirements.NotNil(generation.result) + requirements.Len(generation.result.Projections, 1) + var mutation mutationOutcome + if early != nil { + mutation = *early + } else { + select { + case mutation = <-mutationDone: + case <-ctx.Done(): + requirements.FailNow("organization mutation did not finish", ctx.Err()) + } + } + requirements.NoError(mutation.err) + requirements.NotNil(mutation.organization) + employment, err := st.GetEmploymentContext( + t.Context(), generation.result.Projections[0].RowID) + requirements.NoError(err) + assertions.Equal(candidate.ID, employment.OrganizationID) + }) + } +} + +func TestPersonFactEmploymentOrganizationTableLockWaitsForMergeMutation(t *testing.T) { + dbURL := os.Getenv("MSGVAULT_TEST_DB") + if !IsPostgresURL(dbURL) { + t.Skip("PostgreSQL lock queues are required for the table-lock upgrade race") + } + requirements := require.New(t) + assertions := assert.New(t) + gate := newPersonFactOrganizationCrossedLockGate(2) + st := newPersonFactOrganizationCrossedLockStore(t, dbURL, gate) + personID := createTrackedPersonFactPerson(t, st, "candidate-upgrade-race") + target := projectionTargetBySlug(t, st, "employment") + survivor := createPersonFactOrganization( + t, st, "Candidate Upgrade Survivor", "candidate-upgrade-survivor.example") + losing := createPersonFactOrganization( + t, st, "Candidate Upgrade Losing", "candidate-upgrade-losing.example") + claim := personFactProjectionClaim(personID, target, + `{"organization":{"name":"Candidate Upgrade Losing","domain":"candidate-upgrade-losing.example"},"title":"Engineer"}`, + "candidate-upgrade-race") + input := personFactProjectionInput(personID, "candidate-upgrade-race", + []personfacts.ProposedClaim{claim}, nil) + + ctx, cancel := context.WithTimeout(t.Context(), 15*time.Second) + t.Cleanup(cancel) + mergeCtx := context.WithValue( + ctx, personFactOrganizationCrossedLockActorKey{}, personFactOrganizationCrossedLockFirst) + mergeDone := make(chan error, 1) + go func() { + _, mergeErr := st.MergeOrganizationsContext( + mergeCtx, survivor.ID, survivor.Revision, losing.ID, losing.Revision) + mergeDone <- mergeErr + }() + waitPersonFactEmploymentLockSignal( + t, gate.firstPaused, "merge did not acquire both organization rows") + + type candidateGenerationOutcome struct { + result *personfacts.GenerationResult + err error + } + generationDone := make(chan candidateGenerationOutcome, 1) + go func() { + result, applyErr := st.ApplyPersonFactGenerationContext(ctx, input, nil) + generationDone <- candidateGenerationOutcome{result: result, err: applyErr} + }() + requirements.Eventually(func() bool { + return personFactOrganizationPostgreSQLWaitingLockCount(t, st) >= 1 + }, 5*time.Second, 10*time.Millisecond, + "fact generation did not queue behind the merge table lock") + gate.release() + + select { + case mergeErr := <-mergeDone: + requirements.NoError(mergeErr, + "organization merge must retry a table-lock upgrade deadlock") + case <-ctx.Done(): + requirements.FailNow("organization merge did not finish", ctx.Err()) + } + var generation candidateGenerationOutcome + select { + case generation = <-generationDone: + case <-ctx.Done(): + requirements.FailNow("fact generation did not retry the table-lock deadlock", ctx.Err()) + } + requirements.NoError(generation.err) + requirements.NotNil(generation.result) + requirements.Len(generation.result.Projections, 1) + employment, err := st.GetEmploymentContext( + t.Context(), generation.result.Projections[0].RowID) + requirements.NoError(err) + assertions.NotEqual(losing.ID, employment.OrganizationID) + employer, err := st.GetOrganizationContext(t.Context(), employment.OrganizationID) + requirements.NoError(err) + assertions.Equal(OrganizationKindCompany, employer.Kind) + assertions.Nil(employer.RetiredAt) + assertions.Nil(employer.MergedIntoID) +} + +func applyPersonFactCrossedOrganizationGenerations( + t *testing.T, st *Store, gate *personFactOrganizationCrossedLockGate, + firstInput, secondInput personfacts.GenerationInput, +) { + t.Helper() + requirements := require.New(t) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + t.Cleanup(cancel) + firstCtx := context.WithValue( + ctx, personFactOrganizationCrossedLockActorKey{}, personFactOrganizationCrossedLockFirst) + results := make(chan error, 2) + go func() { + _, applyErr := st.ApplyPersonFactGenerationContext(firstCtx, firstInput, nil) + results <- applyErr + }() + waitPersonFactEmploymentLockSignal( + t, gate.firstPaused, "first generation did not acquire its first redirect root") + go func() { + _, applyErr := st.ApplyPersonFactGenerationContext(ctx, secondInput, nil) + results <- applyErr + }() + requirements.Eventually(func() bool { + return personFactOrganizationPostgreSQLWaitingLockCount(t, st) >= 1 + }, 5*time.Second, 10*time.Millisecond, + "second generation did not wait behind the first organization row") + gate.release() + + errs := make([]error, 0, 2) + for range 2 { + select { + case applyErr := <-results: + errs = append(errs, applyErr) + case <-ctx.Done(): + requirements.FailNow("crossed organization-chain generations did not finish", ctx.Err()) + } + } + for _, applyErr := range errs { + requirements.NoError(applyErr, + "crossed organization-chain batches must share one global lock order") + } +} + +func crossedPersonFactOrganizationGeneration( + t *testing.T, personID int64, target personfacts.TargetDescriptor, + firstSource, secondSource *Organization, wantedFirstID int64, suffix string, +) personfacts.GenerationInput { + t.Helper() + requirements := require.New(t) + for attempt := range 100 { + claims := []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, fmt.Sprintf( + `{"organization":{"id":%d,"name":%q,"domain":%q},"title":%q}`, + firstSource.ID, firstSource.Name, *firstSource.PrimaryDomain, + fmt.Sprintf("Engineer %s A %d", suffix, attempt)), + fmt.Sprintf("crossed-%s-a-%d", suffix, attempt)), + personFactProjectionClaim(personID, target, fmt.Sprintf( + `{"organization":{"id":%d,"name":%q,"domain":%q},"title":%q}`, + secondSource.ID, secondSource.Name, *secondSource.PrimaryDomain, + fmt.Sprintf("Engineer %s B %d", suffix, attempt)), + fmt.Sprintf("crossed-%s-b-%d", suffix, attempt)), + } + input := personFactProjectionInput(personID, + fmt.Sprintf("crossed-%s-%d", suffix, attempt), claims, nil) + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), input, nil) + requirements.NoError(err) + preparedClaims := prepared.Claims() + requirements.Len(preparedClaims, 2) + var value personfacts.EmploymentValue + requirements.NoError(json.Unmarshal(preparedClaims[0].Normalized.JSON, &value)) + requirements.NotNil(value.Organization.ID) + claimKeys := make([]string, len(preparedClaims)) + for index := range preparedClaims { + claimKeys[index], err = personfacts.ClaimKey(prepared.GenerationKey(), preparedClaims[index]) + requirements.NoError(err) + } + storedFirst := 0 + if claimKeys[1] < claimKeys[0] { + storedFirst = 1 + } + var storedValue personfacts.EmploymentValue + requirements.NoError(json.Unmarshal( + preparedClaims[storedFirst].Normalized.JSON, &storedValue)) + requirements.NotNil(storedValue.Organization.ID) + if *value.Organization.ID == wantedFirstID && + *storedValue.Organization.ID == wantedFirstID { + return input + } + } + requirements.FailNow("could not construct crossed canonical claim order") + return personfacts.GenerationInput{} +} + +func createTrackedPersonFactPerson(t *testing.T, st *Store, suffix string) int64 { + t.Helper() + participantID, err := st.EnsureParticipant( + suffix+"@example.invalid", "Crossed Organization Person", "example.invalid") + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + _, err = st.SetPersonTrackingContext(t.Context(), person.ID, true) + require.NoError(t, err) + return person.ID +} + +type personFactOrganizationCrossedLockActorKey struct{} + +const personFactOrganizationCrossedLockFirst = "first" + +type personFactOrganizationCrossedLockGate struct { + firstPaused chan struct{} + releaseFirst chan struct{} + pauseAt int + lockCount int + mu sync.Mutex + pauseOnce sync.Once + releaseOnce sync.Once +} + +func newPersonFactOrganizationCrossedLockGate( + pauseAt int, +) *personFactOrganizationCrossedLockGate { + return &personFactOrganizationCrossedLockGate{ + firstPaused: make(chan struct{}), releaseFirst: make(chan struct{}), pauseAt: pauseAt, + } +} + +func (g *personFactOrganizationCrossedLockGate) pauseAfterOrganizationLock(ctx context.Context) error { + if ctx.Value(personFactOrganizationCrossedLockActorKey{}) != + personFactOrganizationCrossedLockFirst { + return nil + } + g.mu.Lock() + g.lockCount++ + pause := g.lockCount == g.pauseAt + g.mu.Unlock() + if !pause { + return nil + } + var wait bool + g.pauseOnce.Do(func() { + wait = true + close(g.firstPaused) + }) + if !wait { + return nil + } + select { + case <-g.releaseFirst: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (g *personFactOrganizationCrossedLockGate) release() { + g.releaseOnce.Do(func() { close(g.releaseFirst) }) +} + +func (g *personFactOrganizationCrossedLockGate) reset() { + g.mu.Lock() + defer g.mu.Unlock() + g.firstPaused = make(chan struct{}) + g.releaseFirst = make(chan struct{}) + g.lockCount = 0 + g.pauseOnce = sync.Once{} + g.releaseOnce = sync.Once{} +} + +type personFactOrganizationCrossedLockConnector struct { + driver.Connector + + gate *personFactOrganizationCrossedLockGate +} + +func (c *personFactOrganizationCrossedLockConnector) Connect( + ctx context.Context, +) (driver.Conn, error) { + conn, err := c.Connector.Connect(ctx) + if err != nil { + return nil, err + } + return &personFactOrganizationCrossedLockConn{Conn: conn, gate: c.gate}, nil +} + +type personFactOrganizationCrossedLockConn struct { + driver.Conn + + gate *personFactOrganizationCrossedLockGate +} + +func (c *personFactOrganizationCrossedLockConn) QueryContext( + ctx context.Context, query string, args []driver.NamedValue, +) (driver.Rows, error) { + queryer, ok := c.Conn.(driver.QueryerContext) + if !ok { + return nil, driver.ErrSkip + } + rows, err := queryer.QueryContext(ctx, query, args) + if err != nil || ctx.Value(personFactOrganizationCrossedLockActorKey{}) != + personFactOrganizationCrossedLockFirst || + !strings.Contains(strings.Join(strings.Fields(query), " "), + "FROM organizations WHERE id = $1 FOR UPDATE") { + return rows, err + } + return &personFactOrganizationCrossedLockRows{ + Rows: rows, ctx: ctx, gate: c.gate, + }, nil +} + +func (c *personFactOrganizationCrossedLockConn) PrepareContext( + ctx context.Context, query string, +) (driver.Stmt, error) { + if preparer, ok := c.Conn.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, query) + } + return c.Prepare(query) +} + +func (c *personFactOrganizationCrossedLockConn) BeginTx( + ctx context.Context, opts driver.TxOptions, +) (driver.Tx, error) { + if beginner, ok := c.Conn.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, opts) + } + return nil, errors.New("wrapped organization crossed-lock connection does not implement ConnBeginTx") +} + +func (c *personFactOrganizationCrossedLockConn) Ping(ctx context.Context) error { + if pinger, ok := c.Conn.(driver.Pinger); ok { + return pinger.Ping(ctx) + } + return nil +} + +func (c *personFactOrganizationCrossedLockConn) ResetSession(ctx context.Context) error { + if resetter, ok := c.Conn.(driver.SessionResetter); ok { + return resetter.ResetSession(ctx) + } + return nil +} + +func (c *personFactOrganizationCrossedLockConn) IsValid() bool { + if validator, ok := c.Conn.(driver.Validator); ok { + return validator.IsValid() + } + return true +} + +func (c *personFactOrganizationCrossedLockConn) CheckNamedValue(value *driver.NamedValue) error { + if checker, ok := c.Conn.(driver.NamedValueChecker); ok { + return checker.CheckNamedValue(value) + } + return driver.ErrSkip +} + +type personFactOrganizationCrossedLockRows struct { + driver.Rows + + ctx context.Context + gate *personFactOrganizationCrossedLockGate +} + +func (r *personFactOrganizationCrossedLockRows) Next(values []driver.Value) error { + err := r.Rows.Next(values) + if err != nil { + return err + } + return r.gate.pauseAfterOrganizationLock(r.ctx) +} + +func newPersonFactOrganizationCrossedLockStore( + t *testing.T, dbURL string, gate *personFactOrganizationCrossedLockGate, +) *Store { + t.Helper() + base := newPGStoreInternal(t, dbURL) + config, err := postgresConnConfig(base.dbPath, false) + require.NoError(t, err) + db := sql.OpenDB(&personFactOrganizationCrossedLockConnector{ + Connector: stdlib.GetConnector(*config), gate: gate, + }) + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(4) + dialect := &PostgreSQLDialect{} + require.NoError(t, dialect.InitConn(db)) + st := &Store{db: newLoggedDB(db, dialect.Rebind), dbPath: base.dbPath, dialect: dialect} + t.Cleanup(func() { _ = st.Close() }) + return st +} + +func personFactOrganizationPostgreSQLWaitingLockCount(t *testing.T, st *Store) int { + t.Helper() + var count int + require.NoError(t, st.DB().QueryRowContext(t.Context(), ` + SELECT COUNT(*) FROM pg_stat_activity + WHERE datname = current_database() AND wait_event_type = 'Lock'`).Scan(&count)) + return count +} + +var _ driver.Rows = (*personFactOrganizationCrossedLockRows)(nil) +var _ io.Closer = (*personFactOrganizationCrossedLockRows)(nil) + +type personFactOrganizationCandidateGateActorKey struct{} + +type personFactOrganizationCandidateGate struct { + queried chan struct{} + releaseQuery chan struct{} + pauseAt int + queryCount int + mu sync.Mutex + pauseOnce sync.Once + releaseOnce sync.Once +} + +func newPersonFactOrganizationCandidateGate(pauseAt int) *personFactOrganizationCandidateGate { + return &personFactOrganizationCandidateGate{ + queried: make(chan struct{}), releaseQuery: make(chan struct{}), pauseAt: pauseAt, + } +} + +func (g *personFactOrganizationCandidateGate) pauseAfterCandidateQuery( + ctx context.Context, +) error { + g.mu.Lock() + g.queryCount++ + pause := g.queryCount == g.pauseAt + g.mu.Unlock() + if !pause { + return nil + } + var wait bool + g.pauseOnce.Do(func() { + wait = true + close(g.queried) + }) + if !wait { + return nil + } + select { + case <-g.releaseQuery: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +func (g *personFactOrganizationCandidateGate) release() { + g.releaseOnce.Do(func() { close(g.releaseQuery) }) +} + +type personFactOrganizationCandidateGateConnector struct { + driver.Connector + + gate *personFactOrganizationCandidateGate +} + +func (c *personFactOrganizationCandidateGateConnector) Connect( + ctx context.Context, +) (driver.Conn, error) { + conn, err := c.Connector.Connect(ctx) + if err != nil { + return nil, err + } + return &personFactOrganizationCandidateGateConn{Conn: conn, gate: c.gate}, nil +} + +type personFactOrganizationCandidateGateConn struct { + driver.Conn + + gate *personFactOrganizationCandidateGate +} + +func (c *personFactOrganizationCandidateGateConn) QueryContext( + ctx context.Context, query string, args []driver.NamedValue, +) (driver.Rows, error) { + queryer, ok := c.Conn.(driver.QueryerContext) + if !ok { + return nil, driver.ErrSkip + } + rows, err := queryer.QueryContext(ctx, query, args) + if err != nil || ctx.Value(personFactOrganizationCandidateGateActorKey{}) != true || + !strings.Contains(strings.Join(strings.Fields(query), " "), + "SELECT o.id FROM organizations o WHERE o.kind = 'company'") { + return rows, err + } + return &personFactOrganizationCandidateGateRows{ + Rows: rows, ctx: ctx, gate: c.gate, + }, nil +} + +func (c *personFactOrganizationCandidateGateConn) PrepareContext( + ctx context.Context, query string, +) (driver.Stmt, error) { + if preparer, ok := c.Conn.(driver.ConnPrepareContext); ok { + return preparer.PrepareContext(ctx, query) + } + return c.Prepare(query) +} + +func (c *personFactOrganizationCandidateGateConn) BeginTx( + ctx context.Context, opts driver.TxOptions, +) (driver.Tx, error) { + if beginner, ok := c.Conn.(driver.ConnBeginTx); ok { + return beginner.BeginTx(ctx, opts) + } + return nil, errors.New("wrapped organization candidate-gate connection does not implement ConnBeginTx") +} + +func (c *personFactOrganizationCandidateGateConn) Ping(ctx context.Context) error { + if pinger, ok := c.Conn.(driver.Pinger); ok { + return pinger.Ping(ctx) + } + return nil +} + +func (c *personFactOrganizationCandidateGateConn) ResetSession(ctx context.Context) error { + if resetter, ok := c.Conn.(driver.SessionResetter); ok { + return resetter.ResetSession(ctx) + } + return nil +} + +func (c *personFactOrganizationCandidateGateConn) IsValid() bool { + if validator, ok := c.Conn.(driver.Validator); ok { + return validator.IsValid() + } + return true +} + +func (c *personFactOrganizationCandidateGateConn) CheckNamedValue( + value *driver.NamedValue, +) error { + if checker, ok := c.Conn.(driver.NamedValueChecker); ok { + return checker.CheckNamedValue(value) + } + return driver.ErrSkip +} + +type personFactOrganizationCandidateGateRows struct { + driver.Rows + + ctx context.Context + gate *personFactOrganizationCandidateGate +} + +func (r *personFactOrganizationCandidateGateRows) Next(values []driver.Value) error { + err := r.Rows.Next(values) + if !errors.Is(err, io.EOF) { + return err + } + if pauseErr := r.gate.pauseAfterCandidateQuery(r.ctx); pauseErr != nil { + return pauseErr + } + return err +} + +func newPersonFactOrganizationCandidateGateStore( + t *testing.T, dbURL string, gate *personFactOrganizationCandidateGate, +) *Store { + t.Helper() + base := newPGStoreInternal(t, dbURL) + config, err := postgresConnConfig(base.dbPath, false) + require.NoError(t, err) + db := sql.OpenDB(&personFactOrganizationCandidateGateConnector{ + Connector: stdlib.GetConnector(*config), gate: gate, + }) + db.SetMaxOpenConns(8) + db.SetMaxIdleConns(4) + dialect := &PostgreSQLDialect{} + require.NoError(t, dialect.InitConn(db)) + st := &Store{db: newLoggedDB(db, dialect.Rebind), dbPath: base.dbPath, dialect: dialect} + t.Cleanup(func() { _ = st.Close() }) + return st +} + +var _ driver.Rows = (*personFactOrganizationCandidateGateRows)(nil) +var _ io.Closer = (*personFactOrganizationCandidateGateRows)(nil) diff --git a/internal/store/person_fact_organization_replay_test.go b/internal/store/person_fact_organization_replay_test.go new file mode 100644 index 000000000..a6ebf9805 --- /dev/null +++ b/internal/store/person_fact_organization_replay_test.go @@ -0,0 +1,231 @@ +package store + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPersonFactEmploymentHistoricalNameDomainClaimKeepsResolvedOrganization(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "name-domain-binding-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, + `{"organization":{"name":"Original Company","domain":"original.example"},"title":"Engineer"}`, + "name-domain-binding-seed"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 1) + employment, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + requirements.NoError(err) + organization, err := st.GetOrganizationContext(t.Context(), employment.OrganizationID) + requirements.NoError(err) + + renamedDomain := "renamed.example" + renamed, err := st.ReplaceOrganizationContext(t.Context(), organization.ID, organization.Revision, + OrganizationInput{ + Name: "Renamed Company", Kind: OrganizationKindCompany, + PrimaryDomain: &renamedDomain, + }, false) + requirements.NoError(err) + + weak := personFactProjectionClaim(personID, target, + `{"organization":{"name":"Weak Company","domain":"weak.example"},"title":"Advisor"}`, + "name-domain-binding-later") + weak.Confidence.ReportedScore = 0 + weak.Evidence[0].Directness = personfacts.Indirect + weak.Evidence[0].Authority = personfacts.AuthorityAggregator + later, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "name-domain-binding-later", + []personfacts.ProposedClaim{weak}, nil), nil) + requirements.NoError(err) + assertions.Positive(later.GenerationID) + assertions.Empty(later.Projections) + + employments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{PersonID: personID}) + requirements.NoError(err) + requirements.Len(employments, 1) + assertions.Equal(renamed.ID, employments[0].OrganizationID) + organizations, err := st.ListOrganizationsContext(t.Context(), OrganizationFilter{}) + requirements.NoError(err) + requirements.Len(organizations, 1) + assertions.Equal("Renamed Company", organizations[0].Name) +} + +func TestPersonFactEmploymentHistoricalMergedOrganizationReplaysAsSurvivor(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + losing := createPersonFactOrganization(t, st, "Merged Company", "merged-old.example") + survivor := createPersonFactOrganization(t, st, "Surviving Company", "survivor.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Merged Company","domain":"merged-old.example"},"title":"Engineer"}`, + losing.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "merged-replay-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "merged-replay-seed"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 1) + + merged, err := st.MergeOrganizationsContext( + t.Context(), survivor.ID, survivor.Revision, losing.ID, losing.Revision) + requirements.NoError(err) + assertions.Equal(survivor.ID, merged.ID) + terminal := createPersonFactOrganization(t, st, "Canonical Company", "canonical.example") + canonical, err := st.MergeOrganizationsContext( + t.Context(), terminal.ID, terminal.Revision, merged.ID, merged.Revision) + requirements.NoError(err) + assertions.Equal(terminal.ID, canonical.ID) + + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + requirements.NoError(err) + requirements.Len(evidence, 1) + later, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "merged-replay-later", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidence[0].Key, SourceVersion: evidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}), nil) + requirements.NoError(err) + assertions.Positive(later.GenerationID) + requirements.Len(later.Decisions, 1) + assertions.Equal(personfacts.DecisionRetained, later.Decisions[0].Action) + assertions.Equal(personfacts.ReasonEvidenceUnsupported, later.Decisions[0].Reason) + assertions.Empty(later.Projections) + + employment, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + requirements.NoError(err) + assertions.Equal(terminal.ID, employment.OrganizationID) + assertions.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestPersonFactEmploymentHistoricalOrganizationRenameAllowsPin(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization(t, st, "Original Company", "original.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Original Company","domain":"original.example"},"title":"Engineer"}`, + organization.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "rename-pin-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "rename-pin-seed"), + }, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Projections, 1) + + renamedDomain := "renamed.example" + renamed, err := st.ReplaceOrganizationContext(t.Context(), organization.ID, organization.Revision, + OrganizationInput{ + Name: "Renamed Company", Kind: OrganizationKindCompany, + PrimaryDomain: &renamedDomain, + }, false) + requirements.NoError(err) + assertions.Equal("Renamed Company", renamed.Name) + + pinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + true, "organization-rename-test") + requirements.NoError(err) + assertions.True(pinned.State.Pinned) + requirements.Len(pinned.Resolutions, 1) + requirements.Len(pinned.Resolutions[0].Decisions, 1) + assertions.Equal(personfacts.DecisionRetained, pinned.Resolutions[0].Decisions[0].Action) + assertions.Equal(personfacts.ReasonPinRetained, pinned.Resolutions[0].Decisions[0].Reason) + assertions.Empty(pinned.Projections) + + employment, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + requirements.NoError(err) + assertions.Equal(renamed.ID, employment.OrganizationID) + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + requirements.NoError(err) + requirements.Len(pins, 1) + assertions.True(pins[0].Pinned) + assertions.Equal(target.Key, pins[0].Target.Key) + assertions.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestPersonFactEmploymentUnavailableHistoricalOrganizationRejectsOnlyThatClaim(t *testing.T) { + for _, unavailable := range []string{"deleted", "retired"} { + t.Run(unavailable, func(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + stale := createPersonFactOrganization(t, st, "Stale Company", "stale.example") + staleSubmitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Stale Company","domain":"stale.example"},"title":"Advisor"}`, + stale.ID) + staleClaim := personFactProjectionClaim( + personID, target, staleSubmitted, "unavailable-replay-seed") + staleClaim.Confidence.ReportedScore = 0 + staleClaim.Evidence[0].Directness = personfacts.Indirect + staleClaim.Evidence[0].Authority = personfacts.AuthorityAggregator + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unavailable-replay-seed", + []personfacts.ProposedClaim{staleClaim}, nil), nil) + requirements.NoError(err) + requirements.Len(seed.Decisions, 1) + assertions.Equal(personfacts.DecisionRetained, seed.Decisions[0].Action) + assertions.Empty(seed.Projections) + if unavailable == "deleted" { + requirements.NoError(st.DeleteOrganizationContext( + t.Context(), stale.ID, stale.Revision)) + } else { + _, err = st.ReplaceOrganizationContext(t.Context(), stale.ID, stale.Revision, + OrganizationInput{Name: stale.Name, Kind: stale.Kind}, true) + requirements.NoError(err) + } + + current := createPersonFactOrganization(t, st, "Current Company", "current.example") + currentSubmitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Current Company","domain":"current.example"},"title":"Engineer"}`, + current.ID) + later, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unavailable-replay-later", + []personfacts.ProposedClaim{ + personFactProjectionClaim( + personID, target, currentSubmitted, "unavailable-replay-later"), + }, nil), nil) + requirements.NoError(err) + assertions.Positive(later.GenerationID) + requirements.Len(later.Decisions, 2) + invalid, applied := 0, 0 + for _, decision := range later.Decisions { + if decision.Action == personfacts.DecisionInvalid { + invalid++ + assertions.Equal(personfacts.ReasonMalformedValue, decision.Reason) + } + if decision.Action == personfacts.DecisionApplied { + applied++ + } + } + assertions.Equal(1, invalid) + assertions.Equal(1, applied) + requirements.Len(later.Projections, 1) + employment, err := st.GetEmploymentContext(t.Context(), later.Projections[0].RowID) + requirements.NoError(err) + assertions.Equal(current.ID, employment.OrganizationID) + assertions.Equal(int64(2), personFactProjectionRowCount( + t, st, "person_fact_generations")) + claims, err := st.ListPersonFactClaimsContext( + t.Context(), personID, personfacts.ClaimFilter{}) + requirements.NoError(err) + assertions.Len(claims, 2) + }) + } +} diff --git a/internal/store/person_fact_pins.go b/internal/store/person_fact_pins.go new file mode 100644 index 000000000..423360e70 --- /dev/null +++ b/internal/store/person_fact_pins.go @@ -0,0 +1,467 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "errors" + "fmt" + "sort" + "strings" + "time" + + "go.kenn.io/msgvault/internal/personfacts" +) + +func (s *Store) SetPersonFactPinContext( + ctx context.Context, personID int64, target personfacts.TargetRef, + pinned bool, actor string, +) (*personfacts.PinWrite, error) { + actor = strings.TrimSpace(actor) + if actor == "" { + return nil, errors.New("person fact pin actor is required") + } + return retryContendedWrite(ctx, s, "set person fact pin", + func() (*personfacts.PinWrite, error) { + return s.setPersonFactPinOnce(ctx, personID, target, pinned, actor) + }) +} + +func (s *Store) setPersonFactPinOnce( + ctx context.Context, personID int64, target personfacts.TargetRef, + pinned bool, actor string, +) (*personfacts.PinWrite, error) { + write := &personfacts.PinWrite{ + Resolutions: []personfacts.ResolutionResult{}, Projections: []personfacts.ProjectionRef{}, + } + err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-generation", personID); err != nil { + return err + } + if err := verifyTrackedPersonFactPersonTx(ctx, tx, personID); err != nil { + return err + } + descriptor, eligibility, err := s.loadPersonFactTargetDescriptorTx( + ctx, tx, target.Kind, target.Key, nil, true) + if err != nil { + return err + } + if !eligibility.Supported { + return fmt.Errorf("person fact target %s/%s is not active", target.Kind, target.Key) + } + currentRef := personfacts.TargetRef{ + Kind: descriptor.Kind, Key: descriptor.Key, Revision: descriptor.Revision, + } + if target != currentRef { + return errors.New("person fact pin target descriptor is stale") + } + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-target", personID, target.Kind, target.Key); err != nil { + return err + } + projector, err := s.personFactProjectorTx(ctx, tx, descriptor) + if err != nil { + return err + } + current, err := projector.loadCurrent(ctx, personID, descriptor) + if err != nil { + return err + } + state, err := s.effectivePersonFactPinTx(ctx, tx, personID, descriptor, current) + if err != nil { + return err + } + if state.Pinned == pinned { + write.State = state + return nil + } + state, err = s.appendPersonFactPinEventTx(ctx, tx, personID, currentRef, pinned, actor) + if err != nil { + return err + } + write.State = state + + policy := personfacts.PolicyContext{AllowSensitive: descriptor.Sensitive} + generationID, exists, err := latestPersonFactTargetGenerationIDTx( + ctx, tx, personID, target.Kind, target.Key) + if err != nil { + return err + } + if exists { + latest, loadErr := s.loadPersonFactGenerationByIDTx(ctx, tx, generationID) + if loadErr != nil { + return loadErr + } + policy.ProviderPolicyFingerprint = latest.Policy.ProviderPolicyFingerprint + } + actionTime := time.Now().UTC() + catalog, err := s.buildPersonFactCatalogContext(ctx, tx, descriptor.Sensitive) + if err != nil { + return err + } + prepared, err := preparePersonFactPinGeneration( + ctx, personID, descriptor, state, policy, catalog.Fingerprint, actionTime) + if err != nil { + return err + } + generation, replay, err := s.insertPersonFactGenerationTx(ctx, tx, prepared) + if err != nil { + return err + } + if replay { + return errors.New("person fact pin generation unexpectedly replayed for a new pin event") + } + changed, err := s.resolvePersonFactTargetTx( + ctx, tx, generation, descriptor, eligibility, policy, nil, actionTime) + if err != nil { + return err + } + if changed { + if err := s.bumpPersonVCardProjectionsTx(ctx, tx, personID); err != nil { + return err + } + } + result, err := s.loadPersonFactGenerationResultTx(ctx, tx, personID, generation.GenerationKey) + if err != nil { + return err + } + for _, resolution := range result.Resolutions { + if resolution.Target.Kind == target.Kind && resolution.Target.Key == target.Key { + write.Resolutions = append(write.Resolutions, resolution) + } + } + if len(write.Resolutions) > 1 { + write.Resolutions = write.Resolutions[len(write.Resolutions)-1:] + } + projectionSet := make(map[personfacts.ProjectionRef]struct{}) + for _, resolution := range write.Resolutions { + for _, projection := range resolution.Projections { + projectionSet[projection] = struct{}{} + } + } + for projection := range projectionSet { + write.Projections = append(write.Projections, projection) + } + sortPersonFactProjectionRefs(write.Projections) + return nil + }) + if err != nil { + return nil, err + } + return write, nil +} + +func preparePersonFactPinGeneration( + ctx context.Context, personID int64, target personfacts.TargetDescriptor, + state personfacts.PinState, policy personfacts.PolicyContext, + catalogFingerprint string, resolvedAt time.Time, +) (personfacts.PreparedGeneration, error) { + if state.EventID == nil { + return personfacts.PreparedGeneration{}, errors.New("person fact pin generation requires a durable pin event") + } + programFingerprint := fmt.Sprintf("%x", sha256.Sum256([]byte("msgvault-person-fact-pin/v1"))) + return personfacts.PreparePersonFactGeneration(ctx, personfacts.GenerationInput{ + PersonID: personID, + SourceCursors: []personfacts.SourceCursor{{ + Lane: "person-fact-pin:" + string(target.Kind) + ":" + target.Key, + Start: fmt.Sprintf("event:%d", *state.EventID), + End: fmt.Sprintf("pinned:%t@%s", state.Pinned, target.Revision), + }}, + ProgramID: "msgvault-person-fact-pin", + ProgramVersion: "v1", + ProgramFingerprint: programFingerprint, + CatalogFingerprint: catalogFingerprint, + Provider: "msgvault", + ProviderVersion: "person-fact-pin-v1", + ResolvedAt: resolvedAt, + Policy: policy, + }, nil) +} + +const personFactPinAttributeTargetCandidatesSQL = ` + SELECT d.universal_id + FROM person_attribute_values v + JOIN attribute_definitions d ON d.id = v.definition_id + WHERE v.person_id = ? AND v.active_until IS NULL AND v.superseded_at IS NULL + AND d.is_active = TRUE AND d.api_mutable = TRUE + AND d.derived_source IS NULL AND d.record_target IS NULL + AND LENGTH(TRIM(COALESCE(d.description, ''))) BETWEEN 1 AND 280 + AND d.value_type IN ('text', 'integer', 'real', 'boolean', 'date', 'timestamp') + AND (v.source IN (?, ?, ?) + OR NOT EXISTS ( + SELECT 1 + FROM person_fact_decisions fd + WHERE fd.person_id = v.person_id + AND fd.projection_kind = ? + AND fd.projection_row_id = v.id + )) + ORDER BY v.definition_id, v.ordinal` + +const personFactPinEmploymentTargetCandidateSQL = ` + SELECT EXISTS ( + SELECT 1 FROM employments e + WHERE e.person_id = ? AND e.source IN (?, ?, ?) + ) OR EXISTS ( + SELECT 1 FROM employments e + WHERE e.person_id = ? AND e.is_current = TRUE + AND NOT EXISTS ( + SELECT 1 + FROM person_fact_decisions fd + WHERE fd.person_id = e.person_id + AND fd.projection_kind = ? + AND fd.projection_row_id = e.id + AND e.source_ref = ? || fd.decision_key + ) + )` + +func (s *Store) ListPersonFactPinsContext( + ctx context.Context, personID int64, +) ([]personfacts.PinState, error) { + states := make([]personfacts.PinState, 0) + err := s.withReadSnapshotContext(ctx, func(tx *loggedTx) error { + var personExists int + if err := tx.QueryRowContext(ctx, + `SELECT COUNT(*) FROM persons WHERE id = ?`, personID).Scan(&personExists); err != nil { + return fmt.Errorf("verify person %d for fact pins: %w", personID, err) + } + if personExists == 0 { + return ErrPersonNotFound + } + targetKeys := make(map[string]personFactTouchedTarget) + latestEvents := make(map[string]personfacts.PinState) + rows, err := tx.QueryContext(ctx, ` + SELECT latest.id, latest.target_kind, latest.target_key, + latest.target_revision, latest.pinned, latest.actor + FROM person_fact_pin_events latest + WHERE latest.person_id = ? AND latest.id = ( + SELECT MAX(candidate.id) FROM person_fact_pin_events candidate + WHERE candidate.person_id = latest.person_id + AND candidate.target_kind = latest.target_kind + AND candidate.target_key = latest.target_key) + ORDER BY latest.target_kind, latest.target_key`, personID) + if err != nil { + return fmt.Errorf("list person fact pin targets: %w", err) + } + for rows.Next() { + var target personFactTouchedTarget + var state personfacts.PinState + var eventID int64 + if scanErr := rows.Scan(&eventID, &target.Kind, &target.Key, + &state.Target.Revision, &state.Pinned, &state.Actor); scanErr != nil { + _ = rows.Close() + return fmt.Errorf("scan person fact pin target: %w", scanErr) + } + state.Target.Kind = target.Kind + state.Target.Key = target.Key + state.EventID = &eventID + mapKey := personFactTargetMapKey(target.Kind, target.Key) + targetKeys[mapKey] = target + latestEvents[mapKey] = state + } + if rowsErr := rows.Err(); rowsErr != nil { + _ = rows.Close() + return fmt.Errorf("iterate person fact pin targets: %w", rowsErr) + } + if closeErr := rows.Close(); closeErr != nil { + return fmt.Errorf("close person fact pin targets: %w", closeErr) + } + + attributeRows, err := tx.QueryContext(ctx, personFactPinAttributeTargetCandidatesSQL, personID, + ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport, "person_attribute") + if err != nil { + return fmt.Errorf("list protected person fact pin targets: %w", err) + } + for attributeRows.Next() { + var key string + if scanErr := attributeRows.Scan(&key); scanErr != nil { + _ = attributeRows.Close() + return fmt.Errorf("scan protected person fact pin target: %w", scanErr) + } + mapKey := personFactTargetMapKey(personfacts.TargetAttribute, key) + if _, exists := targetKeys[mapKey]; !exists { + targetKeys[mapKey] = personFactTouchedTarget{Kind: personfacts.TargetAttribute, Key: key} + } + } + if rowsErr := attributeRows.Err(); rowsErr != nil { + _ = attributeRows.Close() + return fmt.Errorf("iterate protected person fact pin targets: %w", rowsErr) + } + if closeErr := attributeRows.Close(); closeErr != nil { + return fmt.Errorf("close protected person fact pin targets: %w", closeErr) + } + var hasProtectedEmployment bool + if err := tx.QueryRowContext(ctx, personFactPinEmploymentTargetCandidateSQL, + personID, ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport, + personID, "employment", personFactDecisionSourceRefPrefix, + ).Scan(&hasProtectedEmployment); err != nil { + return fmt.Errorf("list protected employment fact pin target: %w", err) + } + if hasProtectedEmployment { + target := personFactTouchedTarget{ + Kind: personfacts.TargetEmployment, Key: "system:employment", + } + targetKeys[personFactTargetMapKey(target.Kind, target.Key)] = target + } + + for _, target := range targetKeys { + descriptor, _, loadErr := s.loadPersonFactTargetDescriptorSnapshotTx( + ctx, tx, target.Kind, target.Key, nil, true) + if loadErr != nil { + return loadErr + } + projector, loadErr := s.personFactProjectorSnapshotTx(ctx, tx, descriptor) + if errors.Is(loadErr, ErrAttributeDefinitionNotFound) { + if state, exists := latestEvents[personFactTargetMapKey(target.Kind, target.Key)]; exists { + states = append(states, state) + continue + } + } + if loadErr != nil { + return loadErr + } + current, loadErr := projector.loadCurrent(ctx, personID, descriptor) + if loadErr != nil { + return loadErr + } + state, loadErr := s.effectivePersonFactPinTx(ctx, tx, personID, descriptor, current) + if loadErr != nil { + return loadErr + } + states = append(states, state) + } + sort.Slice(states, func(i, j int) bool { + if states[i].Target.Kind != states[j].Target.Kind { + return states[i].Target.Kind < states[j].Target.Kind + } + return states[i].Target.Key < states[j].Target.Key + }) + return nil + }) + if err != nil { + return nil, err + } + return states, nil +} + +func (s *Store) appendManualPersonFactAttributePinTx( + ctx context.Context, tx *loggedTx, definition AttributeDefinition, + personID int64, actor string, +) error { + if !personFactAttributePinSupported(definition) { + return nil + } + target, err := personFactAttributeTargetRef(definition) + if err != nil { + return err + } + _, err = s.appendPersonFactPinEventTx(ctx, tx, personID, target, true, actor) + return err +} + +func personFactAttributePinSupported(definition AttributeDefinition) bool { + if !definition.APIMutable || definition.DerivedSource != nil || definition.RecordTarget != nil { + return false + } + switch definition.ValueType { + case AttributeValueText, AttributeValueInteger, AttributeValueReal, + AttributeValueBoolean, AttributeValueDate, AttributeValueTimestamp: + return true + default: + return false + } +} + +func (s *Store) appendPersonFactPinEventTx( + ctx context.Context, tx *loggedTx, personID int64, target personfacts.TargetRef, + pinned bool, actor string, +) (personfacts.PinState, error) { + var eventID int64 + err := tx.QueryRowContext(ctx, ` + INSERT INTO person_fact_pin_events + (person_id, target_kind, target_key, target_revision, pinned, actor) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id`, personID, target.Kind, target.Key, target.Revision, pinned, actor, + ).Scan(&eventID) + if err != nil { + return personfacts.PinState{}, fmt.Errorf("append person fact pin event: %w", err) + } + return personfacts.PinState{ + Target: target, Pinned: pinned, Actor: actor, EventID: &eventID, + }, nil +} + +func (s *Store) effectivePersonFactPinTx( + ctx context.Context, tx *loggedTx, personID int64, + target personfacts.TargetDescriptor, current []personfacts.CurrentProjection, +) (personfacts.PinState, error) { + state := personfacts.PinState{Target: personfacts.TargetRef{ + Kind: target.Kind, Key: target.Key, Revision: target.Revision, + }} + var eventID int64 + var eventRevision string + err := tx.QueryRowContext(ctx, ` + SELECT id, target_revision, pinned, actor + FROM person_fact_pin_events + WHERE person_id = ? AND target_kind = ? AND target_key = ? + ORDER BY id DESC LIMIT 1`, personID, target.Kind, target.Key, + ).Scan(&eventID, &eventRevision, &state.Pinned, &state.Actor) + if err == nil { + state.EventID = &eventID + return state, nil + } + if !errors.Is(err, sql.ErrNoRows) { + return personfacts.PinState{}, fmt.Errorf("load effective person fact pin: %w", err) + } + if target.Kind == personfacts.TargetEmployment { + if err := tx.QueryRowContext(ctx, ` + SELECT EXISTS ( + SELECT 1 FROM employments + WHERE person_id = ? AND source IN (?, ?, ?) + )`, personID, ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport, + ).Scan(&state.Pinned); err != nil { + return personfacts.PinState{}, fmt.Errorf("load declared employment fact pin: %w", err) + } + } else { + for _, projection := range current { + if projection.Declared { + state.Pinned = true + break + } + } + } + if state.Pinned { + return state, nil + } + for _, projection := range current { + owned, ownershipErr := ownsPersonFactProjectionTx( + ctx, tx, personID, projection.Ref) + if ownershipErr != nil { + return personfacts.PinState{}, ownershipErr + } + if !owned { + state.Pinned = true + break + } + } + return state, nil +} + +func latestPersonFactTargetGenerationIDTx( + ctx context.Context, tx *loggedTx, personID int64, + kind personfacts.TargetKind, key string, +) (int64, bool, error) { + var generationID int64 + err := tx.QueryRowContext(ctx, ` + SELECT c.generation_id FROM person_fact_claims c + WHERE c.person_id = ? AND c.target_kind = ? AND c.target_key = ? + ORDER BY c.generation_id DESC, c.id DESC LIMIT 1`, personID, kind, key, + ).Scan(&generationID) + if errors.Is(err, sql.ErrNoRows) { + return 0, false, nil + } + if err != nil { + return 0, false, fmt.Errorf("load latest person fact target generation: %w", err) + } + return generationID, true, nil +} diff --git a/internal/store/person_fact_pins_test.go b/internal/store/person_fact_pins_test.go new file mode 100644 index 000000000..ba55d26cc --- /dev/null +++ b/internal/store/person_fact_pins_test.go @@ -0,0 +1,959 @@ +package store + +import ( + "bytes" + "encoding/json" + "fmt" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPersonFactImplicitPinUnpinRerunsAndManualSetRepins(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + activeFrom := personFactLedgerNow.Add(-24 * time.Hour) + manual, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("email")}, + ActiveFrom: &activeFrom, Source: ProvenanceUser, + }) + require.NoError(err) + _, err = st.db.Exec(`DELETE FROM person_fact_pin_events WHERE person_id = ?`, personID) + require.NoError(err, "simulate a declared value that predates the pin ledger") + input := personFactProjectionInput(personID, "implicit-pin", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "implicit-pin"), + }, nil) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.True(pins[0].Pinned) + assert.Nil(pins[0].EventID) + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, false, "test") + require.NoError(err) + assert.False(unpinned.State.Pinned) + assert.NotNil(unpinned.State.EventID) + assert.NotEmpty(unpinned.Projections) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("chat", *values[0].Value.Text) + assert.NotEqual(manual.Value.ID, values[0].ID) + + _, err = st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("phone")}, + Source: ProvenanceUser, + }) + require.NoError(err) + pins, err = st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.True(pins[0].Pinned) + assert.NotNil(pins[0].EventID) +} + +func TestPersonFactManualNoteAppendRepinsAfterExplicitUnpin(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, AttributeSlugNotes) + + _, err := st.AppendPersonNoteContext(t.Context(), PersonNoteAppendInput{ + PersonID: personID, Text: "first curated note", Source: ProvenanceUser, + }) + require.NoError(err) + _, err = st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + false, "test") + require.NoError(err) + _, err = st.AppendPersonNoteContext(t.Context(), PersonNoteAppendInput{ + PersonID: personID, Text: "second curated note", Source: ProvenanceUser, + }) + require.NoError(err) + + input := personFactProjectionInput(personID, "manual-note-repin", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"automatic replacement"`, "manual-note-repin"), + }, nil) + input.Policy.AllowSensitive = true + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugNotes}) + require.NoError(err) + require.Len(values, 1) + require.NotNil(values[0].Value.Text) + assert.Equal("first curated note\nsecond curated note", *values[0].Value.Text) +} + +func TestPersonFactExplicitUnpinOverridesUnownedDerivedAttributeProtection(t *testing.T) { + for _, source := range []Provenance{ + ProvenanceExtraction, + ProvenanceEnrichment, + ProvenanceSystem, + } { + for _, operation := range []struct { + name string + value string + relation personfacts.ClaimRelation + }{ + {name: "replacement", value: `"email"`, relation: personfacts.RelationSupport}, + {name: "explicit retirement", value: `"chat"`, relation: personfacts.RelationSupersede}, + } { + t.Run(string(source)+"/"+operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + activeFrom := personFactLedgerNow.Add(-24 * time.Hour) + confidence := 0.8 + unowned, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("chat")}, + ActiveFrom: &activeFrom, Source: source, Confidence: &confidence, + }) + require.NoError(err) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.True(pins[0].Pinned) + assert.Nil(pins[0].EventID) + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + false, "test") + require.NoError(err) + assert.False(unpinned.State.Pinned) + assert.NotNil(unpinned.State.EventID) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_pin_events")) + pins, err = st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.False(pins[0].Pinned) + assert.Equal(unpinned.State.EventID, pins[0].EventID) + + claim := personFactProjectionClaim(personID, target, operation.value, + "unowned-unpin-"+string(source)+"-"+operation.name) + claim.Relation = operation.relation + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unowned-unpin-"+string(source)+"-"+operation.name, + []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionApplied, result.Decisions[0].Action) + assert.NotEmpty(result.Projections) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + if operation.relation == personfacts.RelationSupport { + require.Len(current, 1) + assert.NotEqual(unowned.Value.ID, current[0].ID) + assert.Equal("email", *current[0].Value.Text) + } else { + assert.Empty(current) + } + }) + } + } +} + +func TestListPersonFactPinsDoesNotInferPinForFactOwnedDerivedAttribute(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, target, result, evidenceKey := seedProjectedPersonFact(t) + assert.Equal(personfacts.TargetAttribute, target.Kind) + require.NotEmpty(result.Projections) + assert.NotEmpty(evidenceKey) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + assert.Empty(pins) +} + +func TestListPersonFactPinsAttributeDiscoveryUsesCurrentIndex(t *testing.T) { + st, personID, _ := newPersonFactProjectionStore(t) + if st.IsPostgreSQL() { + t.Skip("SQLite query-plan coverage; PostgreSQL uses the equivalent partial current-row index") + } + plan := explainPlan(t, st, personFactPinAttributeTargetCandidatesSQL, personID, + ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport, "person_attribute") + t.Logf("query plan:\n%s", plan) + assert.Contains(t, plan, "USING INDEX idx_person_attribute_values_current (person_id=?)", + "pin discovery must be bounded to one person's current attribute rows:\n%s", plan) + assert.Contains(t, plan, "USING COVERING INDEX idx_person_fact_decisions_projection", + "projection ownership checks must use the composite decision index:\n%s", plan) + assert.NotContains(t, plan, "TEMP B-TREE", + "unordered target discovery must not sort its bounded candidate set:\n%s", plan) +} + +func TestListPersonFactPinsEmploymentDiscoveryUsesCurrentAndOwnershipIndexes(t *testing.T) { + st, personID, _ := newPersonFactProjectionStore(t) + if st.IsPostgreSQL() { + t.Skip("SQLite query-plan coverage; PostgreSQL has equivalent person/current and ownership indexes") + } + plan := explainPlan(t, st, personFactPinEmploymentTargetCandidateSQL, + personID, ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport, + personID, "employment", personFactDecisionSourceRefPrefix) + t.Logf("query plan:\n%s", plan) + assert.Contains(t, plan, "idx_employments_person", + "employment pin discovery must be bounded to one person's rows:\n%s", plan) + assert.Contains(t, plan, "idx_person_fact_decisions_projection", + "employment ownership checks must use the composite decision index:\n%s", plan) +} + +func TestPersonFactManualSupersedePinsAbsence(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + write, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("email")}, Source: ProvenanceUser, + }) + require.NoError(err) + _, err = st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: targets[AttributeSlugPrimaryChannel].Kind, + Key: targets[AttributeSlugPrimaryChannel].Key, Revision: targets[AttributeSlugPrimaryChannel].Revision}, + false, "test") + require.NoError(err) + + _, err = st.SupersedePersonAttributeValueContext(t.Context(), PersonAttributeSupersedeInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + ExpectedValueID: &write.Value.ID, + }) + require.NoError(err) + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.True(pins[0].Pinned) + assert.NotNil(pins[0].EventID) +} + +func TestPersonFactManualSupersedePinsInactiveAttributeAbsence(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + seed := personFactProjectionInput(personID, "inactive-clear-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "inactive-clear-seed"), + }, nil) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), seed, nil) + require.NoError(err) + + definition, err := st.GetAttributeDefinitionBySlugContext( + t.Context(), AttributeObjectPerson, AttributeSlugPrimaryChannel) + require.NoError(err) + inactive := false + definition, err = st.UpdateAttributeDefinitionContext(t.Context(), definition.ID, definition.Revision, + AttributeDefinitionUpdate{IsActive: &inactive}) + require.NoError(err) + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + _, err = st.SupersedePersonAttributeValueContext(t.Context(), PersonAttributeSupersedeInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + ExpectedValueID: ¤t[0].ID, + }) + require.NoError(err) + + active := true + _, err = st.UpdateAttributeDefinitionContext(t.Context(), definition.ID, definition.Revision, + AttributeDefinitionUpdate{IsActive: &active}) + require.NoError(err) + target = projectionTargetBySlug(t, st, AttributeSlugPrimaryChannel) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "inactive-clear-resolve", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"email"`, "inactive-clear-resolve"), + }, nil), nil) + require.NoError(err) + require.NotEmpty(result.Decisions) + for _, decision := range result.Decisions { + assert.Equal(personfacts.ReasonPinRetained, decision.Reason) + } + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(values) +} + +func TestPersonFactExplicitPinSurvivesAutomaticValueChanges(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + pin, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, true, "test") + require.NoError(err) + require.NotNil(pin.State.EventID) + + input := personFactProjectionInput(personID, "explicit-pin", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "explicit-pin"), + }, nil) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.Equal(pin.State.EventID, pins[0].EventID) +} + +func TestPersonFactPinnedAutomaticProjectionSurvivesStatusInvalidationUntilUnpinned(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, target, _, evidenceKey := seedProjectedPersonFact(t) + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + pinnedRowID := current[0].ID + _, err = st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, true, "test") + require.NoError(err) + beforeRevision := personFactProjectionRevision(t, st, personID) + + status := personFactProjectionInput(personID, "pinned-status-off", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + _, err = st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + current, err = st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + assert.Equal(pinnedRowID, current[0].ID) + assert.Equal(beforeRevision, personFactProjectionRevision(t, st, personID)) + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, false, "test") + require.NoError(err) + assert.NotEmpty(unpinned.Projections) + current, err = st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(current) + assert.Equal(beforeRevision+1, personFactProjectionRevision(t, st, personID)) +} + +func TestPersonFactPinReresolutionUsesHostGenerationAndPreservesProviderReplay(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, target, providerResult, _ := seedProjectedPersonFact(t) + providerJSON, err := json.Marshal(providerResult) + require.NoError(err) + providerCounts := personFactGenerationRowCounts(t, st, providerResult.GenerationID) + ref := personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision} + + pinned, err := st.SetPersonFactPinContext(t.Context(), personID, ref, true, "test") + require.NoError(err) + require.Len(pinned.Resolutions, 1) + assertPersonFactPinHostGeneration(t, st, pinned.Resolutions[0].ID, + providerResult.GenerationID, target, *pinned.State.EventID, true) + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, ref, false, "test") + require.NoError(err) + require.Len(unpinned.Resolutions, 1) + assertPersonFactPinHostGeneration(t, st, unpinned.Resolutions[0].ID, + providerResult.GenerationID, target, *unpinned.State.EventID, false) + assert.NotEqual(pinned.Resolutions[0].ID, unpinned.Resolutions[0].ID) + + providerInput := personFactProjectionInput(personID, "seed-"+AttributeSlugPrimaryChannel, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "seed-"+AttributeSlugPrimaryChannel), + }, nil) + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), providerInput, nil) + require.NoError(err) + replayedJSON, err := json.Marshal(replayed) + require.NoError(err) + assert.True(bytes.Equal(providerJSON, replayedJSON), "provider replay bytes differ") + assert.Equal(providerCounts, personFactGenerationRowCounts(t, st, providerResult.GenerationID)) + assert.Equal(int64(3), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestPersonFactExplicitPinSurvivesDescriptorRevisionChange(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + pin, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, true, "test") + require.NoError(err) + + _, err = st.db.Exec(`UPDATE attribute_definitions SET description = ? WHERE universal_id = ?`, + "Updated extraction guidance", target.Key) + require.NoError(err) + updated := projectionTargetBySlug(t, st, AttributeSlugPrimaryChannel) + require.NotEqual(target.Revision, updated.Revision) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.True(pins[0].Pinned) + assert.Equal(pin.State.EventID, pins[0].EventID) + assert.Equal(updated.Revision, pins[0].Target.Revision) +} + +func TestListPersonFactPinsRetainsEventForDeletedAttributeDefinition(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + description := "Disposable inference target" + definition, err := st.CreateAttributeDefinitionContext(t.Context(), AttributeDefinitionInput{ + UniversalID: "test-deleted-pin-target", ObjectType: AttributeObjectPerson, + Slug: "deleted_pin_target", Label: "Deleted pin target", Description: &description, + ValueType: AttributeValueText, FieldType: AttributeFieldText, + Cardinality: AttributeCardinalitySingle, Ownership: AttributeOwnershipUser, + APIMutable: true, IsDeletable: true, + }) + require.NoError(err) + _, err = st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: definition.Slug, + Value: AttributeValue{Type: AttributeValueText, Text: new("temporary")}, + Source: ProvenanceExtraction, + }) + require.NoError(err) + target := projectionTargetBySlug(t, st, definition.Slug) + _, err = st.SetPersonFactPinContext(t.Context(), personID, personfacts.TargetRef{ + Kind: target.Kind, Key: target.Key, Revision: target.Revision, + }, false, "test") + require.NoError(err) + pin, err := st.SetPersonFactPinContext(t.Context(), personID, personfacts.TargetRef{ + Kind: target.Kind, Key: target.Key, Revision: target.Revision, + }, true, "test") + require.NoError(err) + + _, err = st.db.Exec(`DELETE FROM person_attribute_values WHERE definition_id = ?`, definition.ID) + require.NoError(err) + require.NoError(st.DeleteAttributeDefinitionContext( + t.Context(), definition.ID, definition.Revision)) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.Equal(pin.State, pins[0]) +} + +func TestPersonFactHistoricalDeclaredEmploymentIsEffectivelyPinnedUntilExplicitUnpin(t *testing.T) { + for _, source := range []Provenance{ProvenanceUser, ProvenanceCardDAVImport, ProvenanceVCardImport} { + t.Run(string(source), func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + sourceSlug := strings.ReplaceAll(string(source), "_", "-") + declaredOrganization := createPersonFactOrganization(t, st, "Declared "+string(source), "declared-"+sourceSlug+".example") + endDate := PartialDate{Year: new(2024), Month: new(6)} + declared, err := st.AddEmploymentContext(t.Context(), EmploymentInput{ + PersonID: personID, OrganizationID: declaredOrganization.ID, + Title: new("Engineer"), EndDate: &endDate, Source: source, + }) + require.NoError(err) + assert.False(declared.IsCurrent) + _, err = st.db.Exec(`DELETE FROM person_fact_pin_events WHERE person_id = ?`, personID) + require.NoError(err, "simulate declared employment predating the pin ledger") + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.Equal(personfacts.TargetEmployment, pins[0].Target.Kind) + assert.True(pins[0].Pinned) + assert.Nil(pins[0].EventID) + + automaticOrganization := createPersonFactOrganization(t, st, "Automatic "+string(source), "automatic-"+sourceSlug+".example") + submitted := fmt.Sprintf(`{"organization":{"id":%d,"name":"Automatic %s","domain":"automatic-%s.example"},"title":"Advisor"}`, + automaticOrganization.ID, source, sourceSlug) + input := personFactProjectionInput(personID, "declared-pin-"+string(source), []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, submitted, "declared-pin"), + }, nil) + retained, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(retained.Decisions, 1) + assert.Equal(personfacts.ReasonPinRetained, retained.Decisions[0].Reason) + assert.Empty(retained.Projections) + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + false, "test") + require.NoError(err) + assert.False(unpinned.State.Pinned) + assert.NotEmpty(unpinned.Projections) + pins, err = st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + require.Len(pins, 1) + assert.False(pins[0].Pinned) + assert.NotNil(pins[0].EventID) + }) + } +} + +func TestPersonFactUnownedDerivedEmploymentIsImplicitlyPinned(t *testing.T) { + for _, source := range []Provenance{ + ProvenanceExtraction, + ProvenanceEnrichment, + ProvenanceSystem, + } { + for _, operation := range []struct { + name string + relation personfacts.ClaimRelation + role string + }{ + {name: "replacement", relation: personfacts.RelationSupport, role: "Infrastructure"}, + {name: "end", relation: personfacts.RelationSupersede, role: "Platform"}, + } { + t.Run(string(source)+"/"+operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Unowned Employment", "unowned-employment.example") + start := PartialDate{Year: new(2020)} + unowned, err := st.AddEmploymentContext(t.Context(), EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Engineer"), Role: new("Platform"), StartDate: &start, + Source: source, + }) + require.NoError(err) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + assert.Len(pins, 1) + if len(pins) == 1 { + assert.Equal(personfacts.TargetEmployment, pins[0].Target.Kind) + assert.True(pins[0].Pinned) + assert.Nil(pins[0].EventID) + } + + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Unowned Employment","domain":"unowned-employment.example"},"title":"Engineer","role":%q,"start_date":{"year":2020}}`, + organization.ID, operation.role) + claim := personFactProjectionClaim( + personID, target, submitted, "unowned-employment-"+operation.name) + claim.Relation = operation.relation + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, + "unowned-employment-"+string(source)+"-"+operation.name, + []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + current, err := st.GetEmploymentContext(t.Context(), unowned.ID) + require.NoError(err) + assert.True(current.IsCurrent) + require.NotNil(current.Role) + assert.Equal("Platform", *current.Role) + assert.Equal(int64(1), current.Revision) + }) + } + } +} + +func TestPersonFactExplicitUnpinOverridesUnownedDerivedEmploymentProtection(t *testing.T) { + for _, operation := range []struct { + name string + relation personfacts.ClaimRelation + role string + }{ + {name: "replacement", relation: personfacts.RelationSupport, role: "Infrastructure"}, + {name: "end", relation: personfacts.RelationSupersede, role: "Platform"}, + } { + t.Run(operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Unpinned Employment", "unpinned-employment.example") + start := PartialDate{Year: new(2020)} + unowned, err := st.AddEmploymentContext(t.Context(), EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: new("Engineer"), Role: new("Platform"), StartDate: &start, + Source: ProvenanceExtraction, + }) + require.NoError(err) + + unpinned, err := st.SetPersonFactPinContext(t.Context(), personID, + personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision}, + false, "test") + require.NoError(err) + assert.False(unpinned.State.Pinned) + assert.NotNil(unpinned.State.EventID) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_pin_events")) + + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Unpinned Employment","domain":"unpinned-employment.example"},"title":"Engineer","role":%q,"start_date":{"year":2020}}`, + organization.ID, operation.role) + claim := personFactProjectionClaim( + personID, target, submitted, "unpinned-employment-"+operation.name) + claim.Relation = operation.relation + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unpinned-employment-"+operation.name, + []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionApplied, result.Decisions[0].Action) + assert.NotEmpty(result.Projections) + + current, err := st.GetEmploymentContext(t.Context(), unowned.ID) + require.NoError(err) + assert.Equal(int64(2), current.Revision) + if operation.relation == personfacts.RelationSupport { + assert.True(current.IsCurrent) + require.NotNil(current.Role) + assert.Equal("Infrastructure", *current.Role) + } else { + assert.False(current.IsCurrent) + require.NotNil(current.EndDate) + } + }) + } +} + +func TestPersonFactOwnedEmploymentRemainsAutomaticallyMutable(t *testing.T) { + for _, operation := range []struct { + name string + relation personfacts.ClaimRelation + role string + }{ + {name: "replacement", relation: personfacts.RelationSupport, role: "Infrastructure"}, + {name: "end", relation: personfacts.RelationSupersede, role: "Platform"}, + } { + t.Run(operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Owned Employment", "owned-employment.example") + platform := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Owned Employment","domain":"owned-employment.example"},"title":"Engineer","role":"Platform","start_date":{"year":2020}}`, + organization.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "owned-employment-seed", + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, platform, "owned-employment-seed"), + }, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + rowID := seed.Projections[0].RowID + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + assert.Empty(pins) + + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Owned Employment","domain":"owned-employment.example"},"title":"Engineer","role":%q,"start_date":{"year":2020}}`, + organization.ID, operation.role) + claim := personFactProjectionClaim( + personID, target, submitted, "owned-employment-"+operation.name) + claim.Relation = operation.relation + input := personFactProjectionInput(personID, "owned-employment-"+operation.name, + []personfacts.ProposedClaim{claim}, nil) + input.ResolvedAt = personFactLedgerNow.Add(time.Hour) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionApplied, result.Decisions[0].Action) + assert.NotEmpty(result.Projections) + + current, err := st.GetEmploymentContext(t.Context(), rowID) + require.NoError(err) + assert.Equal(int64(2), current.Revision) + if operation.relation == personfacts.RelationSupport { + assert.True(current.IsCurrent) + require.NotNil(current.Role) + assert.Equal("Infrastructure", *current.Role) + } else { + assert.False(current.IsCurrent) + require.NotNil(current.EndDate) + } + }) + } +} + +func TestPersonFactEmploymentUpdatesPinProjectedRows(t *testing.T) { + for _, operation := range []struct { + name, updatedRole string + relation personfacts.ClaimRelation + role string + preserveProvenance bool + }{ + {name: "replacement", updatedRole: "Externally Enriched", relation: personfacts.RelationSupport, role: "Automatic Replacement"}, + {name: "end", updatedRole: "Externally Enriched", relation: personfacts.RelationSupersede, role: "Externally Enriched"}, + {name: "preserved provenance", updatedRole: "Manual Role", relation: personfacts.RelationSupport, + role: "Automatic Replacement", preserveProvenance: true}, + } { + t.Run(operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Externally Updated Employment", "external-update.example") + seedValue := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Externally Updated Employment","domain":"external-update.example"},"title":"Engineer","role":"Platform","start_date":{"year":2020}}`, + organization.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "external-update-seed-"+operation.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, seedValue, + "external-update-seed-"+operation.name), + }, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + projected, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + require.NoError(err) + + externalSourceRef := "external-enrichment:" + operation.name + externalConfidence := 0.95 + source := ProvenanceEnrichment + sourceRef := &externalSourceRef + confidence := &externalConfidence + if operation.preserveProvenance { + source = projected.Source + sourceRef = projected.SourceRef + confidence = projected.Confidence + } + externallyUpdated, err := st.UpdateEmploymentContext( + t.Context(), projected.ID, projected.Revision, EmploymentInput{ + PersonID: personID, OrganizationID: organization.ID, + Title: projected.Title, Role: new(operation.updatedRole), + StartDate: projected.StartDate, Source: source, + SourceRef: sourceRef, Confidence: confidence, + }) + require.NoError(err) + assert.Equal(projected.ID, externallyUpdated.ID) + assert.Equal(projected.Revision+1, externallyUpdated.Revision) + + pins, err := st.ListPersonFactPinsContext(t.Context(), personID) + require.NoError(err) + assert.Len(pins, 1) + if len(pins) == 1 { + assert.Equal(personfacts.TargetEmployment, pins[0].Target.Kind) + assert.True(pins[0].Pinned) + assert.NotNil(pins[0].EventID) + } + + claimValue := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Externally Updated Employment","domain":"external-update.example"},"title":"Engineer","role":%q,"start_date":{"year":2020}}`, + organization.ID, operation.role) + claim := personFactProjectionClaim( + personID, target, claimValue, "external-update-claim-"+operation.name) + claim.Relation = operation.relation + input := personFactProjectionInput(personID, "external-update-generation-"+operation.name, + []personfacts.ProposedClaim{claim}, nil) + input.ResolvedAt = personFactLedgerNow.Add(time.Hour) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + retained, err := st.GetEmploymentContext(t.Context(), projected.ID) + require.NoError(err) + assert.Equal(externallyUpdated.Revision, retained.Revision) + assert.True(retained.IsCurrent) + require.NotNil(retained.Role) + assert.Equal(operation.updatedRole, *retained.Role) + }) + } +} + +func TestPersonFactDerivedEmploymentDetachmentDurablyPinsAffectedPeople(t *testing.T) { + for _, operation := range []struct { + name string + reassign bool + }{ + {name: "end"}, + {name: "reassign", reassign: true}, + } { + t.Run(operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, originalPersonID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, "employment") + organization := createPersonFactOrganization( + t, st, "Detached Employment", "detached-employment.example") + submitted := fmt.Sprintf( + `{"organization":{"id":%d,"name":"Detached Employment","domain":"detached-employment.example"},"title":"Engineer","role":"Platform","start_date":{"year":2020}}`, + organization.ID) + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(originalPersonID, "detachment-seed-"+operation.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(originalPersonID, target, submitted, + "detachment-seed-"+operation.name), + }, nil), nil) + require.NoError(err) + require.Len(seed.Projections, 1) + projected, err := st.GetEmploymentContext(t.Context(), seed.Projections[0].RowID) + require.NoError(err) + + destinationPersonID := originalPersonID + affectedPersonIDs := []int64{originalPersonID} + var endDate *PartialDate + if operation.reassign { + participantID, ensureErr := st.EnsureParticipant( + "detached-destination@example.com", "Detached Destination", "example.com") + require.NoError(ensureErr) + destination, _, createErr := st.CreatePersonFromParticipant(participantID) + require.NoError(createErr) + _, trackingErr := st.SetPersonTrackingContext(t.Context(), destination.ID, true) + require.NoError(trackingErr) + destinationPersonID = destination.ID + affectedPersonIDs = append(affectedPersonIDs, destinationPersonID) + } else { + endDate = &PartialDate{Year: new(2025), Month: new(6)} + } + + externalSourceRef := "external-enrichment:" + operation.name + externalConfidence := 0.95 + updated, err := st.UpdateEmploymentContext( + t.Context(), projected.ID, projected.Revision, EmploymentInput{ + PersonID: destinationPersonID, OrganizationID: organization.ID, + Title: projected.Title, Role: projected.Role, StartDate: projected.StartDate, + EndDate: endDate, Source: ProvenanceEnrichment, + SourceRef: &externalSourceRef, Confidence: &externalConfidence, + }) + require.NoError(err) + assert.Equal(projected.ID, updated.ID) + assert.Equal(projected.Revision+1, updated.Revision) + + for index, affectedPersonID := range affectedPersonIDs { + suffix := fmt.Sprintf("detachment-replay-%s-%d", operation.name, index) + input := personFactProjectionInput(affectedPersonID, suffix, + []personfacts.ProposedClaim{ + personFactProjectionClaim(affectedPersonID, target, submitted, suffix), + }, nil) + input.ResolvedAt = personFactLedgerNow.Add(time.Hour) + result, applyErr := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(applyErr) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + pins, listErr := st.ListPersonFactPinsContext(t.Context(), affectedPersonID) + require.NoError(listErr) + if assert.Len(pins, 1) { + assert.Equal(personfacts.TargetEmployment, pins[0].Target.Kind) + assert.True(pins[0].Pinned) + assert.NotNil(pins[0].EventID, "detachment protection must be durable") + } + } + + originalEmployments, err := st.ListEmploymentsContext(t.Context(), EmploymentFilter{ + PersonID: originalPersonID, + }) + require.NoError(err) + if operation.reassign { + assert.Empty(originalEmployments) + destinationEmployments, listErr := st.ListEmploymentsContext( + t.Context(), EmploymentFilter{PersonID: destinationPersonID}) + require.NoError(listErr) + require.Len(destinationEmployments, 1) + assert.Equal(projected.ID, destinationEmployments[0].ID) + assert.Equal(updated.Revision, destinationEmployments[0].Revision) + } else { + require.Len(originalEmployments, 1) + assert.Equal(projected.ID, originalEmployments[0].ID) + assert.Equal(updated.Revision, originalEmployments[0].Revision) + assert.False(originalEmployments[0].IsCurrent) + } + }) + } +} + +func TestSetPersonFactPinRejectsBlankActorAndSkipsDuplicateEvent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + ref := personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision} + _, err := st.SetPersonFactPinContext(t.Context(), personID, ref, true, " ") + require.Error(err) + first, err := st.SetPersonFactPinContext(t.Context(), personID, ref, true, "test") + require.NoError(err) + second, err := st.SetPersonFactPinContext(t.Context(), personID, ref, true, "test-again") + require.NoError(err) + assert.Equal(first.State.EventID, second.State.EventID) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_pin_events")) +} + +func TestSetPersonFactPinRejectsUntrackedPersonWithoutWrites(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + ref := personfacts.TargetRef{Kind: target.Kind, Key: target.Key, Revision: target.Revision} + pinned, err := st.SetPersonFactPinContext(t.Context(), personID, ref, true, "test") + requirements.NoError(err) + requirements.True(pinned.State.Pinned) + beforeEvents := personFactProjectionRowCount(t, st, "person_fact_pin_events") + beforeGenerations := personFactProjectionRowCount(t, st, "person_fact_generations") + + _, err = st.SetPersonTrackingContext(t.Context(), personID, false) + requirements.NoError(err) + _, err = st.SetPersonFactPinContext(t.Context(), personID, ref, false, "test") + requirements.ErrorIs(err, ErrPersonFactPersonNotTracked) + assertions.Equal(beforeEvents, personFactProjectionRowCount(t, st, "person_fact_pin_events")) + assertions.Equal(beforeGenerations, personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func assertPersonFactPinHostGeneration( + t *testing.T, st *Store, resolutionID, providerGenerationID int64, + target personfacts.TargetDescriptor, eventID int64, pinned bool, +) { + t.Helper() + assert := assert.New(t) + require := require.New(t) + var generationID int64 + var programID, provider, providerVersion, catalogFingerprint, policyFingerprint, cursorsJSON string + require.NoError(st.db.QueryRow(` + SELECT g.id, g.program_id, g.provider, g.provider_version, g.catalog_fingerprint, + g.provider_policy_fingerprint, g.source_cursors_json + FROM person_fact_resolutions r + JOIN person_fact_generations g ON g.id = r.generation_id + WHERE r.id = ?`, resolutionID).Scan( + &generationID, &programID, &provider, &providerVersion, &catalogFingerprint, + &policyFingerprint, &cursorsJSON)) + assert.NotEqual(providerGenerationID, generationID) + assert.Equal("msgvault-person-fact-pin", programID) + assert.Equal("msgvault", provider) + assert.Equal("person-fact-pin-v1", providerVersion) + wantCatalog, err := st.BuildPersonFactCatalogContext(t.Context(), target.Sensitive) + require.NoError(err) + assert.Equal(wantCatalog.Fingerprint, catalogFingerprint) + assert.Equal("policy-v1", policyFingerprint) + var cursors []personfacts.SourceCursor + require.NoError(json.Unmarshal([]byte(cursorsJSON), &cursors)) + require.Len(cursors, 1) + assert.Equal("person-fact-pin:"+string(target.Kind)+":"+target.Key, cursors[0].Lane) + assert.Equal(fmt.Sprintf("event:%d", eventID), cursors[0].Start) + assert.Equal(fmt.Sprintf("pinned:%t@%s", pinned, target.Revision), cursors[0].End) +} diff --git a/internal/store/person_fact_projection.go b/internal/store/person_fact_projection.go new file mode 100644 index 000000000..26622f653 --- /dev/null +++ b/internal/store/person_fact_projection.go @@ -0,0 +1,2656 @@ +package store + +import ( + "context" + "crypto/sha256" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + "time" + + "go.kenn.io/msgvault/internal/personfacts" +) + +var ErrPersonFactPersonNotTracked = errors.New("person is not tracked for automatic fact maintenance") + +type personFactTxRunner func(context.Context, func(*loggedTx) error) error + +type personFactTargetProjector interface { + loadCurrent(ctx context.Context, personID int64, target personfacts.TargetDescriptor) ([]personfacts.CurrentProjection, error) + projectionContext(ctx context.Context, personID int64, claims []personfacts.ResolvedClaim) (string, error) + project(ctx context.Context, plan personfacts.ProjectionPlan, claim personfacts.ResolvedClaim, + decisionKey string, transactionTime time.Time) (*personfacts.ProjectionRef, bool, error) +} + +type personFactAttributeProjector struct { + store *Store + tx *loggedTx + definition AttributeDefinition +} + +type personFactEmploymentProjector struct { + store *Store + tx *loggedTx + lock bool + organizationLocks *personFactOrganizationLockSet + consumed map[int64]struct{} +} + +type personFactRetiredProjection struct { + Ref personfacts.ProjectionRef + ClaimKey string +} + +const personFactDecisionSourceRefPrefix = "person-fact-decision:" +const personFactProjectionKindEmployment = "employment" + +type personFactTouchedTarget struct { + Kind personfacts.TargetKind + Key string + Fallback *personfacts.TargetDescriptor + FallbackClaimID int64 +} + +type personFactTargetEligibility struct { + Supported bool + SensitivePolicyBlocked bool +} + +type personFactClaimChronology struct { + ResolvedAt time.Time + GenerationID int64 +} + +func (s *Store) ApplyPersonFactGenerationContext( + ctx context.Context, input personfacts.GenerationInput, aligner personfacts.EvidenceAligner, +) (*personfacts.GenerationResult, error) { + prepared, err := personfacts.PreparePersonFactGeneration(ctx, input, aligner) + if err != nil { + return nil, err + } + return retryContendedWrite(ctx, s, "apply person fact generation", + func() (*personfacts.GenerationResult, error) { + var result *personfacts.GenerationResult + err := s.withTxContext(ctx, func(tx *loggedTx) error { + var applyErr error + result, applyErr = s.applyPreparedPersonFactGenerationTx(ctx, tx, prepared) + return applyErr + }) + return result, err + }) +} + +func (s *Store) applyPersonFactGenerationContext( + ctx context.Context, input personfacts.GenerationInput, aligner personfacts.EvidenceAligner, + runTx personFactTxRunner, +) (*personfacts.GenerationResult, error) { + prepared, err := personfacts.PreparePersonFactGeneration(ctx, input, aligner) + if err != nil { + return nil, err + } + if runTx == nil { + return nil, errors.New("person fact transaction runner is required") + } + var result *personfacts.GenerationResult + err = runTx(ctx, func(tx *loggedTx) error { + var applyErr error + result, applyErr = s.applyPreparedPersonFactGenerationTx(ctx, tx, prepared) + return applyErr + }) + if err != nil { + return nil, err + } + return result, nil +} + +func (s *Store) applyPreparedPersonFactGenerationTx( + ctx context.Context, tx *loggedTx, prepared personfacts.PreparedGeneration, +) (*personfacts.GenerationResult, error) { + input, claims, statuses, err := verifyPreparedPersonFactGeneration(prepared) + if err != nil { + return nil, err + } + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-generation", input.PersonID); err != nil { + return nil, err + } + if err := verifyTrackedPersonFactPersonTx(ctx, tx, input.PersonID); err != nil { + return nil, err + } + touched, err := s.personFactTouchedTargetsTx(ctx, tx, input.PersonID, claims, statuses) + if err != nil { + return nil, err + } + for _, target := range touched { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-target", input.PersonID, target.Kind, target.Key); err != nil { + return nil, err + } + } + organizationRefs, employmentTouched, err := s.personFactGenerationEmploymentOrganizationReferencesTx( + ctx, tx, input.PersonID, touched, claims) + if err != nil { + return nil, err + } + if employmentTouched { + if err := s.lockPersonFactOrganizationTableForReferencesTx( + ctx, tx, organizationRefs); err != nil { + return nil, err + } + } + for _, target := range touched { + if target.Kind == personfacts.TargetEmployment { + if err := s.lockEmploymentPeopleTx(ctx, tx, input.PersonID); err != nil { + return nil, err + } + break + } + } + + generation, replay, err := s.insertPersonFactGenerationTx(ctx, tx, prepared) + if err != nil { + return nil, err + } + if replay { + return s.loadPersonFactGenerationResultTx(ctx, tx, input.PersonID, generation.GenerationKey) + } + + preparedFailures := make(map[string]*personfacts.ValidationFailure, len(claims)) + for index := range claims { + claimKey, keyErr := personfacts.ClaimKey(generation.GenerationKey, claims[index]) + if keyErr != nil { + return nil, keyErr + } + failure := claims[index].Failure + descriptor, eligibility, loadErr := s.loadPersonFactTargetDescriptorTx( + ctx, tx, claims[index].Target.Kind, claims[index].Target.Key, + &claims[index].Target, input.Policy.AllowSensitive) + if loadErr != nil { + return nil, loadErr + } + if failure == nil && !eligibility.Supported { + failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionInvalid, Reason: personfacts.ReasonUnsupportedTarget, + Detail: "target is no longer eligible for automatic projection", + } + } else if failure == nil && descriptor.Revision != claims[index].Target.Revision { + failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionInvalid, Reason: personfacts.ReasonStaleTargetRevision, + Detail: "target descriptor changed after generation preparation", + } + } + if failure != nil { + copyFailure := *failure + preparedFailures[claimKey] = ©Failure + } + } + var organizationLocks *personFactOrganizationLockSet + if employmentTouched { + organizationLocks, err = s.lockPersonFactOrganizationReferencesTx( + ctx, tx, organizationRefs) + if err != nil { + return nil, err + } + } + if err := s.preflightPersonFactEmploymentOrganizationsTx( + ctx, tx, generation.GenerationKey, claims, preparedFailures, + organizationLocks); err != nil { + return nil, err + } + if _, err := s.insertPersonFactClaimsWithFailuresTx( + ctx, tx, generation, claims, preparedFailures); err != nil { + return nil, err + } + if err := s.insertPersonFactEvidenceStatusEventsTx(ctx, tx, generation, statuses); err != nil { + return nil, err + } + + transactionTime := time.Now().UTC() + projectionChanged := false + for _, touchedTarget := range touched { + descriptor, eligibility, loadErr := s.loadPersonFactTargetDescriptorTx( + ctx, tx, touchedTarget.Kind, touchedTarget.Key, touchedTarget.Fallback, + input.Policy.AllowSensitive) + if loadErr != nil { + return nil, loadErr + } + changed, resolveErr := s.resolvePersonFactTargetWithOrganizationLocksTx( + ctx, tx, generation, descriptor, eligibility, input.Policy, + preparedFailures, organizationLocks, transactionTime) + if resolveErr != nil { + return nil, resolveErr + } + projectionChanged = projectionChanged || changed + } + if projectionChanged { + if err := s.bumpPersonVCardProjectionsTx(ctx, tx, input.PersonID); err != nil { + return nil, err + } + } + return s.loadPersonFactGenerationResultTx(ctx, tx, input.PersonID, generation.GenerationKey) +} + +func (s *Store) preflightPersonFactEmploymentOrganizationsTx( + ctx context.Context, tx *loggedTx, generationKey string, + claims []personfacts.PreparedClaim, failures map[string]*personfacts.ValidationFailure, + organizationLocks *personFactOrganizationLockSet, +) error { + type preparedReference struct { + claimKey string + ref personfacts.OrganizationReference + keys []personFactOrganizationLookupKey + } + prepared := make([]preparedReference, 0, len(claims)) + for _, claim := range claims { + if claim.Target.Kind != personfacts.TargetEmployment || claim.Normalized == nil { + continue + } + claimKey, err := personfacts.ClaimKey(generationKey, claim) + if err != nil { + return err + } + if failures[claimKey] != nil { + continue + } + var value personfacts.EmploymentValue + if err := json.Unmarshal(claim.Normalized.JSON, &value); err != nil { + return fmt.Errorf("decode normalized employment organization preflight: %w", err) + } + ref, keys, normalizeErr := normalizePersonFactOrganizationReference(value.Organization) + if normalizeErr != nil { + failure := personFactOrganizationReferenceFailure(normalizeErr) + if failure == nil { + return normalizeErr + } + failures[claimKey] = failure + continue + } + prepared = append(prepared, preparedReference{claimKey: claimKey, ref: ref, keys: keys}) + } + for _, reference := range prepared { + _, err := s.prepareLockedPersonFactOrganizationReferenceTx( + ctx, tx, reference.ref, reference.keys, organizationLocks, true) + if err == nil { + continue + } + failure := personFactOrganizationReferenceFailure(err) + if failure == nil { + return err + } + failures[reference.claimKey] = failure + } + return nil +} + +func (s *Store) personFactGenerationEmploymentOrganizationReferencesTx( + ctx context.Context, tx *loggedTx, personID int64, + touched []personFactTouchedTarget, claims []personfacts.PreparedClaim, +) ([]personfacts.OrganizationReference, bool, error) { + targetKeys := make(map[string]struct{}) + for _, target := range touched { + if target.Kind == personfacts.TargetEmployment { + targetKeys[target.Key] = struct{}{} + } + } + if len(targetKeys) == 0 { + return nil, false, nil + } + refs := make([]personfacts.OrganizationReference, 0, len(claims)) + for _, claim := range claims { + if claim.Target.Kind != personfacts.TargetEmployment || claim.Normalized == nil { + continue + } + if _, touchedTarget := targetKeys[claim.Target.Key]; !touchedTarget { + continue + } + var value personfacts.EmploymentValue + if err := json.Unmarshal(claim.Normalized.JSON, &value); err != nil { + return nil, false, fmt.Errorf( + "decode incoming employment organization lock scope: %w", err) + } + refs = append(refs, value.Organization) + } + historical, err := s.loadPersonFactEmploymentOrganizationReferencesTx( + ctx, tx, personID, targetKeys) + if err != nil { + return nil, false, err + } + refs = append(refs, historical...) + return refs, true, nil +} + +func (s *Store) loadPersonFactEmploymentOrganizationReferencesTx( + ctx context.Context, tx *loggedTx, personID int64, targetKeys map[string]struct{}, +) ([]personfacts.OrganizationReference, error) { + orderedTargetKeys := make([]string, 0, len(targetKeys)) + for targetKey := range targetKeys { + orderedTargetKeys = append(orderedTargetKeys, targetKey) + } + sort.Strings(orderedTargetKeys) + if len(orderedTargetKeys) == 0 { + return nil, nil + } + args := make([]any, 0, len(orderedTargetKeys)+2) + args = append(args, personID, personfacts.TargetEmployment) + for _, targetKey := range orderedTargetKeys { + args = append(args, targetKey) + } + rows, err := tx.QueryContext(ctx, ` + SELECT c.claim_key, CAST(c.normalized_value_json AS TEXT) + FROM person_fact_claims c + WHERE c.person_id = ? AND c.target_kind = ? + AND c.target_key IN (`+placeholders(len(orderedTargetKeys))+`) + AND c.normalized_value_json IS NOT NULL AND c.rejection_action IS NULL + ORDER BY c.id`, args...) + if err != nil { + return nil, fmt.Errorf("list historical employment organization lock scope: %w", err) + } + type historicalReference struct { + claimKey string + ref personfacts.OrganizationReference + } + historical := make([]historicalReference, 0) + claimKeys := make([]string, 0) + for rows.Next() { + var claimKey, normalizedJSON string + if scanErr := rows.Scan(&claimKey, &normalizedJSON); scanErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("scan historical employment organization lock scope: %w", scanErr) + } + var value personfacts.EmploymentValue + if decodeErr := json.Unmarshal([]byte(normalizedJSON), &value); decodeErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("decode historical employment organization lock scope: %w", decodeErr) + } + historical = append(historical, historicalReference{claimKey: claimKey, ref: value.Organization}) + claimKeys = append(claimKeys, claimKey) + } + if rowsErr := rows.Err(); rowsErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("iterate historical employment organization lock scope: %w", rowsErr) + } + if closeErr := rows.Close(); closeErr != nil { + return nil, fmt.Errorf("close historical employment organization lock scope: %w", closeErr) + } + bindings, err := loadPersonFactEmploymentOrganizationBindingsTx(ctx, tx, personID, claimKeys) + if err != nil { + return nil, err + } + refs := make([]personfacts.OrganizationReference, 0, len(historical)) + for _, reference := range historical { + if organizationID, bound := bindings[reference.claimKey]; bound { + reference.ref.ID = &organizationID + } + refs = append(refs, reference.ref) + } + return refs, nil +} + +func personFactOrganizationReferenceFailure(err error) *personfacts.ValidationFailure { + if !errors.Is(err, ErrOrganizationNotFound) && !errors.Is(err, ErrOrganizationInvalid) { + return nil + } + return &personfacts.ValidationFailure{ + Action: personfacts.DecisionInvalid, Reason: personfacts.ReasonMalformedValue, + Detail: err.Error(), + } +} + +func verifyPreparedPersonFactGeneration( + prepared personfacts.PreparedGeneration, +) (personfacts.GenerationInput, []personfacts.PreparedClaim, []personfacts.PreparedEvidenceStatusChange, error) { + input := prepared.Input() + claims := prepared.Claims() + statuses := prepared.EvidenceStatusChanges() + if input.PersonID <= 0 || len(prepared.CanonicalJSON()) == 0 || prepared.GenerationKey() == "" { + return personfacts.GenerationInput{}, nil, nil, errors.New("prepared person fact generation is empty") + } + key, err := personfacts.GenerationKey(input, claims, statuses) + if err != nil { + return personfacts.GenerationInput{}, nil, nil, fmt.Errorf("verify prepared person fact generation: %w", err) + } + if key != prepared.GenerationKey() || input.ProgramFingerprint != prepared.ProgramFingerprint() { + return personfacts.GenerationInput{}, nil, nil, + errors.New("prepared person fact generation failed integrity verification") + } + return input, claims, statuses, nil +} + +func verifyTrackedPersonFactPersonTx(ctx context.Context, tx *loggedTx, personID int64) error { + var tracked int + err := tx.QueryRowContext(ctx, ` + SELECT COUNT(*) FROM persons p + WHERE p.id = ? AND EXISTS ( + SELECT 1 FROM person_tracking pt WHERE pt.person_id = p.id + )`, personID).Scan(&tracked) + if err != nil { + return fmt.Errorf("verify tracked person %d: %w", personID, err) + } + if tracked == 0 { + return ErrPersonFactPersonNotTracked + } + return nil +} + +func (s *Store) personFactTouchedTargetsTx( + ctx context.Context, tx *loggedTx, personID int64, + claims []personfacts.PreparedClaim, statuses []personfacts.PreparedEvidenceStatusChange, +) ([]personFactTouchedTarget, error) { + targets := make(map[string]personFactTouchedTarget) + for index := range claims { + target := claims[index].Target + key := personFactTargetMapKey(target.Kind, target.Key) + copyTarget := target + targets[key] = personFactTouchedTarget{Kind: target.Kind, Key: target.Key, Fallback: ©Target} + } + for _, status := range statuses { + var evidenceID int64 + var sourceVersion string + err := tx.QueryRowContext(ctx, ` + SELECT id, source_version FROM person_fact_evidence + WHERE person_id = ? AND evidence_key = ?`, personID, status.EvidenceKey, + ).Scan(&evidenceID, &sourceVersion) + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("person fact status references unknown evidence %q", status.EvidenceKey) + } + if err != nil { + return nil, fmt.Errorf("verify person fact status evidence %q: %w", status.EvidenceKey, err) + } + if sourceVersion != status.SourceVersion { + return nil, fmt.Errorf("person fact status source version does not match evidence %q", status.EvidenceKey) + } + rows, queryErr := tx.QueryContext(ctx, ` + SELECT c.id, c.target_kind, c.target_key, c.target_revision + FROM person_fact_claim_evidence ce + JOIN person_fact_claims c ON c.id = ce.claim_id + WHERE ce.evidence_id = ? AND c.person_id = ? + ORDER BY c.target_kind, c.target_key, c.id`, evidenceID, personID) + if queryErr != nil { + return nil, fmt.Errorf("discover person fact status targets: %w", queryErr) + } + for rows.Next() { + var claimID int64 + var kind personfacts.TargetKind + var key, revision string + if scanErr := rows.Scan(&claimID, &kind, &key, &revision); scanErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("scan person fact status target: %w", scanErr) + } + mapKey := personFactTargetMapKey(kind, key) + if existing, ok := targets[mapKey]; ok && existing.Fallback != nil && + (existing.FallbackClaimID == 0 || existing.FallbackClaimID >= claimID) { + continue + } + fallback := personfacts.TargetDescriptor{ + Kind: kind, Key: key, UniversalID: key, Revision: revision, + ValueType: personfacts.ValueText, Cardinality: personfacts.CardinalitySingle, + } + if kind == personfacts.TargetEmployment { + fallback.ValueType = personfacts.ValueEmployment + fallback.Cardinality = personfacts.CardinalityMulti + } + targets[mapKey] = personFactTouchedTarget{ + Kind: kind, Key: key, Fallback: &fallback, FallbackClaimID: claimID, + } + } + if rowsErr := rows.Err(); rowsErr != nil { + _ = rows.Close() + return nil, fmt.Errorf("iterate person fact status targets: %w", rowsErr) + } + if closeErr := rows.Close(); closeErr != nil { + return nil, fmt.Errorf("close person fact status targets: %w", closeErr) + } + } + result := make([]personFactTouchedTarget, 0, len(targets)) + for _, target := range targets { + result = append(result, target) + } + sort.Slice(result, func(i, j int) bool { + if result[i].Kind != result[j].Kind { + return result[i].Kind < result[j].Kind + } + return result[i].Key < result[j].Key + }) + return result, nil +} + +func (s *Store) resolvePersonFactTargetTx( + ctx context.Context, tx *loggedTx, generation personfacts.Generation, + descriptor personfacts.TargetDescriptor, eligibility personFactTargetEligibility, + policyContext personfacts.PolicyContext, + preparedFailures map[string]*personfacts.ValidationFailure, transactionTime time.Time, +) (bool, error) { + return s.resolvePersonFactTargetWithOrganizationLocksTx( + ctx, tx, generation, descriptor, eligibility, policyContext, + preparedFailures, nil, transactionTime) +} + +func (s *Store) resolvePersonFactTargetWithOrganizationLocksTx( + ctx context.Context, tx *loggedTx, generation personfacts.Generation, + descriptor personfacts.TargetDescriptor, eligibility personFactTargetEligibility, + policyContext personfacts.PolicyContext, + preparedFailures map[string]*personfacts.ValidationFailure, + organizationLocks *personFactOrganizationLockSet, transactionTime time.Time, +) (bool, error) { + resolvedClaims, chronology, err := s.loadPersonFactResolvedClaimsTx( + ctx, tx, generation.PersonID, descriptor, preparedFailures) + if err != nil { + return false, err + } + var projector personFactTargetProjector + var current []personfacts.CurrentProjection + var pin personfacts.PinState + var projectionContext string + supportedKind := descriptor.Kind == personfacts.TargetAttribute || + descriptor.Kind == personfacts.TargetEmployment + projectable := eligibility.Supported && supportedKind + if projectable { + projector, err = s.personFactProjectorWithOrganizationLocksTx( + ctx, tx, descriptor, organizationLocks) + if err != nil { + return false, err + } + current, err = projector.loadCurrent(ctx, generation.PersonID, descriptor) + if err != nil { + return false, err + } + pin, err = s.effectivePersonFactPinTx(ctx, tx, generation.PersonID, descriptor, current) + if err != nil { + return false, err + } + projectionContext, err = projector.projectionContext(ctx, generation.PersonID, resolvedClaims) + if err != nil { + return false, err + } + } else { + for index := range resolvedClaims { + resolvedClaims[index].Normalized = nil + if resolvedClaims[index].Failure == nil { + if eligibility.SensitivePolicyBlocked && supportedKind { + resolvedClaims[index].Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionPolicyRejected, Reason: personfacts.ReasonSensitivePolicy, + Detail: "sensitive target is disabled by policy", + } + } else { + resolvedClaims[index].Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionInvalid, Reason: personfacts.ReasonUnsupportedTarget, + Detail: "target is unavailable or ineligible for automatic projection", + } + } + } + } + } + resolverInputClaims := resolvedClaims + if projectable && descriptor.Kind == personfacts.TargetEmployment { + resolverInputClaims, err = s.reconcilePersonFactEmploymentClaimsTx( + ctx, tx, generation.PersonID, resolvedClaims, chronology) + if err != nil { + return false, err + } + } + resolver, err := personfacts.NewResolver(personfacts.DefaultPolicyV1()) + if err != nil { + return false, err + } + resolvedAt, err := personFactTargetResolutionWatermarkTx( + ctx, tx, generation.PersonID, descriptor, generation.ResolvedAt) + if err != nil { + return false, err + } + resolverClaims, ambiguousClaims := personFactResolverClaims(resolverInputClaims) + resolution, err := resolver.Resolve(personfacts.ResolutionInput{ + PersonID: generation.PersonID, Target: descriptor, ResolvedAt: resolvedAt, + Policy: policyContext, ProjectionContextFingerprint: projectionContext, + Current: current, Claims: resolverClaims, Pin: pin, + }) + if err != nil { + return false, fmt.Errorf("resolve person fact target %s/%s: %w", descriptor.Kind, descriptor.Key, err) + } + if err := restorePersonFactAmbiguousDecisions(&resolution, ambiguousClaims); err != nil { + return false, err + } + if projectable && descriptor.Kind == personfacts.TargetEmployment { + if err := reconcilePersonFactEmploymentCompetition(&resolution, resolverInputClaims); err != nil { + return false, err + } + } + if !projectable && len(resolution.Projections) != 0 { + return false, errors.New("unsupported person fact target produced a projection plan") + } + shell, err := s.insertPersonFactResolutionTx(ctx, tx, generation, + personfacts.Resolution{ + Target: resolution.Target, ResolverVersion: resolution.ResolverVersion, + InputFingerprint: resolution.InputFingerprint, ResolvedAt: resolution.ResolvedAt, + Decisions: []personfacts.Decision{}, Projections: []personfacts.ProjectionPlan{}, + }, policyContext.ProviderPolicyFingerprint) + if err != nil { + return false, err + } + claimByKey := make(map[string]personfacts.ResolvedClaim, len(resolvedClaims)) + for _, claim := range resolvedClaims { + claimByKey[claim.ClaimKey] = claim + } + decisionIndex := make(map[string]int, len(resolution.Decisions)) + for index := range resolution.Decisions { + decisionIndex[resolution.Decisions[index].ClaimKey] = index + } + sortPersonFactProjectionPlans(resolution.Projections) + changed := false + plannedCurrent := make(map[personfacts.ProjectionRef]struct{}) + for _, plan := range resolution.Projections { + if plan.CurrentRef != nil { + plannedCurrent[*plan.CurrentRef] = struct{}{} + } + claim, ok := claimByKey[plan.ClaimKey] + if !ok { + return false, fmt.Errorf("projection plan references unknown claim %q", plan.ClaimKey) + } + index, ok := decisionIndex[plan.ClaimKey] + if !ok { + return false, fmt.Errorf("projection plan references missing decision %q", plan.ClaimKey) + } + ref, planChanged, projectErr := projector.project( + ctx, plan, claim, resolution.Decisions[index].DecisionKey, transactionTime) + if projectErr != nil { + return false, projectErr + } + if ref != nil { + resolution.Decisions[index].Projection = ref + } + changed = changed || planChanged + } + if attribute, ok := projector.(*personFactAttributeProjector); ok && !pin.Pinned { + retired, retireChanged, retireErr := attribute.retireUnsupported( + ctx, generation.PersonID, resolution.ResolvedAt, transactionTime, current, resolvedClaims, + resolution.Decisions, plannedCurrent) + if retireErr != nil { + return false, retireErr + } + for _, projection := range retired { + if index, exists := decisionIndex[projection.ClaimKey]; exists && resolution.Decisions[index].Projection == nil { + ref := projection.Ref + resolution.Decisions[index].Projection = &ref + } + } + changed = changed || retireChanged + } + if employment, ok := projector.(*personFactEmploymentProjector); ok && !pin.Pinned { + retired, retireChanged, retireErr := employment.retireExpired( + ctx, generation.PersonID, resolution.ResolvedAt, current, resolvedClaims, + resolution.Decisions, plannedCurrent) + if retireErr != nil { + return false, retireErr + } + for _, projection := range retired { + if index, exists := decisionIndex[projection.ClaimKey]; exists && resolution.Decisions[index].Projection == nil { + ref := projection.Ref + resolution.Decisions[index].Projection = &ref + } + } + changed = changed || retireChanged + } + for _, decision := range resolution.Decisions { + if err := s.insertPersonFactDecisionTx(ctx, tx, generation.PersonID, shell.ID, + resolution.InputFingerprint, decision); err != nil { + return false, err + } + } + return changed, nil +} + +func personFactTargetResolutionWatermarkTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, + incoming time.Time, +) (time.Time, error) { + var stored personFactTimestamp + err := tx.QueryRowContext(ctx, ` + SELECT resolved_at FROM person_fact_resolutions + WHERE person_id = ? AND target_kind = ? AND target_key = ? + ORDER BY resolved_at DESC, id DESC LIMIT 1`, + personID, descriptor.Kind, descriptor.Key).Scan(&stored) + if errors.Is(err, sql.ErrNoRows) { + return incoming, nil + } + if err != nil { + return time.Time{}, fmt.Errorf("load person fact target resolution watermark: %w", err) + } + if stored.Time.After(incoming) { + return stored.Time, nil + } + return incoming, nil +} + +func personFactResolverClaims( + claims []personfacts.ResolvedClaim, +) ([]personfacts.ResolvedClaim, map[string]struct{}) { + resolverClaims := append([]personfacts.ResolvedClaim(nil), claims...) + ambiguous := make(map[string]struct{}) + for index := range resolverClaims { + failure := resolverClaims[index].Failure + if failure == nil || failure.Action != personfacts.DecisionAmbiguousRetained || + failure.Reason != personfacts.ReasonOrganizationAmbiguous { + continue + } + ambiguous[resolverClaims[index].ClaimKey] = struct{}{} + // Resolver v1's durable rejection input predates organization matching + // and accepts only its original invalid/identity/policy vocabulary. Feed + // it a non-eligible rejection so the ambiguous claim cannot compete or + // project, then restore the exact organization decision below. The real + // match identity is already bound into ProjectionContextFingerprint. + resolverClaims[index].Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionIdentityRejected, + Reason: personfacts.ReasonIdentityMismatch, + Detail: failure.Detail, + } + } + return resolverClaims, ambiguous +} + +func restorePersonFactAmbiguousDecisions( + resolution *personfacts.Resolution, ambiguous map[string]struct{}, +) error { + if len(ambiguous) == 0 { + return nil + } + for index := range resolution.Decisions { + decision := &resolution.Decisions[index] + if _, exists := ambiguous[decision.ClaimKey]; !exists { + continue + } + decision.Action = personfacts.DecisionAmbiguousRetained + decision.Reason = personfacts.ReasonOrganizationAmbiguous + decision.CompetingClaimKey = "" + decision.Projection = nil + key, err := personfacts.DecisionKey( + resolution.InputFingerprint, decision.ClaimKey, decision.Action) + if err != nil { + return err + } + decision.DecisionKey = key + } + plans := resolution.Projections[:0] + for _, plan := range resolution.Projections { + if _, exists := ambiguous[plan.ClaimKey]; !exists { + plans = append(plans, plan) + } + } + resolution.Projections = plans + return nil +} + +func (s *Store) loadPersonFactResolvedClaimsTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, + preparedFailures map[string]*personfacts.ValidationFailure, +) ([]personfacts.ResolvedClaim, map[string]personFactClaimChronology, error) { + claims, err := loadPersonFactTargetClaimsTx(ctx, tx, personID, descriptor) + if err != nil { + return nil, nil, err + } + chronologyByGeneration, err := loadPersonFactTargetChronologyTx( + ctx, tx, personID, descriptor) + if err != nil { + return nil, nil, err + } + evidenceByClaim, err := loadPersonFactTargetEvidenceTx(ctx, tx, personID, descriptor) + if err != nil { + return nil, nil, err + } + statusByEvidence, err := loadPersonFactTargetEvidenceStatusesTx(ctx, tx, personID, descriptor) + if err != nil { + return nil, nil, err + } + + resolved := make([]personfacts.ResolvedClaim, 0, len(claims)) + chronology := make(map[string]personFactClaimChronology, len(claims)) + for _, claim := range claims { + proposedTarget := descriptor + proposedTarget.Revision = claim.Target.Revision + item := personfacts.ResolvedClaim{ + ClaimKey: claim.ClaimKey, + Claim: personfacts.ProposedClaim{ + Target: proposedTarget, Relation: claim.Relation, + SubmittedValue: append(json.RawMessage(nil), claim.SubmittedValue...), + ValidFrom: personFactPortableTimePointer(claim.ValidFrom), + ValidUntil: personFactPortableTimePointer(claim.ValidUntil), + Origin: claim.Origin, Confidence: claim.Confidence, + }, + Normalized: copyPersonFactNormalized(claim.Normalized), + Failure: copyPersonFactFailure(claim.Failure), + } + if failure := preparedFailures[claim.ClaimKey]; failure != nil { + item.Failure = copyPersonFactFailure(failure) + } + if item.Failure == nil && item.Normalized == nil { + item.Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionInvalid, Reason: personfacts.ReasonMalformedValue, + Detail: "claim has no normalized value", + } + } + for _, evidence := range evidenceByClaim[claim.ID] { + if status, exists := statusByEvidence[evidence.ID]; exists { + evidence.Supported = status.Supported + evidence.LatestStatus = &status + } + item.Evidence = append(item.Evidence, evidence) + item.Claim.Evidence = append(item.Claim.Evidence, evidence.Input) + } + resolvedAt, exists := chronologyByGeneration[claim.GenerationID] + if !exists { + return nil, nil, fmt.Errorf("load person fact claim generation %d: %w", + claim.GenerationID, sql.ErrNoRows) + } + resolved = append(resolved, item) + chronology[item.ClaimKey] = personFactClaimChronology{ + ResolvedAt: resolvedAt, GenerationID: claim.GenerationID, + } + } + sort.Slice(resolved, func(i, j int) bool { return resolved[i].ClaimKey < resolved[j].ClaimKey }) + return resolved, chronology, nil +} + +func loadPersonFactTargetClaimsTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, +) ([]personfacts.Claim, error) { + rows, err := tx.QueryContext(ctx, `SELECT `+personFactClaimColumns+` + FROM person_fact_claims c + WHERE c.person_id = ? AND c.target_kind = ? AND c.target_key = ? + ORDER BY c.id DESC`, personID, descriptor.Kind, descriptor.Key) + if err != nil { + return nil, fmt.Errorf("load person fact target claims: %w", err) + } + defer func() { _ = rows.Close() }() + claims := make([]personfacts.Claim, 0) + for rows.Next() { + claim, scanErr := scanPersonFactClaim(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact target claim: %w", scanErr) + } + claims = append(claims, claim) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact target claims: %w", err) + } + return claims, nil +} + +func loadPersonFactTargetChronologyTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, +) (map[int64]time.Time, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT g.id, g.resolved_at + FROM person_fact_generations g + WHERE g.person_id = ? AND EXISTS ( + SELECT 1 FROM person_fact_claims c + WHERE c.generation_id = g.id AND c.person_id = g.person_id + AND c.target_kind = ? AND c.target_key = ?) + ORDER BY g.id`, personID, descriptor.Kind, descriptor.Key) + if err != nil { + return nil, fmt.Errorf("load person fact target generations: %w", err) + } + defer func() { _ = rows.Close() }() + chronology := make(map[int64]time.Time) + for rows.Next() { + var generationID int64 + var resolvedAt personFactTimestamp + if err := rows.Scan(&generationID, &resolvedAt); err != nil { + return nil, fmt.Errorf("scan person fact target generation: %w", err) + } + chronology[generationID] = resolvedAt.Time + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact target generations: %w", err) + } + return chronology, nil +} + +func loadPersonFactTargetEvidenceTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, +) (map[int64][]personfacts.Evidence, error) { + rows, err := tx.QueryContext(ctx, `SELECT ce.claim_id, `+personFactEvidenceColumns+` + FROM person_fact_claim_evidence ce + JOIN person_fact_claims c ON c.id = ce.claim_id + JOIN person_fact_evidence e ON e.id = ce.evidence_id AND e.person_id = c.person_id + WHERE c.person_id = ? AND c.target_kind = ? AND c.target_key = ? + ORDER BY ce.claim_id, e.id`, personID, descriptor.Kind, descriptor.Key) + if err != nil { + return nil, fmt.Errorf("load person fact target evidence: %w", err) + } + defer func() { _ = rows.Close() }() + evidenceByClaim := make(map[int64][]personfacts.Evidence) + for rows.Next() { + var claimID int64 + evidence, scanErr := scanPersonFactEvidenceWithPrefix(rows, &claimID) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact target evidence: %w", scanErr) + } + evidenceByClaim[claimID] = append(evidenceByClaim[claimID], evidence) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact target evidence: %w", err) + } + return evidenceByClaim, nil +} + +func loadPersonFactTargetEvidenceStatusesTx( + ctx context.Context, tx *loggedTx, personID int64, descriptor personfacts.TargetDescriptor, +) (map[int64]personfacts.EvidenceStatusEvent, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT latest.id, latest.person_id, latest.generation_id, latest.evidence_id, + latest.evidence_key, latest.source_version, latest.supported, + latest.reason, latest.created_at + FROM person_fact_evidence_status_events latest + WHERE latest.person_id = ? AND EXISTS ( + SELECT 1 FROM person_fact_claim_evidence ce + JOIN person_fact_claims c ON c.id = ce.claim_id + WHERE ce.evidence_id = latest.evidence_id AND c.person_id = latest.person_id + AND c.target_kind = ? AND c.target_key = ?) + AND latest.id = ( + SELECT candidate.id + FROM person_fact_evidence_status_events candidate + JOIN person_fact_generations candidate_generation + ON candidate_generation.id = candidate.generation_id + WHERE candidate.person_id = latest.person_id + AND candidate.evidence_key = latest.evidence_key + AND candidate.source_version = latest.source_version + ORDER BY candidate_generation.resolved_at DESC, candidate.id DESC + LIMIT 1) + ORDER BY latest.id`, personID, descriptor.Kind, descriptor.Key) + if err != nil { + return nil, fmt.Errorf("load person fact target evidence statuses: %w", err) + } + defer func() { _ = rows.Close() }() + statuses := make(map[int64]personfacts.EvidenceStatusEvent) + for rows.Next() { + status, scanErr := scanPersonFactStatusEvent(rows) + if scanErr != nil { + return nil, fmt.Errorf("scan person fact target evidence status: %w", scanErr) + } + statuses[status.EvidenceID] = status + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate person fact target evidence statuses: %w", err) + } + return statuses, nil +} + +func (s *Store) loadPersonFactGenerationResultTx( + ctx context.Context, tx *loggedTx, personID int64, generationKey string, +) (*personfacts.GenerationResult, error) { + ledger, err := s.loadPersonFactGenerationTx(ctx, tx, personID, generationKey) + if err != nil { + return nil, err + } + result := &personfacts.GenerationResult{ + GenerationID: ledger.Generation.ID, GenerationKey: ledger.Generation.GenerationKey, + EvidenceStatusEvents: append([]personfacts.EvidenceStatusEvent(nil), ledger.EvidenceStatusEvents...), + Resolutions: append([]personfacts.ResolutionResult(nil), ledger.Resolutions...), + Decisions: make([]personfacts.Decision, 0), + Projections: make([]personfacts.ProjectionRef, 0), + } + projectionSet := make(map[personfacts.ProjectionRef]struct{}) + for _, resolution := range result.Resolutions { + result.Decisions = append(result.Decisions, resolution.Decisions...) + for _, projection := range resolution.Projections { + projectionSet[projection] = struct{}{} + } + } + sort.Slice(result.EvidenceStatusEvents, func(i, j int) bool { + return result.EvidenceStatusEvents[i].ID < result.EvidenceStatusEvents[j].ID + }) + sort.Slice(result.Resolutions, func(i, j int) bool { + left, right := result.Resolutions[i], result.Resolutions[j] + if left.Target.Kind != right.Target.Kind { + return left.Target.Kind < right.Target.Kind + } + if left.Target.Key != right.Target.Key { + return left.Target.Key < right.Target.Key + } + if left.Target.Revision != right.Target.Revision { + return left.Target.Revision < right.Target.Revision + } + return left.ID < right.ID + }) + sort.Slice(result.Decisions, func(i, j int) bool { + left, right := result.Decisions[i], result.Decisions[j] + if left.ResolutionID != right.ResolutionID { + return left.ResolutionID < right.ResolutionID + } + if left.ClaimKey != right.ClaimKey { + return left.ClaimKey < right.ClaimKey + } + return left.Action < right.Action + }) + for projection := range projectionSet { + result.Projections = append(result.Projections, projection) + } + sortPersonFactProjectionRefs(result.Projections) + if result.EvidenceStatusEvents == nil { + result.EvidenceStatusEvents = []personfacts.EvidenceStatusEvent{} + } + if result.Resolutions == nil { + result.Resolutions = []personfacts.ResolutionResult{} + } + return result, nil +} + +func (s *Store) personFactProjectorTx( + ctx context.Context, tx *loggedTx, target personfacts.TargetDescriptor, +) (personFactTargetProjector, error) { + return s.personFactProjectorWithOrganizationLocksTx(ctx, tx, target, nil) +} + +func (s *Store) personFactProjectorWithOrganizationLocksTx( + ctx context.Context, tx *loggedTx, target personfacts.TargetDescriptor, + organizationLocks *personFactOrganizationLockSet, +) (personFactTargetProjector, error) { + return s.personFactProjector(ctx, tx, target, true, organizationLocks) +} + +func (s *Store) personFactProjectorSnapshotTx( + ctx context.Context, tx *loggedTx, target personfacts.TargetDescriptor, +) (personFactTargetProjector, error) { + return s.personFactProjector(ctx, tx, target, false, nil) +} + +func (s *Store) personFactProjector( + ctx context.Context, tx *loggedTx, target personfacts.TargetDescriptor, lock bool, + organizationLocks *personFactOrganizationLockSet, +) (personFactTargetProjector, error) { + switch target.Kind { + case personfacts.TargetAttribute: + definition, err := s.getAttributeDefinitionByUniversalID(ctx, tx, target.Key, lock) + if err != nil { + return nil, err + } + return &personFactAttributeProjector{store: s, tx: tx, definition: *definition}, nil + case personfacts.TargetEmployment: + return &personFactEmploymentProjector{ + store: s, tx: tx, lock: lock, organizationLocks: organizationLocks, + }, nil + default: + return nil, fmt.Errorf("unsupported person fact target kind %q", target.Kind) + } +} + +func (p *personFactAttributeProjector) loadCurrent( + ctx context.Context, personID int64, target personfacts.TargetDescriptor, +) ([]personfacts.CurrentProjection, error) { + values, err := p.store.listPersonAttributeValuesContext(ctx, p.tx, personID, + PersonAttributeQuery{DefinitionSlug: p.definition.Slug}) + if err != nil { + return nil, err + } + current := make([]personfacts.CurrentProjection, 0, len(values)) + for _, value := range values { + normalized, normalizeErr := normalizedPersonFactAttributeValue(target, value.Value) + if normalizeErr != nil { + return nil, normalizeErr + } + current = append(current, personfacts.CurrentProjection{ + Ref: personfacts.ProjectionRef{Kind: "person_attribute", RowID: value.ID}, + Normalized: *normalized, ActiveFrom: value.ActiveFrom, + ActiveUntil: value.ActiveUntil, TransactionTime: value.CreatedAt, + Declared: value.Source.IsDeclared(), + }) + } + return current, nil +} + +func (p *personFactAttributeProjector) projectionContext( + context.Context, int64, []personfacts.ResolvedClaim, +) (string, error) { + return "", nil +} + +func (p *personFactEmploymentProjector) loadCurrent( + ctx context.Context, personID int64, target personfacts.TargetDescriptor, +) ([]personfacts.CurrentProjection, error) { + if p.lock { + if p.organizationLocks == nil { + refs, err := p.store.loadPersonFactEmploymentOrganizationReferencesTx( + ctx, p.tx, personID, map[string]struct{}{target.Key: {}}) + if err != nil { + return nil, err + } + if err := p.store.lockPersonFactOrganizationTableForReferencesTx( + ctx, p.tx, refs); err != nil { + return nil, err + } + } + if err := p.store.lockEmploymentPeopleTx(ctx, p.tx, personID); err != nil { + return nil, err + } + } + employments, err := p.store.listAllEmploymentsContext( + ctx, p.tx, EmploymentFilter{PersonID: personID}) + if err != nil { + return nil, err + } + ownedHistorical, err := loadOwnedPersonFactEmploymentProjectionIDsTx(ctx, p.tx, personID) + if err != nil { + return nil, err + } + current := make([]personfacts.CurrentProjection, 0, len(employments)) + for _, employment := range employments { + if _, owned := ownedHistorical[employment.ID]; !employment.IsCurrent && !owned { + continue + } + organization, loadErr := getOrganizationTx(ctx, p.tx, employment.OrganizationID) + if loadErr != nil { + return nil, loadErr + } + normalized, normalizeErr := normalizedPersonFactEmploymentValue(target, employment, *organization) + if normalizeErr != nil { + return nil, normalizeErr + } + current = append(current, personfacts.CurrentProjection{ + Ref: personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: employment.ID}, + Normalized: *normalized, ActiveFrom: personFactEmploymentActiveFrom(employment), + TransactionTime: employment.UpdatedAt.UTC(), Declared: employment.Source.IsDeclared(), + }) + } + return current, nil +} + +func loadOwnedPersonFactEmploymentProjectionIDsTx( + ctx context.Context, tx *loggedTx, personID int64, +) (map[int64]struct{}, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT DISTINCT e.id + FROM employments e + JOIN person_fact_decisions d + ON d.person_id = e.person_id + AND d.projection_kind = 'employment' + AND d.projection_row_id = e.id + WHERE e.person_id = ? AND e.source_ref = ? || d.decision_key`, + personID, personFactDecisionSourceRefPrefix) + if err != nil { + return nil, fmt.Errorf("load owned employment projections: %w", err) + } + defer func() { _ = rows.Close() }() + owned := make(map[int64]struct{}) + for rows.Next() { + var rowID int64 + if err := rows.Scan(&rowID); err != nil { + return nil, fmt.Errorf("scan owned employment projection: %w", err) + } + owned[rowID] = struct{}{} + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate owned employment projections: %w", err) + } + return owned, nil +} + +func (p *personFactEmploymentProjector) projectionContext( + ctx context.Context, personID int64, claims []personfacts.ResolvedClaim, +) (string, error) { + type preparedClaim struct { + index int + value personfacts.EmploymentValue + ref personfacts.OrganizationReference + keys []personFactOrganizationLookupKey + } + preparedClaims := make([]preparedClaim, 0, len(claims)) + claimKeys := make([]string, 0, len(claims)) + for index := range claims { + if claims[index].Failure == nil && claims[index].Normalized != nil { + claimKeys = append(claimKeys, claims[index].ClaimKey) + } + } + organizationBindings, err := loadPersonFactEmploymentOrganizationBindingsTx( + ctx, p.tx, personID, claimKeys) + if err != nil { + return "", err + } + for index := range claims { + if claims[index].Failure != nil || claims[index].Normalized == nil { + continue + } + var value personfacts.EmploymentValue + if err := json.Unmarshal(claims[index].Normalized.JSON, &value); err != nil { + return "", fmt.Errorf("decode normalized employment claim %q: %w", claims[index].ClaimKey, err) + } + if organizationID, bound := organizationBindings[claims[index].ClaimKey]; bound { + value.Organization.ID = &organizationID + } + ref, keys, err := normalizePersonFactOrganizationReference(value.Organization) + if err != nil { + return "", err + } + value.Organization = ref + preparedClaims = append(preparedClaims, preparedClaim{ + index: index, value: value, ref: ref, keys: keys, + }) + } + lockSet := p.organizationLocks + if lockSet == nil { + refs := make([]personfacts.OrganizationReference, len(preparedClaims)) + for index := range preparedClaims { + refs[index] = preparedClaims[index].ref + } + var err error + lockSet, err = p.store.lockPersonFactOrganizationReferencesTx(ctx, p.tx, refs) + if err != nil { + return "", err + } + } + p.organizationLocks = lockSet + + type fingerprintEntry struct { + ClaimKey string `json:"claim_key"` + Fingerprint string `json:"match_fingerprint"` + Status OrganizationResolutionStatus `json:"status"` + Candidates []int64 `json:"candidate_ids"` + } + entries := make([]fingerprintEntry, 0, len(preparedClaims)) + for _, prepared := range preparedClaims { + match, err := p.store.prepareLockedPersonFactOrganizationReferenceTx( + ctx, p.tx, prepared.ref, prepared.keys, lockSet, false) + if err != nil { + failure := personFactOrganizationReferenceFailure(err) + if failure == nil { + return "", err + } + claim := &claims[prepared.index] + claim.Normalized = nil + claim.Failure = failure + continue + } + claim := &claims[prepared.index] + if match.Status == OrganizationAmbiguous { + claim.Failure = &personfacts.ValidationFailure{ + Action: personfacts.DecisionAmbiguousRetained, + Reason: personfacts.ReasonOrganizationAmbiguous, + Detail: "organization reference matches multiple active companies", + } + } else if match.Status == OrganizationReused && match.Organization != nil { + prepared.value.Organization = canonicalPersonFactOrganizationReference(*match.Organization) + normalized, failure, normalizeErr := personfacts.NormalizeClaimValue( + claim.Claim.Target, mustMarshalPersonFactEmployment(prepared.value)) + if normalizeErr != nil { + return "", normalizeErr + } + if failure != nil { + return "", fmt.Errorf("normalize resolved employment organization: %s", failure.Detail) + } + claim.Normalized = normalized + } + entries = append(entries, fingerprintEntry{ + ClaimKey: claim.ClaimKey, Fingerprint: match.Fingerprint, Status: match.Status, + Candidates: append([]int64(nil), match.CandidateIDs...), + }) + } + sort.Slice(entries, func(i, j int) bool { return entries[i].ClaimKey < entries[j].ClaimKey }) + encoded, err := json.Marshal(entries) + if err != nil { + return "", fmt.Errorf("encode employment projection context: %w", err) + } + digest := sha256.Sum256(encoded) + return hex.EncodeToString(digest[:]), nil +} + +func loadPersonFactEmploymentOrganizationBindingsTx( + ctx context.Context, tx *loggedTx, personID int64, claimKeys []string, +) (map[string]int64, error) { + bindings := make(map[string]int64) + if len(claimKeys) == 0 { + return bindings, nil + } + args := make([]any, 1, len(claimKeys)+1) + args[0] = personID + for _, claimKey := range claimKeys { + args = append(args, claimKey) + } + rows, err := tx.QueryContext(ctx, ` + SELECT c.claim_key, d.resolved_organization_id + FROM person_fact_decisions d + JOIN person_fact_claims c ON c.id = d.claim_id + WHERE d.person_id = ? AND d.action = 'applied' + AND d.resolved_organization_id IS NOT NULL + AND c.claim_key IN (`+placeholders(len(claimKeys))+`) + ORDER BY c.claim_key, d.id DESC + `, args...) + if err != nil { + return nil, fmt.Errorf("load employment organization bindings: %w", err) + } + defer func() { _ = rows.Close() }() + for rows.Next() { + var claimKey string + var organizationID int64 + if err := rows.Scan(&claimKey, &organizationID); err != nil { + return nil, fmt.Errorf("scan employment organization binding: %w", err) + } + if _, exists := bindings[claimKey]; !exists { + bindings[claimKey] = organizationID + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate employment organization bindings: %w", err) + } + return bindings, nil +} + +func (s *Store) reconcilePersonFactEmploymentClaimsTx( + ctx context.Context, tx *loggedTx, personID int64, claims []personfacts.ResolvedClaim, + chronology map[string]personFactClaimChronology, +) ([]personfacts.ResolvedClaim, error) { + // Employment corrections and retirements form a timeline per canonical + // organization/title identity. Only the newest generation may project again. + projected, err := loadProjectedPersonFactEmploymentClaimsTx(ctx, tx, personID) + if err != nil { + return nil, err + } + identities := make([]string, len(claims)) + endDated := make([]bool, len(claims)) + latest := make(map[string]personFactClaimChronology) + type projectedEpisode struct { + chronology personFactClaimChronology + relation personfacts.ClaimRelation + fingerprint string + } + projectedByIdentity := make(map[string]projectedEpisode) + for index := range claims { + if claims[index].Failure != nil || claims[index].Normalized == nil { + continue + } + identity, historical, identityErr := personFactEmploymentStableIdentity(claims[index]) + if identityErr != nil { + return nil, identityErr + } + claimChronology, exists := chronology[claims[index].ClaimKey] + if !exists { + return nil, fmt.Errorf("employment claim %q has no ledger chronology", claims[index].ClaimKey) + } + identities[index] = identity + endDated[index] = historical + if current, exists := latest[identity]; !exists || personFactChronologyBefore(current, claimChronology) { + latest[identity] = claimChronology + } + if rowID, exists := projected[claims[index].ClaimKey]; exists { + matches, matchErr := s.personFactEmploymentProjectionMatchesClaimTx( + ctx, tx, personID, rowID, claims[index]) + if matchErr != nil { + return nil, matchErr + } + if !matches { + continue + } + projection := projectedEpisode{ + chronology: claimChronology, relation: claims[index].Claim.Relation, + fingerprint: claims[index].Normalized.Fingerprint, + } + if current, exists := projectedByIdentity[identity]; !exists || personFactChronologyBefore(current.chronology, claimChronology) { + projectedByIdentity[identity] = projection + } + } + } + + reconciled := make([]personfacts.ResolvedClaim, 0, len(claims)) + for index, claim := range claims { + identity := identities[index] + if identity == "" { + reconciled = append(reconciled, claim) + continue + } + claimChronology := chronology[claim.ClaimKey] + if !samePersonFactChronology(claimChronology, latest[identity]) { + continue + } + if claim.Claim.Relation == personfacts.RelationSupport && endDated[index] { + projection, alreadyProjected := projectedByIdentity[identity] + if alreadyProjected && projection.relation == personfacts.RelationSupport && + projection.fingerprint == claim.Normalized.Fingerprint { + continue + } + } + reconciled = append(reconciled, claim) + } + return reconciled, nil +} + +func reconcilePersonFactEmploymentCompetition( + resolution *personfacts.Resolution, claims []personfacts.ResolvedClaim, +) error { + type direction struct { + indexes []int + representative int + } + claimByKey := make(map[string]personfacts.ResolvedClaim, len(claims)) + for _, claim := range claims { + claimByKey[claim.ClaimKey] = claim + } + byIdentityFingerprint := make(map[string]map[string][]int) + for index := range resolution.Decisions { + decision := resolution.Decisions[index] + claim, exists := claimByKey[decision.ClaimKey] + if !exists { + return fmt.Errorf("employment decision references unknown claim %q", decision.ClaimKey) + } + if claim.Failure != nil || claim.Normalized == nil || + claim.Claim.Relation != personfacts.RelationSupport { + continue + } + // These are the complete outcomes for an eligible support-only + // fingerprint direction after resolveMulti. Other outcomes are either + // ineligible claims or support directions defeated by an explicit + // contradiction/supersession and must not be reactivated here. + eligible := decision.Action == personfacts.DecisionApplied + if decision.Action == personfacts.DecisionSuperseded { + eligible = decision.Reason == personfacts.ReasonAppliedProjection + } + if decision.Action == personfacts.DecisionRetained { + eligible = decision.Reason == personfacts.ReasonBelowThreshold + } + if !eligible { + continue + } + identity, historical, err := personFactEmploymentStableIdentity(claim) + if err != nil { + return err + } + if historical { + continue + } + if byIdentityFingerprint[identity] == nil { + byIdentityFingerprint[identity] = make(map[string][]int) + } + fingerprint := claim.Normalized.Fingerprint + byIdentityFingerprint[identity][fingerprint] = append( + byIdentityFingerprint[identity][fingerprint], index) + } + byIdentity := make(map[string][]direction, len(byIdentityFingerprint)) + for identity, byFingerprint := range byIdentityFingerprint { + for _, indexes := range byFingerprint { + sort.Slice(indexes, func(i, j int) bool { + left := resolution.Decisions[indexes[i]] + right := resolution.Decisions[indexes[j]] + if left.Score.Total != right.Score.Total { + return left.Score.Total > right.Score.Total + } + return left.ClaimKey < right.ClaimKey + }) + representative := indexes[0] + for _, index := range indexes { + if resolution.Decisions[index].Action == personfacts.DecisionApplied { + representative = index + break + } + } + byIdentity[identity] = append(byIdentity[identity], direction{ + indexes: indexes, representative: representative, + }) + } + } + + notApplied := make(map[string]struct{}) + setDecision := func(index int, action personfacts.DecisionAction, reason personfacts.DecisionReason, competing string) error { + decision := &resolution.Decisions[index] + decision.Action = action + decision.Reason = reason + decision.CompetingClaimKey = competing + decision.Projection = nil + key, err := personfacts.DecisionKey( + resolution.InputFingerprint, decision.ClaimKey, decision.Action) + if err != nil { + return err + } + decision.DecisionKey = key + if action == personfacts.DecisionApplied { + delete(notApplied, decision.ClaimKey) + } else { + notApplied[decision.ClaimKey] = struct{}{} + } + return nil + } + setDirection := func(item direction, action personfacts.DecisionAction, reason personfacts.DecisionReason, competing string) error { + for _, index := range item.indexes { + if err := setDecision(index, action, reason, competing); err != nil { + return err + } + } + return nil + } + setWinningDirection := func(item direction) error { + representative := resolution.Decisions[item.representative].ClaimKey + for _, index := range item.indexes { + action := personfacts.DecisionSuperseded + competing := representative + if index == item.representative { + action = personfacts.DecisionApplied + competing = "" + } + if err := setDecision(index, action, personfacts.ReasonAppliedProjection, competing); err != nil { + return err + } + } + return nil + } + policy := personfacts.DefaultPolicyV1() + for _, directions := range byIdentity { + if len(directions) < 2 { + continue + } + sort.Slice(directions, func(i, j int) bool { + left := resolution.Decisions[directions[i].representative] + right := resolution.Decisions[directions[j].representative] + if left.Score.Total != right.Score.Total { + return left.Score.Total > right.Score.Total + } + return left.ClaimKey < right.ClaimKey + }) + winner := resolution.Decisions[directions[0].representative].ClaimKey + if resolution.Decisions[directions[0].representative].Score.Total < policy.ApplyThreshold { + for _, item := range directions { + if err := setDirection(item, personfacts.DecisionRetained, + personfacts.ReasonBelowThreshold, ""); err != nil { + return err + } + } + continue + } + if resolution.Decisions[directions[0].representative].Score.Total == + resolution.Decisions[directions[1].representative].Score.Total { + tied := 2 + for tied < len(directions) && + resolution.Decisions[directions[tied].representative].Score.Total == + resolution.Decisions[directions[0].representative].Score.Total { + tied++ + } + for tiedIndex, item := range directions[:tied] { + competing := resolution.Decisions[directions[(tiedIndex+1)%tied].representative].ClaimKey + if err := setDirection(item, personfacts.DecisionAmbiguousRetained, + personfacts.ReasonCompetingTie, competing); err != nil { + return err + } + } + for _, item := range directions[tied:] { + if err := setDirection(item, personfacts.DecisionConflictRejected, + personfacts.ReasonInsufficientMargin, winner); err != nil { + return err + } + } + continue + } + if resolution.Decisions[directions[0].representative].Score.Total- + resolution.Decisions[directions[1].representative].Score.Total < + policy.ReplacementMargin { + if err := setDirection(directions[0], personfacts.DecisionRetained, + personfacts.ReasonInsufficientMargin, + resolution.Decisions[directions[1].representative].ClaimKey); err != nil { + return err + } + for _, item := range directions[1:] { + if err := setDirection(item, personfacts.DecisionConflictRejected, + personfacts.ReasonInsufficientMargin, winner); err != nil { + return err + } + } + continue + } + if err := setWinningDirection(directions[0]); err != nil { + return err + } + for _, item := range directions[1:] { + if err := setDirection(item, personfacts.DecisionSuperseded, + personfacts.ReasonAppliedProjection, winner); err != nil { + return err + } + } + } + if len(notApplied) == 0 { + return nil + } + plans := resolution.Projections[:0] + for _, plan := range resolution.Projections { + if _, exists := notApplied[plan.ClaimKey]; !exists { + plans = append(plans, plan) + } + } + resolution.Projections = plans + return nil +} + +func (s *Store) personFactEmploymentProjectionMatchesClaimTx( + ctx context.Context, tx *loggedTx, personID, rowID int64, claim personfacts.ResolvedClaim, +) (bool, error) { + employment, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, rowID) + if errors.Is(err, ErrEmploymentNotFound) { + return false, nil + } + if err != nil { + return false, err + } + if employment.PersonID != personID { + return false, nil + } + organization, err := getOrganizationForUpdateTx(ctx, tx, s.dialect, employment.OrganizationID) + if err != nil { + return false, err + } + actual, err := normalizedPersonFactEmploymentValue(claim.Claim.Target, *employment, *organization) + if err != nil { + return false, err + } + return claim.Normalized != nil && actual.Fingerprint == claim.Normalized.Fingerprint, nil +} + +func loadProjectedPersonFactEmploymentClaimsTx( + ctx context.Context, tx *loggedTx, personID int64, +) (map[string]int64, error) { + rows, err := tx.QueryContext(ctx, ` + SELECT c.claim_key, d.projection_row_id + FROM person_fact_decisions d + JOIN person_fact_claims c ON c.id = d.claim_id + WHERE d.person_id = ? AND d.action = 'applied' + AND d.projection_kind = 'employment' AND d.projection_row_id IS NOT NULL + ORDER BY c.claim_key, d.id DESC`, personID) + if err != nil { + return nil, fmt.Errorf("load projected employment claims: %w", err) + } + defer func() { _ = rows.Close() }() + projected := make(map[string]int64) + for rows.Next() { + var claimKey string + var rowID int64 + if scanErr := rows.Scan(&claimKey, &rowID); scanErr != nil { + return nil, fmt.Errorf("scan projected employment claim: %w", scanErr) + } + if _, exists := projected[claimKey]; !exists { + projected[claimKey] = rowID + } + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("iterate projected employment claims: %w", err) + } + return projected, nil +} + +func personFactEmploymentStableIdentity( + claim personfacts.ResolvedClaim, +) (string, bool, error) { + if claim.Normalized == nil { + return "", false, fmt.Errorf("employment claim %q has no normalized value", claim.ClaimKey) + } + return personFactEmploymentNormalizedStableIdentity(claim.ClaimKey, *claim.Normalized) +} + +func personFactEmploymentNormalizedStableIdentity( + claimKey string, normalized personfacts.NormalizedValue, +) (string, bool, error) { + var value personfacts.EmploymentValue + if err := json.Unmarshal(normalized.JSON, &value); err != nil { + return "", false, fmt.Errorf("decode employment claim %q for chronology: %w", claimKey, err) + } + _, keys, err := normalizePersonFactOrganizationReference(value.Organization) + if err != nil { + return "", false, fmt.Errorf("normalize employment claim %q organization for chronology: %w", + claimKey, err) + } + organization, err := json.Marshal(keys) + if err != nil { + return "", false, fmt.Errorf("encode employment claim %q organization identity: %w", + claimKey, err) + } + title := value.Title + return string(organization) + "\x00" + NormalizeEmploymentTitle(&title), value.EndDate != nil, nil +} + +func personFactChronologyBefore(left, right personFactClaimChronology) bool { + if !left.ResolvedAt.Equal(right.ResolvedAt) { + return left.ResolvedAt.Before(right.ResolvedAt) + } + return left.GenerationID < right.GenerationID +} + +func samePersonFactChronology(left, right personFactClaimChronology) bool { + return left.ResolvedAt.Equal(right.ResolvedAt) && left.GenerationID == right.GenerationID +} + +func (p *personFactEmploymentProjector) retireExpired( + ctx context.Context, personID int64, resolvedAt time.Time, + current []personfacts.CurrentProjection, claims []personfacts.ResolvedClaim, + decisions []personfacts.Decision, planned map[personfacts.ProjectionRef]struct{}, +) ([]personFactRetiredProjection, bool, error) { + type expiry struct { + claim personfacts.ResolvedClaim + decisionKey string + validUntil time.Time + } + claimByKey := make(map[string]personfacts.ResolvedClaim, len(claims)) + for _, claim := range claims { + claimByKey[claim.ClaimKey] = claim + } + live := make(map[string]struct{}) + expired := make(map[string]expiry) + for _, decision := range decisions { + claim, exists := claimByKey[decision.ClaimKey] + if !exists || claim.Normalized == nil || + claim.Claim.Relation != personfacts.RelationSupport { + continue + } + identity, _, err := personFactEmploymentStableIdentity(claim) + if err != nil { + return nil, false, err + } + if decision.Reason == personfacts.ReasonEvidenceUnsupported { + live[identity] = struct{}{} + continue + } + if decision.Reason != personfacts.ReasonOutsideValidity { + if decision.Action != personfacts.DecisionInvalid && + decision.Action != personfacts.DecisionIdentityRejected && + decision.Action != personfacts.DecisionPolicyRejected { + live[identity] = struct{}{} + } + continue + } + if claim.Claim.ValidUntil == nil || resolvedAt.Before(*claim.Claim.ValidUntil) { + continue + } + candidate := expiry{ + claim: claim, decisionKey: decision.DecisionKey, + validUntil: claim.Claim.ValidUntil.UTC(), + } + previous, exists := expired[identity] + if !exists || previous.validUntil.Before(candidate.validUntil) || + (previous.validUntil.Equal(candidate.validUntil) && + candidate.claim.ClaimKey < previous.claim.ClaimKey) { + expired[identity] = candidate + } + } + + retired := make([]personFactRetiredProjection, 0) + for _, item := range current { + if item.Declared { + continue + } + if _, exists := planned[item.Ref]; exists { + continue + } + identity, _, err := personFactEmploymentNormalizedStableIdentity( + fmt.Sprintf("projection:%d", item.Ref.RowID), item.Normalized) + if err != nil { + return nil, false, err + } + if _, exists := live[identity]; exists { + continue + } + candidate, exists := expired[identity] + if !exists { + continue + } + owned, err := ownsPersonFactProjectionTx(ctx, p.tx, personID, item.Ref) + if err != nil { + return nil, false, err + } + if !owned { + continue + } + employment, err := getEmploymentForUpdateTx( + ctx, p.tx, p.store.dialect, item.Ref.RowID) + if err != nil { + return nil, false, err + } + if employment.PersonID != personID { + return nil, false, ErrEmploymentRevisionConflict + } + if !employment.IsCurrent { + continue + } + endDate := PartialDate{ + Year: new(candidate.validUntil.Year()), Month: new(int(candidate.validUntil.Month())), + Day: new(candidate.validUntil.Day()), + } + if employment.StartDate != nil && + CompareAtSharedPrecision(endDate, *employment.StartDate) < 0 { + endDate = *employment.StartDate + } + source, err := personFactEmploymentProvenance(candidate.claim.Claim.Origin) + if err != nil { + return nil, false, err + } + ended, err := p.store.endEmploymentProjectionTx( + ctx, p.tx, employment, endDate, source, + personFactDecisionSourceRefPrefix+candidate.decisionKey, + float64(candidate.claim.Claim.Confidence.ReportedScore)/1000) + if err != nil { + return nil, false, err + } + retired = append(retired, personFactRetiredProjection{ + Ref: personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: ended.ID}, + ClaimKey: candidate.claim.ClaimKey, + }) + } + return retired, len(retired) > 0, nil +} + +func (p *personFactEmploymentProjector) project( + ctx context.Context, plan personfacts.ProjectionPlan, resolved personfacts.ResolvedClaim, + decisionKey string, transactionTime time.Time, +) (*personfacts.ProjectionRef, bool, error) { + if p.consumed == nil { + p.consumed = make(map[int64]struct{}) + } + ref, err := p.store.projectEmploymentFactTx( + ctx, p.tx, plan, resolved, p.organizationLocks, p.consumed, decisionKey, transactionTime) + if ref != nil { + p.consumed[ref.RowID] = struct{}{} + } + return ref, ref != nil, err +} + +func (s *Store) projectEmploymentFactTx( + ctx context.Context, tx *loggedTx, plan personfacts.ProjectionPlan, + resolved personfacts.ResolvedClaim, organizationLocks *personFactOrganizationLockSet, + consumed map[int64]struct{}, decisionKey string, transactionTime time.Time, +) (*personfacts.ProjectionRef, error) { + if transactionTime.IsZero() || decisionKey == "" { + return nil, errors.New("employment fact projection metadata is required") + } + if plan.Target.Kind != personfacts.TargetEmployment { + return nil, fmt.Errorf("employment projector cannot project target %q", plan.Target.Kind) + } + claim, err := scanPersonFactClaim(tx.QueryRowContext(ctx, + `SELECT `+personFactClaimColumns+` FROM person_fact_claims c WHERE c.claim_key = ?`, + plan.ClaimKey)) + if err != nil { + return nil, fmt.Errorf("load employment projection claim %q: %w", plan.ClaimKey, err) + } + if err := s.lockEmploymentPeopleTx(ctx, tx, claim.PersonID); err != nil { + return nil, err + } + source, err := personFactEmploymentProvenance(claim.Origin) + if err != nil { + return nil, err + } + sourceRef := personFactDecisionSourceRefPrefix + decisionKey + confidence := float64(claim.Confidence.ReportedScore) / 1000 + + switch plan.Operation { + case personfacts.ProjectionRetire: + if plan.CurrentRef == nil || plan.CurrentRef.Kind != "employment" { + return nil, errors.New("employment retirement requires an exact current employment reference") + } + current, err := getEmploymentForUpdateTx(ctx, tx, s.dialect, plan.CurrentRef.RowID) + if err != nil { + return nil, err + } + if current.PersonID != claim.PersonID { + return nil, ErrEmploymentRevisionConflict + } + if !current.IsCurrent { + owned, ownershipErr := ownsPersonFactProjectionTx( + ctx, tx, claim.PersonID, *plan.CurrentRef) + if ownershipErr != nil { + return nil, ownershipErr + } + if !owned { + return nil, ErrEmploymentRevisionConflict + } + var deletedID int64 + deleteErr := tx.QueryRowContext(ctx, + `DELETE FROM employments WHERE id = ? AND revision = ? RETURNING id`, + current.ID, current.Revision).Scan(&deletedID) + if errors.Is(deleteErr, sql.ErrNoRows) { + return nil, ErrEmploymentRevisionConflict + } + if deleteErr != nil { + return nil, fmt.Errorf("retract historical employment projection: %w", deleteErr) + } + return &personfacts.ProjectionRef{ + Kind: personFactProjectionKindEmployment, RowID: deletedID, + }, nil + } + endDate := PartialDate{ + Year: new(plan.ActiveFrom.Year()), Month: new(int(plan.ActiveFrom.Month())), + Day: new(plan.ActiveFrom.Day()), + } + if current.StartDate != nil && CompareAtSharedPrecision(endDate, *current.StartDate) < 0 { + endDate = *current.StartDate + } + ended, err := s.endEmploymentProjectionTx( + ctx, tx, current, endDate, source, sourceRef, confidence) + if err != nil { + return nil, err + } + return &personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: ended.ID}, nil + + case personfacts.ProjectionSet: + if resolved.ClaimKey != plan.ClaimKey { + return nil, errors.New("employment projection resolved claim does not match its plan") + } + if resolved.Normalized == nil { + return nil, errors.New("employment projection claim has no normalized value") + } + var value personfacts.EmploymentValue + if err := json.Unmarshal(resolved.Normalized.JSON, &value); err != nil { + return nil, fmt.Errorf("decode employment projection value: %w", err) + } + ref, keys, err := normalizePersonFactOrganizationReference(value.Organization) + if err != nil { + return nil, err + } + if organizationLocks == nil { + return nil, errors.New("employment projection organization lock scope is required") + } + prepared, err := s.prepareLockedPersonFactOrganizationReferenceTx( + ctx, tx, ref, keys, organizationLocks, true) + if err != nil { + return nil, err + } + organization, status, err := s.materializeLockedPersonFactOrganizationTx( + ctx, tx, ref, prepared, organizationLocks) + if err != nil { + return nil, err + } + if status == OrganizationAmbiguous || organization == nil { + return nil, errors.New("accepted employment projection has an ambiguous organization") + } + input := personFactEmploymentInput( + claim.PersonID, organization.ID, value, source, sourceRef, confidence) + if value.EndDate == nil { + current, currentErr := scanEmployment(tx.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM employments + WHERE person_id = ? AND organization_id = ? AND title_normalized = ? + AND %s%s + `, employmentColumns, s.dialect.BoolTrueExpr("is_current"), s.dialect.SelectForUpdate()), + claim.PersonID, organization.ID, NormalizeEmploymentTitle(input.Title))) + if currentErr == nil { + if _, alreadyConsumed := consumed[current.ID]; !alreadyConsumed { + corrected, reviseErr := s.reviseEmploymentTx( + ctx, tx, current.ID, current.Revision, input) + if reviseErr != nil { + return nil, reviseErr + } + return &personfacts.ProjectionRef{ + Kind: personFactProjectionKindEmployment, RowID: corrected.ID, + }, nil + } + } else if !errors.Is(currentErr, sql.ErrNoRows) { + return nil, fmt.Errorf("find correctable employment: %w", currentErr) + } + } + projectedID, projectionErr := s.loadMatchingPersonFactEmploymentProjectionTx( + ctx, tx, claim, input, consumed) + if projectionErr == nil && value.EndDate != nil { + historical, loadErr := getEmploymentForUpdateTx(ctx, tx, s.dialect, projectedID) + if loadErr != nil { + return nil, loadErr + } + corrected, reviseErr := s.reviseEmploymentTx( + ctx, tx, historical.ID, historical.Revision, input) + if reviseErr != nil { + return nil, reviseErr + } + return &personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: corrected.ID}, nil + } + if projectionErr != nil && !errors.Is(projectionErr, sql.ErrNoRows) { + return nil, projectionErr + } + inserted, err := s.addEmploymentTx(ctx, tx, input) + if err != nil { + return nil, err + } + return &personfacts.ProjectionRef{Kind: personFactProjectionKindEmployment, RowID: inserted.ID}, nil + default: + return nil, fmt.Errorf("unsupported employment projection operation %q", plan.Operation) + } +} + +func (s *Store) loadMatchingPersonFactEmploymentProjectionTx( + ctx context.Context, tx *loggedTx, claim personfacts.Claim, input EmploymentInput, + consumed map[int64]struct{}, +) (int64, error) { + rows, err := tx.QueryContext(ctx, fmt.Sprintf(` + SELECT %s FROM employments + WHERE person_id = ? AND organization_id = ? AND title_normalized = ? + AND NOT (%s) AND id IN ( + SELECT d.projection_row_id + FROM person_fact_decisions d + JOIN person_fact_claims c ON c.id = d.claim_id + WHERE d.person_id = ? AND d.action = 'applied' + AND d.projection_kind = 'employment' AND d.projection_row_id IS NOT NULL + AND c.relation = 'support' AND c.origin = ?) + ORDER BY id`, employmentColumns, s.dialect.BoolTrueExpr("is_current")), + claim.PersonID, input.OrganizationID, NormalizeEmploymentTitle(input.Title), + claim.PersonID, claim.Origin) + if err != nil { + return 0, fmt.Errorf("load employment projection candidates: %w", err) + } + defer func() { _ = rows.Close() }() + type episodeCandidate struct { + rowID int64 + score int + } + candidates := make([]episodeCandidate, 0) + for rows.Next() { + employment, scanErr := scanEmployment(rows) + if scanErr != nil { + return 0, fmt.Errorf("scan employment projection candidate: %w", scanErr) + } + if _, exists := consumed[employment.ID]; exists { + continue + } + score := 0 + if samePersonFactEmploymentDate(employment.StartDate, input.StartDate) { + score += 2 + } + if samePersonFactEmploymentDate(employment.EndDate, input.EndDate) { + score++ + } + if personFactEmploymentDateRangesOverlap(employment, input) { + score++ + } + candidates = append(candidates, episodeCandidate{rowID: employment.ID, score: score}) + } + if err := rows.Err(); err != nil { + return 0, fmt.Errorf("iterate employment projection candidates: %w", err) + } + if len(candidates) == 1 && candidates[0].score > 0 { + return candidates[0].rowID, nil + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score != candidates[j].score { + return candidates[i].score > candidates[j].score + } + return candidates[i].rowID < candidates[j].rowID + }) + if len(candidates) == 0 || candidates[0].score == 0 || + candidates[0].score == candidates[1].score { + return 0, sql.ErrNoRows + } + return candidates[0].rowID, nil +} + +func samePersonFactEmploymentDate(left, right *PartialDate) bool { + return left != nil && right != nil && CompareAtSharedPrecision(*left, *right) == 0 +} + +func personFactEmploymentDateRangesOverlap(employment *Employment, input EmploymentInput) bool { + return employment.StartDate != nil && employment.EndDate != nil && + input.StartDate != nil && input.EndDate != nil && + CompareAtSharedPrecision(*employment.EndDate, *input.StartDate) >= 0 && + CompareAtSharedPrecision(*input.EndDate, *employment.StartDate) >= 0 +} + +func normalizedPersonFactEmploymentValue( + target personfacts.TargetDescriptor, employment Employment, organization Organization, +) (*personfacts.NormalizedValue, error) { + value := personfacts.EmploymentValue{ + Organization: canonicalPersonFactOrganizationReference(organization), + Title: personFactStringValue(employment.Title), Role: personFactStringValue(employment.Role), + Department: personFactStringValue(employment.Department), + Location: personFactStringValue(employment.Location), + StartDate: personFactEmploymentPartialDate(employment.StartDate), + EndDate: personFactEmploymentPartialDate(employment.EndDate), + } + normalized, failure, err := personfacts.NormalizeClaimValue(target, mustMarshalPersonFactEmployment(value)) + if err != nil { + return nil, err + } + if failure != nil { + return nil, fmt.Errorf("normalize stored employment projection: %s", failure.Detail) + } + return normalized, nil +} + +func canonicalPersonFactOrganizationReference(organization Organization) personfacts.OrganizationReference { + ref := personfacts.OrganizationReference{ID: new(organization.ID), Name: organization.Name} + if organization.PrimaryDomain != nil { + ref.Domain = *organization.PrimaryDomain + } + return ref +} + +func mustMarshalPersonFactEmployment(value personfacts.EmploymentValue) json.RawMessage { + encoded, err := json.Marshal(value) + if err != nil { + panic(err) + } + return encoded +} + +func personFactEmploymentPartialDate(value *PartialDate) *personfacts.PartialDateValue { + if value == nil || value.Year == nil { + return nil + } + result := &personfacts.PartialDateValue{Year: *value.Year} + if value.Month != nil { + result.Month = *value.Month + } + if value.Day != nil { + result.Day = *value.Day + } + return result +} + +func storeEmploymentPartialDate(value *personfacts.PartialDateValue) *PartialDate { + if value == nil { + return nil + } + result := &PartialDate{Year: new(value.Year)} + if value.Month != 0 { + result.Month = new(value.Month) + } + if value.Day != 0 { + result.Day = new(value.Day) + } + return result +} + +func personFactEmploymentActiveFrom(employment Employment) time.Time { + if employment.StartDate == nil || employment.StartDate.Year == nil { + return employment.CreatedAt.UTC() + } + month, day := 1, 1 + if employment.StartDate.Month != nil { + month = *employment.StartDate.Month + } + if employment.StartDate.Day != nil { + day = *employment.StartDate.Day + } + return time.Date(*employment.StartDate.Year, time.Month(month), day, 0, 0, 0, 0, time.UTC) +} + +func personFactEmploymentProvenance(origin personfacts.ClaimOrigin) (Provenance, error) { + switch origin { + case personfacts.OriginExtraction: + return ProvenanceExtraction, nil + case personfacts.OriginEnrichment: + return ProvenanceEnrichment, nil + case personfacts.OriginSystem: + return ProvenanceSystem, nil + default: + return "", fmt.Errorf("unsupported employment claim origin %q", origin) + } +} + +func personFactEmploymentInput( + personID, organizationID int64, value personfacts.EmploymentValue, + source Provenance, sourceRef string, confidence float64, +) EmploymentInput { + current := value.EndDate == nil + return EmploymentInput{ + PersonID: personID, OrganizationID: organizationID, + Title: trimEmploymentString(&value.Title), Role: trimEmploymentString(&value.Role), + Department: trimEmploymentString(&value.Department), + Location: trimEmploymentString(&value.Location), + StartDate: storeEmploymentPartialDate(value.StartDate), + EndDate: storeEmploymentPartialDate(value.EndDate), + IsCurrent: ¤t, Source: source, SourceRef: &sourceRef, Confidence: &confidence, + } +} + +func (p *personFactAttributeProjector) project( + ctx context.Context, plan personfacts.ProjectionPlan, claim personfacts.ResolvedClaim, + decisionKey string, transactionTime time.Time, +) (*personfacts.ProjectionRef, bool, error) { + if claim.Normalized == nil { + return nil, false, errors.New("attribute projection claim has no normalized value") + } + switch plan.Operation { + case personfacts.ProjectionSet: + if len(claim.Claim.Evidence) == 0 { + return nil, false, errors.New("attribute projection claim has no evidence") + } + value, err := personFactAttributeValueFromNormalized(p.definition, *claim.Normalized) + if err != nil { + return nil, false, err + } + var source Provenance + switch claim.Claim.Origin { + case personfacts.OriginEnrichment: + source = ProvenanceEnrichment + case personfacts.OriginSystem: + source = ProvenanceSystem + default: + source = ProvenanceExtraction + } + sourceRef := personFactDecisionSourceRefPrefix + decisionKey + confidence := float64(claim.Claim.Confidence.ReportedScore) / 1000 + activeFrom := plan.ActiveFrom + input := PersonAttributeValueInput{ + PersonID: claim.Claim.Evidence[0].PersonID, DefinitionSlug: p.definition.Slug, + Value: value, ActiveFrom: &activeFrom, Source: source, + SourceRef: &sourceRef, Confidence: &confidence, + } + if plan.CurrentRef != nil { + current, err := p.loadCurrentValue(ctx, plan.CurrentRef.RowID) + if err != nil { + return nil, false, err + } + input.PersonID = current.PersonID + input.Ordinal = ¤t.Ordinal + input.ExpectedValueID = ¤t.ID + if activeFrom.Before(current.ActiveFrom) { + activeFrom = current.ActiveFrom + } + } + write, err := p.store.setPersonAttributeValueTx( + ctx, p.tx, p.definition, input, activeFrom, transactionTime) + if err != nil { + return nil, false, err + } + ref := &personfacts.ProjectionRef{Kind: "person_attribute", RowID: write.Value.ID} + return ref, true, nil + case personfacts.ProjectionRetire: + if plan.CurrentRef == nil || plan.CurrentRef.Kind != "person_attribute" { + return nil, false, errors.New("attribute retirement requires an exact current attribute reference") + } + current, err := p.loadCurrentValue(ctx, plan.CurrentRef.RowID) + if err != nil { + return nil, false, err + } + activeUntil := plan.ActiveFrom + if activeUntil.Before(current.ActiveFrom) { + activeUntil = current.ActiveFrom + } + if _, err := p.store.closePersonAttributeValueTx( + ctx, p.tx, current.ID, activeUntil, transactionTime); err != nil { + return nil, false, err + } + ref := *plan.CurrentRef + return &ref, true, nil + default: + return nil, false, fmt.Errorf("unsupported person fact projection operation %q", plan.Operation) + } +} + +func (p *personFactAttributeProjector) loadCurrentValue( + ctx context.Context, rowID int64, +) (*PersonAttributeValue, error) { + value, err := scanPersonAttributeValue(p.tx.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM person_attribute_values v + JOIN attribute_definitions d ON d.id = v.definition_id + WHERE v.id = ? AND v.definition_id = ? + AND v.active_until IS NULL AND v.superseded_at IS NULL%s + `, personAttributeValueColumns, p.store.dialect.SelectForUpdate()), rowID, p.definition.ID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAttributeValueConflict + } + if err != nil { + return nil, fmt.Errorf("load person fact current attribute %d: %w", rowID, err) + } + return value, nil +} + +func (p *personFactAttributeProjector) retireUnsupported( + ctx context.Context, personID int64, activeUntil, transactionTime time.Time, + current []personfacts.CurrentProjection, claims []personfacts.ResolvedClaim, + decisions []personfacts.Decision, planned map[personfacts.ProjectionRef]struct{}, +) ([]personFactRetiredProjection, bool, error) { + live := make(map[string]struct{}) + retired := make([]personFactRetiredProjection, 0) + claimByKey := make(map[string]personfacts.ResolvedClaim, len(claims)) + for _, claim := range claims { + claimByKey[claim.ClaimKey] = claim + } + for _, decision := range decisions { + claim := claimByKey[decision.ClaimKey] + if claim.Normalized == nil || claim.Claim.Relation != personfacts.RelationSupport || + decision.Reason == personfacts.ReasonEvidenceUnsupported || + decision.Reason == personfacts.ReasonOutsideValidity || + decision.Action == personfacts.DecisionInvalid || + decision.Action == personfacts.DecisionIdentityRejected || + decision.Action == personfacts.DecisionPolicyRejected { + continue + } + live[claim.Normalized.Fingerprint] = struct{}{} + } + for _, item := range current { + if item.Declared { + continue + } + owned, err := ownsPersonFactProjectionTx(ctx, p.tx, personID, item.Ref) + if err != nil { + return nil, false, err + } + if !owned { + continue + } + if _, exists := planned[item.Ref]; exists { + continue + } + if _, exists := live[item.Normalized.Fingerprint]; exists { + continue + } + currentValue, err := p.loadCurrentValue(ctx, item.Ref.RowID) + if err != nil { + return nil, false, err + } + claimKey := "" + expiryClaimKey := "" + var expiredAt *time.Time + for _, decision := range decisions { + claim := claimByKey[decision.ClaimKey] + if claim.Normalized == nil || claim.Normalized.Fingerprint != item.Normalized.Fingerprint { + continue + } + if decision.Reason == personfacts.ReasonEvidenceUnsupported { + claimKey = decision.ClaimKey + expiredAt = nil + break + } + if decision.Reason != personfacts.ReasonOutsideValidity || claim.Claim.ValidUntil == nil || + activeUntil.Before(*claim.Claim.ValidUntil) { + continue + } + validUntil := claim.Claim.ValidUntil.UTC() + if expiredAt == nil || expiredAt.Before(validUntil) || + (expiredAt.Equal(validUntil) && decision.ClaimKey < expiryClaimKey) { + expiryClaimKey = decision.ClaimKey + expiredAt = &validUntil + } + } + worldTime := activeUntil + if expiredAt != nil { + worldTime = *expiredAt + claimKey = expiryClaimKey + } + if worldTime.Before(currentValue.ActiveFrom) { + worldTime = currentValue.ActiveFrom + } + if _, err := p.store.closePersonAttributeValueTx( + ctx, p.tx, currentValue.ID, worldTime, transactionTime); err != nil { + return nil, false, err + } + retired = append(retired, personFactRetiredProjection{Ref: item.Ref, ClaimKey: claimKey}) + } + return retired, len(retired) > 0, nil +} + +const personFactAttributeProjectionOwnershipSQL = ` + SELECT EXISTS ( + SELECT 1 FROM person_fact_decisions + WHERE person_id = ? AND projection_kind = ? AND projection_row_id = ? + )` + +const personFactEmploymentProjectionOwnershipSQL = ` + SELECT EXISTS ( + SELECT 1 + FROM employments e + JOIN person_fact_decisions fd + ON fd.person_id = e.person_id + AND fd.projection_kind = 'employment' + AND fd.projection_row_id = e.id + WHERE e.person_id = ? AND e.id = ? + AND e.source_ref = ? || fd.decision_key + )` + +func ownsPersonFactProjectionTx( + ctx context.Context, tx *loggedTx, personID int64, ref personfacts.ProjectionRef, +) (bool, error) { + var owned bool + query := personFactAttributeProjectionOwnershipSQL + args := []any{personID, ref.Kind, ref.RowID} + if ref.Kind == personFactProjectionKindEmployment { + query = personFactEmploymentProjectionOwnershipSQL + args = []any{personID, ref.RowID, personFactDecisionSourceRefPrefix} + } + err := tx.QueryRowContext(ctx, query, args...).Scan(&owned) + if err != nil { + return false, fmt.Errorf("load person fact projection ownership: %w", err) + } + return owned, nil +} + +func (s *Store) loadPersonFactTargetDescriptorTx( + ctx context.Context, tx *loggedTx, kind personfacts.TargetKind, key string, + fallback *personfacts.TargetDescriptor, allowSensitive bool, +) (personfacts.TargetDescriptor, personFactTargetEligibility, error) { + return s.loadPersonFactTargetDescriptor(ctx, tx, kind, key, fallback, allowSensitive, true) +} + +func (s *Store) loadPersonFactTargetDescriptorSnapshotTx( + ctx context.Context, tx *loggedTx, kind personfacts.TargetKind, key string, + fallback *personfacts.TargetDescriptor, allowSensitive bool, +) (personfacts.TargetDescriptor, personFactTargetEligibility, error) { + return s.loadPersonFactTargetDescriptor(ctx, tx, kind, key, fallback, allowSensitive, false) +} + +func (s *Store) loadPersonFactTargetDescriptor( + ctx context.Context, tx *loggedTx, kind personfacts.TargetKind, key string, + fallback *personfacts.TargetDescriptor, allowSensitive, lock bool, +) (personfacts.TargetDescriptor, personFactTargetEligibility, error) { + switch kind { + case personfacts.TargetAttribute: + definition, err := s.getAttributeDefinitionByUniversalID(ctx, tx, key, lock) + if errors.Is(err, ErrAttributeDefinitionNotFound) { + if fallback != nil { + return *fallback, personFactTargetEligibility{}, nil + } + return personfacts.TargetDescriptor{ + Kind: personfacts.TargetAttribute, Key: key, UniversalID: key, + Slug: key, Description: "Unavailable attribute target", + ValueType: personfacts.ValueText, Cardinality: personfacts.CardinalitySingle, + Revision: "unavailable", + }, personFactTargetEligibility{}, nil + } + if err != nil { + return personfacts.TargetDescriptor{}, personFactTargetEligibility{}, err + } + descriptor, err := personFactAttributeDescriptor(*definition) + if err != nil { + return personfacts.TargetDescriptor{}, personFactTargetEligibility{}, err + } + eligibleWithSensitive := personFactAttributeEligible(*definition, true) + return descriptor, personFactTargetEligibility{ + Supported: personFactAttributeEligible(*definition, allowSensitive), + SensitivePolicyBlocked: eligibleWithSensitive && definition.IsSensitive && + !allowSensitive, + }, nil + case personfacts.TargetEmployment: + catalog, err := personfacts.BuildCatalog(nil, personfacts.CatalogOptions{}) + if err != nil { + return personfacts.TargetDescriptor{}, personFactTargetEligibility{}, err + } + for _, descriptor := range catalog.Targets { + if descriptor.Kind == kind && descriptor.Key == key { + return descriptor, personFactTargetEligibility{Supported: true}, nil + } + } + } + if fallback != nil { + return *fallback, personFactTargetEligibility{}, nil + } + return personfacts.TargetDescriptor{}, personFactTargetEligibility{}, + fmt.Errorf("unknown person fact target %s/%s", kind, key) +} + +func (s *Store) getAttributeDefinitionByUniversalID( + ctx context.Context, tx *loggedTx, universalID string, lock bool, +) (*AttributeDefinition, error) { + lockClause := "" + if lock { + lockClause = s.dialect.SelectForUpdate() + } + definition, err := scanAttributeDefinition(tx.QueryRowContext(ctx, fmt.Sprintf(` + SELECT %s FROM attribute_definitions + WHERE object_type = ? AND universal_id = ?%s + `, attributeDefinitionColumns, lockClause), + string(AttributeObjectPerson), universalID)) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrAttributeDefinitionNotFound + } + if err != nil { + return nil, fmt.Errorf("get person attribute definition %q: %w", universalID, err) + } + return definition, nil +} + +func personFactAttributeDescriptor(definition AttributeDefinition) (personfacts.TargetDescriptor, error) { + descriptor := personfacts.TargetDescriptor{ + Kind: personfacts.TargetAttribute, Key: definition.UniversalID, + UniversalID: definition.UniversalID, Slug: definition.Slug, + Description: strings.TrimSpace(personFactStringValue(definition.Description)), + ValueType: personfacts.ValueType(definition.ValueType), + Cardinality: personfacts.Cardinality(definition.Cardinality), + RecordTarget: personFactStringValue(definition.RecordTarget), + MaxLength: personFactMaxLength(definition.Options), + Choices: personFactChoices(definition.Options), Sensitive: definition.IsSensitive, + } + revision, err := personfacts.DescriptorRevision(descriptor) + if err != nil { + return personfacts.TargetDescriptor{}, err + } + descriptor.Revision = revision + return descriptor, nil +} + +func personFactAttributeEligible(definition AttributeDefinition, allowSensitive bool) bool { + if !definition.IsActive || !definition.APIMutable || definition.DerivedSource != nil || + definition.RecordTarget != nil || (definition.IsSensitive && !allowSensitive) { + return false + } + description := strings.TrimSpace(personFactStringValue(definition.Description)) + if description == "" || len([]rune(description)) > 280 { + return false + } + switch definition.ValueType { + case AttributeValueText, AttributeValueInteger, AttributeValueReal, + AttributeValueBoolean, AttributeValueDate, AttributeValueTimestamp: + return true + default: + return false + } +} + +func normalizedPersonFactAttributeValue( + target personfacts.TargetDescriptor, value AttributeValue, +) (*personfacts.NormalizedValue, error) { + raw, err := personFactAttributeValueJSON(value) + if err != nil { + return nil, err + } + normalized, failure, err := personfacts.NormalizeClaimValue(target, raw) + if err != nil { + return nil, err + } + if failure != nil { + return nil, fmt.Errorf("normalize stored attribute projection: %s", failure.Detail) + } + return normalized, nil +} + +func personFactAttributeValueJSON(value AttributeValue) (json.RawMessage, error) { + var scalar any + switch value.Type { + case AttributeValueText: + scalar = value.Text + case AttributeValueInteger: + scalar = value.Integer + case AttributeValueReal: + scalar = value.Real + case AttributeValueBoolean: + scalar = value.Boolean + case AttributeValueDate: + scalar = value.Date + case AttributeValueTimestamp: + if value.Timestamp != nil { + scalar = value.Timestamp.UTC().Format(time.RFC3339Nano) + } + default: + return nil, fmt.Errorf("attribute value type %q is not a generic person fact", value.Type) + } + encoded, err := json.Marshal(scalar) + if err != nil { + return nil, fmt.Errorf("encode stored person fact attribute: %w", err) + } + return encoded, nil +} + +func personFactAttributeValueFromNormalized( + definition AttributeDefinition, normalized personfacts.NormalizedValue, +) (AttributeValue, error) { + value := AttributeValue{Type: definition.ValueType} + var err error + switch definition.ValueType { + case AttributeValueText: + err = json.Unmarshal(normalized.JSON, &value.Text) + case AttributeValueInteger: + err = json.Unmarshal(normalized.JSON, &value.Integer) + case AttributeValueReal: + err = json.Unmarshal(normalized.JSON, &value.Real) + case AttributeValueBoolean: + err = json.Unmarshal(normalized.JSON, &value.Boolean) + case AttributeValueDate: + err = json.Unmarshal(normalized.JSON, &value.Date) + case AttributeValueTimestamp: + var rendered string + if err = json.Unmarshal(normalized.JSON, &rendered); err == nil { + var parsed time.Time + parsed, err = time.Parse(time.RFC3339Nano, rendered) + if err == nil { + parsed = parsed.UTC() + value.Timestamp = &parsed + } + } + default: + return AttributeValue{}, fmt.Errorf("unsupported person fact attribute type %q", definition.ValueType) + } + if err != nil { + return AttributeValue{}, fmt.Errorf("decode normalized person fact attribute: %w", err) + } + return value, nil +} + +func sortPersonFactProjectionPlans(plans []personfacts.ProjectionPlan) { + sort.Slice(plans, func(i, j int) bool { + left, right := plans[i], plans[j] + if left.Target.Kind != right.Target.Kind { + return left.Target.Kind < right.Target.Kind + } + if left.Target.Key != right.Target.Key { + return left.Target.Key < right.Target.Key + } + if left.Operation != right.Operation { + return left.Operation < right.Operation + } + if left.ClaimKey != right.ClaimKey { + return left.ClaimKey < right.ClaimKey + } + return personFactProjectionRowID(left.CurrentRef) < personFactProjectionRowID(right.CurrentRef) + }) +} + +func sortPersonFactProjectionRefs(refs []personfacts.ProjectionRef) { + sort.Slice(refs, func(i, j int) bool { + if refs[i].Kind != refs[j].Kind { + return refs[i].Kind < refs[j].Kind + } + return refs[i].RowID < refs[j].RowID + }) +} + +func personFactProjectionRowID(ref *personfacts.ProjectionRef) int64 { + if ref == nil { + return 0 + } + return ref.RowID +} + +func personFactTargetMapKey(kind personfacts.TargetKind, key string) string { + return string(kind) + "\x00" + key +} + +func copyPersonFactNormalized(value *personfacts.NormalizedValue) *personfacts.NormalizedValue { + if value == nil { + return nil + } + copyValue := *value + copyValue.JSON = append(json.RawMessage(nil), value.JSON...) + return ©Value +} + +func copyPersonFactFailure(value *personfacts.ValidationFailure) *personfacts.ValidationFailure { + if value == nil { + return nil + } + copyValue := *value + return ©Value +} diff --git a/internal/store/person_fact_projection_test.go b/internal/store/person_fact_projection_test.go new file mode 100644 index 000000000..07756b7ac --- /dev/null +++ b/internal/store/person_fact_projection_test.go @@ -0,0 +1,1757 @@ +package store + +import ( + "bytes" + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "log/slog" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestApplyPersonFactGenerationProjectsValidClaimsAndRetainsInvalidClaims(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "mixed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "valid"), + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `42`, "invalid"), + }, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Resolutions, 2) + require.Len(result.Decisions, 2) + assert.Equal([]personfacts.DecisionAction{ + personfacts.DecisionApplied, personfacts.DecisionInvalid, + }, []personfacts.DecisionAction{result.Decisions[0].Action, result.Decisions[1].Action}) + assert.NotEmpty(result.Projections) + + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("chat", *values[0].Value.Text) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + for _, claim := range claims { + if claim.Target.Key == targets[AttributeSlugAskMeAbout].Key { + assert.Nil(claim.Normalized) + } + } +} + +func TestLoadPersonFactResolvedClaimsQueriesStayBoundedAcrossUnrelatedHistory(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + primaryTarget := targets[AttributeSlugPrimaryChannel] + unrelatedTarget := targets[AttributeSlugAskMeAbout] + + for index := range 8 { + suffix := fmt.Sprintf("bounded-history-%d", index) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, suffix, []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, primaryTarget, `"chat"`, suffix+"-primary"), + personFactProjectionClaim(personID, unrelatedTarget, `"topic"`, suffix+"-unrelated"), + }, nil), nil) + require.NoError(err) + } + + storeLogOptions := SQLLogOptions{FullTrace: true, MaxStmtChars: 10_000} + ConfigureSQLLogging(storeLogOptions) + t.Cleanup(func() { ConfigureSQLLogging(SQLLogOptions{}) }) + var logBuffer bytes.Buffer + previousLogger := slog.Default() + slog.SetDefault(slog.New(slog.NewJSONHandler(&logBuffer, &slog.HandlerOptions{Level: slog.LevelDebug}))) + t.Cleanup(func() { slog.SetDefault(previousLogger) }) + + var resolved []personfacts.ResolvedClaim + require.NoError(st.withReadSnapshotContext(t.Context(), func(tx *loggedTx) error { + var loadErr error + resolved, _, loadErr = st.loadPersonFactResolvedClaimsTx( + t.Context(), tx, personID, primaryTarget, nil) + return loadErr + })) + require.Len(resolved, 8) + for _, claim := range resolved { + assert.Equal(primaryTarget.Key, claim.Claim.Target.Key) + } + queryCount := strings.Count(logBuffer.String(), `"msg":"sql"`) + assert.Positive(queryCount, "SQL trace must observe the production query path") + assert.LessOrEqual(queryCount, 4, + "target claim hydration must use a bounded number of SQL statements") +} + +func TestApplyPersonFactGenerationPersistsDistinctMalformedEvidenceSubmissions(t *testing.T) { + assertions := assert.New(t) + requirements := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + + input := personFactProjectionInput(personID, "malformed-evidence-identity", nil, nil) + claim := personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "malformed-evidence-first") + claim.Evidence[0].PersonID = personID + 1 + input.Claims = []personfacts.ProposedClaim{claim} + first, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + requirements.NoError(err) + requirements.Len(first.Decisions, 1) + assertions.Equal(personfacts.DecisionIdentityRejected, first.Decisions[0].Action) + + input.Claims[0].Evidence[0].SourceURL = "https://example.com/malformed-evidence-second" + second, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + requirements.NoError(err) + requirements.Len(second.Decisions, 2) + for _, decision := range second.Decisions { + assertions.Equal(personfacts.DecisionIdentityRejected, decision.Action) + } + assertions.NotEqual(first.GenerationID, second.GenerationID) + assertions.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) + assertions.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_claims")) +} + +func TestApplyPersonFactGenerationPersistsUnsupportedTargetBesideValidSibling(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + unsupported := targets[AttributeSlugAskMeAbout] + unsupported.Kind = personfacts.TargetKind("candidate") + unsupported.Key = "system:candidate" + unsupported.UniversalID = unsupported.Key + unsupported.Revision = strings.Repeat("a", 64) + input := personFactProjectionInput(personID, "unsupported-target", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, unsupported, `"candidate"`, "unsupported-target"), + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "supported-target"), + }, nil) + + first, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(first.Decisions, 2) + require.Len(first.Projections, 1) + invalid := 0 + for _, decision := range first.Decisions { + if decision.Action == personfacts.DecisionInvalid { + invalid++ + assert.Equal(personfacts.ReasonUnsupportedTarget, decision.Reason) + assert.Nil(decision.Projection) + } + } + assert.Equal(1, invalid) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + foundUnsupported := false + for _, claim := range claims { + if claim.Target.Kind != unsupported.Kind { + continue + } + foundUnsupported = true + require.NotNil(claim.Failure) + assert.Equal(personfacts.ReasonUnsupportedTarget, claim.Failure.Reason) + assert.Nil(claim.Normalized) + } + assert.True(foundUnsupported) + + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(first.GenerationID, replayed.GenerationID) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_attribute_values")) + + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + var evidenceKey string + for _, item := range evidence { + if strings.HasSuffix(item.Input.SourceURL, "/unsupported-target") { + evidenceKey = item.Key + } + } + require.NotEmpty(evidenceKey) + status := personFactProjectionInput(personID, "unsupported-target-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + statusResult, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + require.Len(statusResult.Resolutions, 1) + require.Len(statusResult.Decisions, 1) + assert.Equal(unsupported.Kind, statusResult.Resolutions[0].Target.Kind) + assert.Equal(personfacts.ReasonUnsupportedTarget, statusResult.Decisions[0].Reason) + statusReplay, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + assert.Equal(statusResult.GenerationID, statusReplay.GenerationID) +} + +func TestApplyPersonFactGenerationPreservesSensitivePolicyFailureForNonProjectableTarget(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, AttributeSlugReligion) + input := personFactProjectionInput(personID, "sensitive-policy", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"Buddhist"`, "sensitive-policy"), + }, nil) + input.Policy.AllowSensitive = false + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionPolicyRejected, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonSensitivePolicy, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 1) + require.NotNil(claims[0].Failure) + assert.Equal(personfacts.DecisionPolicyRejected, claims[0].Failure.Action) + assert.Equal(personfacts.ReasonSensitivePolicy, claims[0].Failure.Reason) +} + +func TestApplyPersonFactStatusOnlyGenerationAppliesCurrentSensitivePolicy(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, AttributeSlugReligion) + seedInput := personFactProjectionInput(personID, "sensitive-policy-status-seed", + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"Buddhist"`, "sensitive-policy-status-seed"), + }, nil) + seedInput.Policy.AllowSensitive = true + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), seedInput, nil) + require.NoError(err) + require.Len(seed.Decisions, 1) + assert.Equal(personfacts.DecisionApplied, seed.Decisions[0].Action) + require.NotEmpty(seed.Projections) + + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + require.Len(evidence, 1) + statusInput := personFactProjectionInput(personID, "sensitive-policy-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidence[0].Key, SourceVersion: evidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + statusInput.Policy.AllowSensitive = false + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), statusInput, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionPolicyRejected, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonSensitivePolicy, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 1) + assert.Nil(claims[0].Failure) +} + +func TestApplyPersonFactStatusOnlyGenerationKeepsUnavailableSensitiveTargetUnsupported(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*testing.T, *Store, AttributeDefinition) + }{ + { + name: "removed", + mutate: func(t *testing.T, st *Store, definition AttributeDefinition) { + t.Helper() + _, err := st.db.Exec(`DELETE FROM person_attribute_values WHERE definition_id = ?`, + definition.ID) + require.NoError(t, err) + _, err = st.db.Exec(`DELETE FROM attribute_definitions WHERE id = ?`, definition.ID) + require.NoError(t, err) + }, + }, + { + name: "inactive", + mutate: func(t *testing.T, st *Store, definition AttributeDefinition) { + t.Helper() + _, err := st.db.Exec(`UPDATE attribute_definitions SET is_active = FALSE WHERE id = ?`, + definition.ID) + require.NoError(t, err) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + target := projectionTargetBySlug(t, st, AttributeSlugReligion) + seedInput := personFactProjectionInput(personID, "sensitive-unavailable-seed-"+test.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"Buddhist"`, + "sensitive-unavailable-seed-"+test.name), + }, nil) + seedInput.Policy.AllowSensitive = true + seed, err := st.ApplyPersonFactGenerationContext(t.Context(), seedInput, nil) + require.NoError(err) + require.Len(seed.Decisions, 1) + assert.Equal(personfacts.DecisionApplied, seed.Decisions[0].Action) + + definition, err := st.GetAttributeDefinitionBySlugContext( + t.Context(), AttributeObjectPerson, AttributeSlugReligion) + require.NoError(err) + test.mutate(t, st, *definition) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + require.Len(evidence, 1) + statusInput := personFactProjectionInput(personID, "sensitive-unavailable-status-"+test.name, + nil, []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidence[0].Key, SourceVersion: evidence[0].Input.SourceVersion, + Supported: false, Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + statusInput.Policy.AllowSensitive = false + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), statusInput, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionInvalid, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonUnsupportedTarget, result.Decisions[0].Reason) + assert.Empty(result.Projections) + }) + } +} + +func TestApplyPersonFactGenerationPersistsRemovedOrIneligibleTargetBesideValidSibling(t *testing.T) { + for _, test := range []struct { + name string + mutate func(*testing.T, *Store, personfacts.TargetDescriptor) + }{ + { + name: "removed", + mutate: func(t *testing.T, st *Store, target personfacts.TargetDescriptor) { + t.Helper() + require.NoError(t, func() error { + _, err := st.db.Exec(`DELETE FROM attribute_definitions WHERE universal_id = ?`, target.Key) + return err + }()) + }, + }, + { + name: "ineligible", + mutate: func(t *testing.T, st *Store, target personfacts.TargetDescriptor) { + t.Helper() + require.NoError(t, func() error { + _, err := st.db.Exec(`UPDATE attribute_definitions SET api_mutable = FALSE WHERE universal_id = ?`, target.Key) + return err + }()) + }, + }, + { + name: "unsupported value type", + mutate: func(t *testing.T, st *Store, target personfacts.TargetDescriptor) { + t.Helper() + require.NoError(t, func() error { + _, err := st.db.Exec(`UPDATE attribute_definitions SET value_type = 'json' WHERE universal_id = ?`, target.Key) + return err + }()) + }, + }, + } { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + unsupported := targets[AttributeSlugAskMeAbout] + valid := targets[AttributeSlugPrimaryChannel] + test.mutate(t, st, unsupported) + input := personFactProjectionInput(personID, "catalog-target-"+test.name, + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, unsupported, `"sailing"`, "unsupported-catalog-target"), + personFactProjectionClaim(personID, valid, `"chat"`, "valid-catalog-sibling"), + }, nil) + + first, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(first.Decisions, 2) + require.Len(first.Projections, 1) + assert.Equal("person_attribute", first.Projections[0].Kind) + unsupportedDecisions := 0 + for _, decision := range first.Decisions { + if decision.Reason != personfacts.ReasonUnsupportedTarget { + continue + } + unsupportedDecisions++ + assert.Equal(personfacts.DecisionInvalid, decision.Action) + assert.Nil(decision.Projection) + } + assert.Equal(1, unsupportedDecisions) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + for _, claim := range claims { + if claim.Target.Key == unsupported.Key { + require.NotNil(claim.Failure) + assert.Equal(personfacts.ReasonUnsupportedTarget, claim.Failure.Reason) + assert.Nil(claim.Normalized) + } + } + + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.Equal(first.GenerationID, replayed.GenerationID) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_attribute_values")) + }) + } +} + +func TestApplyPersonFactGenerationPersistsInvalidEnvelopeClaimsAlongsideValidSibling(t *testing.T) { + tests := []struct { + name string + rawToken string + mutate func(*personfacts.ProposedClaim) + check func(*testing.T, personfacts.Claim) + }{ + { + name: "invalid relation", rawToken: "agrees", + mutate: func(claim *personfacts.ProposedClaim) { + claim.Relation = personfacts.ClaimRelation("agrees") + }, + check: func(t *testing.T, claim personfacts.Claim) { + t.Helper() + assert.Equal(t, personfacts.RelationInvalid, claim.Relation) + assert.Equal(t, personfacts.OriginExtraction, claim.Origin) + }, + }, + { + name: "invalid origin", rawToken: "crawler", + mutate: func(claim *personfacts.ProposedClaim) { + claim.Origin = personfacts.ClaimOrigin("crawler") + }, + check: func(t *testing.T, claim personfacts.Claim) { + t.Helper() + assert.Equal(t, personfacts.RelationSupport, claim.Relation) + assert.Equal(t, personfacts.OriginInvalid, claim.Origin) + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + valid := personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, test.name+"-valid") + invalid := personFactProjectionClaim( + personID, targets[AttributeSlugAskMeAbout], `"sailing"`, test.name+"-invalid") + test.mutate(&invalid) + input := personFactProjectionInput( + personID, test.name, []personfacts.ProposedClaim{invalid, valid}, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 2) + require.Len(result.Projections, 1) + assert.Equal("person_attribute", result.Projections[0].Kind) + invalidDecisions := 0 + for _, decision := range result.Decisions { + if decision.Action == personfacts.DecisionInvalid { + invalidDecisions++ + assert.Equal(personfacts.ReasonMalformedValue, decision.Reason) + assert.Nil(decision.Projection) + } + } + assert.Equal(1, invalidDecisions) + + claims, err := st.ListPersonFactClaimsContext( + t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + foundInvalid := false + for _, claim := range claims { + if claim.Target.Key != targets[AttributeSlugAskMeAbout].Key { + continue + } + foundInvalid = true + test.check(t, claim) + assert.Nil(claim.Normalized) + require.NotNil(claim.Failure) + assert.Equal(personfacts.DecisionInvalid, claim.Failure.Action) + assert.Equal(personfacts.ReasonMalformedValue, claim.Failure.Reason) + assert.Contains(claim.Failure.Detail, test.rawToken) + } + assert.True(foundInvalid) + + var reloaded []personfacts.ResolvedClaim + require.NoError(st.withTxContext(t.Context(), func(tx *loggedTx) error { + var loadErr error + reloaded, _, loadErr = st.loadPersonFactResolvedClaimsTx( + t.Context(), tx, personID, targets[AttributeSlugAskMeAbout], nil) + return loadErr + })) + require.Len(reloaded, 1) + require.NotNil(reloaded[0].Failure) + assert.Contains(reloaded[0].Failure.Detail, test.rawToken) + + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("chat", *values[0].Value.Text) + }) + } +} + +func TestApplyPersonFactGenerationDoesNotRetireUnownedDerivedAttribute(t *testing.T) { + for _, source := range []Provenance{ + ProvenanceExtraction, + ProvenanceEnrichment, + ProvenanceSystem, + } { + t.Run(string(source), func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + activeFrom := personFactLedgerNow.Add(-24 * time.Hour) + confidence := 0.8 + unowned, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("chat")}, + ActiveFrom: &activeFrom, Source: source, Confidence: &confidence, + }) + require.NoError(err) + + input := personFactProjectionInput(personID, "unowned-"+string(source), + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], + `42`, "unowned-"+string(source)), + }, nil) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionInvalid, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonMalformedValue, result.Decisions[0].Reason) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + assert.Equal(unowned.Value.ID, current[0].ID) + assert.Equal("chat", *current[0].Value.Text) + }) + } +} + +func TestApplyPersonFactGenerationProtectsUnownedDerivedAttributeFromProjectionPlans(t *testing.T) { + for _, source := range []Provenance{ + ProvenanceExtraction, + ProvenanceEnrichment, + ProvenanceSystem, + } { + for _, operation := range []struct { + name string + value string + relation personfacts.ClaimRelation + }{ + {name: "replacement", value: `"email"`, relation: personfacts.RelationSupport}, + {name: "explicit retirement", value: `"chat"`, relation: personfacts.RelationSupersede}, + } { + t.Run(string(source)+"/"+operation.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + activeFrom := personFactLedgerNow.Add(-24 * time.Hour) + confidence := 0.8 + unowned, err := st.SetPersonAttributeValueContext(t.Context(), PersonAttributeValueInput{ + PersonID: personID, DefinitionSlug: AttributeSlugPrimaryChannel, + Value: AttributeValue{Type: AttributeValueText, Text: new("chat")}, + ActiveFrom: &activeFrom, Source: source, Confidence: &confidence, + }) + require.NoError(err) + + claim := personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], + operation.value, "unowned-plan-"+string(source)+"-"+operation.name) + claim.Relation = operation.relation + result, err := st.ApplyPersonFactGenerationContext(t.Context(), + personFactProjectionInput(personID, "unowned-plan-"+string(source)+"-"+operation.name, + []personfacts.ProposedClaim{claim}, nil), nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonPinRetained, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + assert.Equal(unowned.Value.ID, current[0].ID) + assert.Equal("chat", *current[0].Value.Text) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "person_fact_pin_events")) + }) + } + } +} + +func TestPersonFactProjectionOwnershipUsesScopedCompositeIndex(t *testing.T) { + st, personID, _ := newPersonFactProjectionStore(t) + if st.IsPostgreSQL() { + t.Skip("SQLite query-plan coverage; PostgreSQL schema uses the same composite key order") + } + plan := explainPlan(t, st, personFactAttributeProjectionOwnershipSQL, + personID, "person_attribute", int64(1)) + t.Logf("query plan:\n%s", plan) + assert.Contains(t, plan, + "USING COVERING INDEX idx_person_fact_decisions_projection", + "ownership lookup must be bounded by person and projection identity:\n%s", plan) +} + +func TestApplyPersonFactGenerationPersistsOverLimitTextClaimAlongsideValidSibling(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + overLimit := strings.Repeat("🙂", 121) + submitted, err := json.Marshal(overLimit) + require.NoError(err) + input := personFactProjectionInput(personID, "over-limit-text", + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], + string(submitted), "over-limit-text"), + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], + `"chat"`, "over-limit-valid-sibling"), + }, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 2) + require.Len(result.Projections, 1) + assert.Equal("person_attribute", result.Projections[0].Kind) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + for _, claim := range claims { + if claim.Target.Key != targets[AttributeSlugAskMeAbout].Key { + continue + } + assert.Nil(claim.Normalized) + require.NotNil(claim.Failure) + assert.Equal(personfacts.DecisionInvalid, claim.Failure.Action) + assert.Equal(personfacts.ReasonMalformedValue, claim.Failure.Reason) + assert.Contains(claim.Failure.Detail, "max_length 120") + } + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("chat", *values[0].Value.Text) +} + +func TestApplyPersonFactGenerationPersistsTamperedTargetDescriptorAlongsideValidSibling(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + tampered := targets[AttributeSlugAskMeAbout] + tampered.MaxLength = 0 + overLimit := strings.Repeat("🙂", 121) + submitted, err := json.Marshal(overLimit) + require.NoError(err) + input := personFactProjectionInput(personID, "tampered-target-descriptor", + []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, tampered, + string(submitted), "tampered-target-descriptor"), + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], + `"chat"`, "tampered-target-valid-sibling"), + }, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 2) + require.Len(result.Projections, 1) + assert.Equal("person_attribute", result.Projections[0].Kind) + + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + require.Len(claims, 2) + foundTampered := false + for _, claim := range claims { + if claim.Target.Key != tampered.Key { + continue + } + foundTampered = true + assert.Nil(claim.Normalized) + require.NotNil(claim.Failure) + assert.Equal(personfacts.DecisionInvalid, claim.Failure.Action) + assert.Equal(personfacts.ReasonStaleTargetRevision, claim.Failure.Reason) + } + assert.True(foundTampered) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("chat", *values[0].Value.Text) +} + +func TestApplyPersonFactGenerationPersistsOutsideValidityDecision(t *testing.T) { + tests := []struct { + name string + mutate func(*personfacts.ProposedClaim) + }{ + { + name: "future", + mutate: func(claim *personfacts.ProposedClaim) { + validFrom := personFactLedgerNow.Add(time.Hour) + claim.ValidFrom = &validFrom + }, + }, + { + name: "expired", + mutate: func(claim *personfacts.ProposedClaim) { + validUntil := personFactLedgerNow.Add(-time.Hour) + claim.ValidUntil = &validUntil + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + claim := personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "validity-"+test.name) + test.mutate(&claim) + input := personFactProjectionInput( + personID, "validity-"+test.name, []personfacts.ProposedClaim{claim}, nil) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + require.Len(result.Decisions, 1) + assert.Equal(personfacts.DecisionRetained, result.Decisions[0].Action) + assert.Equal(personfacts.ReasonOutsideValidity, result.Decisions[0].Reason) + assert.Empty(result.Projections) + + decisions, err := st.ListPersonFactDecisionsContext( + t.Context(), personID, personfacts.DecisionFilter{}) + require.NoError(err) + require.Len(decisions, 1) + assert.Equal(personfacts.ReasonOutsideValidity, decisions[0].Reason) + }) + } +} + +func TestApplyPersonFactGenerationExpiresProjectionAtExclusiveValidityEndWhenProcessedLate(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + validUntil := personFactLedgerNow.Add(time.Hour) + firstClaim := personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "expiry-first") + firstClaim.ValidUntil = &validUntil + firstInput := personFactProjectionInput( + personID, "expiry-first", []personfacts.ProposedClaim{firstClaim}, nil) + firstInput.ResolvedAt = personFactLedgerNow + + first, err := st.ApplyPersonFactGenerationContext(t.Context(), firstInput, nil) + require.NoError(err) + require.Len(first.Projections, 1) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + projectedID := values[0].ID + + boundaryClaim := personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "expiry-boundary") + boundaryClaim.ValidUntil = &validUntil + boundaryInput := personFactProjectionInput( + personID, "expiry-boundary", []personfacts.ProposedClaim{boundaryClaim}, nil) + boundaryInput.ResolvedAt = validUntil.Add(24 * time.Hour) + boundary, err := st.ApplyPersonFactGenerationContext(t.Context(), boundaryInput, nil) + require.NoError(err) + require.Len(boundary.Projections, 1) + assert.Equal(first.Projections[0], boundary.Projections[0]) + linkedDecisions := 0 + for _, decision := range boundary.Decisions { + assert.Equal(personfacts.ReasonOutsideValidity, decision.Reason) + if decision.Projection != nil { + linkedDecisions++ + assert.Equal(first.Projections[0], *decision.Projection) + } + } + assert.Equal(1, linkedDecisions) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(current) + history, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel, IncludeHistory: true}) + require.NoError(err) + require.Len(history, 1) + assert.Equal(projectedID, history[0].ID) + require.NotNil(history[0].ActiveUntil) + assert.Equal(validUntil, *history[0].ActiveUntil) +} + +func TestApplyPersonFactGenerationDoesNotMoveTargetResolutionBackward(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + newerResolvedAt := personFactLedgerNow.Add(2 * time.Hour) + newerClaim := personFactProjectionClaim(personID, target, `"email"`, "newer-first") + newerClaim.ValidFrom = &newerResolvedAt + newerClaim.Confidence.ReportedScore = 1000 + newer := personFactProjectionInput( + personID, "newer-first", []personfacts.ProposedClaim{newerClaim}, nil) + newer.ResolvedAt = newerResolvedAt + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), newer, nil) + require.NoError(err) + olderClaim := personFactProjectionClaim(personID, target, `"chat"`, "older-second") + olderClaim.Confidence.ReportedScore = 700 + older := personFactProjectionInput( + personID, "older-second", []personfacts.ProposedClaim{olderClaim}, nil) + older.ResolvedAt = personFactLedgerNow + result, err := st.ApplyPersonFactGenerationContext(t.Context(), older, nil) + require.NoError(err) + require.Len(result.Resolutions, 1) + assert.Equal(newerResolvedAt, result.Resolutions[0].ResolvedAt) + + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(values, 1) + assert.Equal("email", *values[0].Value.Text) +} + +func TestApplyPersonFactGenerationClampsBackdatedAttributeReplacementToCurrentStart(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + initialClaim := personFactProjectionClaim(personID, target, `"chat"`, "backdated-replacement-initial") + initialClaim.Confidence.ReportedScore = 0 + initialInput := personFactProjectionInput( + personID, "backdated-replacement-initial", []personfacts.ProposedClaim{initialClaim}, nil) + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), initialInput, nil) + require.NoError(err) + initial, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(initial, 1) + currentStart := initial[0].ActiveFrom + + validFrom := currentStart.Add(-24 * time.Hour) + correction := personFactProjectionClaim(personID, target, `"email"`, "backdated-replacement-correction") + correction.Confidence.ReportedScore = 1000 + correction.ValidFrom = &validFrom + correctionInput := personFactProjectionInput( + personID, "backdated-replacement-correction", []personfacts.ProposedClaim{correction}, nil) + correctionInput.ResolvedAt = currentStart.Add(time.Hour) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), correctionInput, nil) + require.NoError(err) + assert.NotZero(result.GenerationID) + assert.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) + assert.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_claims")) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(current, 1) + assert.Equal("email", *current[0].Value.Text) + assert.Equal(currentStart, current[0].ActiveFrom) + history, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel, IncludeHistory: true}) + require.NoError(err) + require.Len(history, 2) + require.NotNil(history[1].ActiveUntil) + assert.Equal(currentStart, *history[1].ActiveUntil) +} + +func TestApplyPersonFactGenerationClampsBackdatedAttributeRetirementToCurrentStart(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + initialClaim := personFactProjectionClaim(personID, target, `"chat"`, "backdated-retirement-initial") + initialClaim.Confidence.ReportedScore = 0 + initialInput := personFactProjectionInput( + personID, "backdated-retirement-initial", []personfacts.ProposedClaim{initialClaim}, nil) + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), initialInput, nil) + require.NoError(err) + initial, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + require.Len(initial, 1) + currentStart := initial[0].ActiveFrom + + validFrom := currentStart.Add(-24 * time.Hour) + correction := personFactProjectionClaim(personID, target, `"chat"`, "backdated-retirement-correction") + correction.Relation = personfacts.RelationSupersede + correction.Confidence.ReportedScore = 1000 + correction.ValidFrom = &validFrom + correctionInput := personFactProjectionInput( + personID, "backdated-retirement-correction", []personfacts.ProposedClaim{correction}, nil) + correctionInput.ResolvedAt = currentStart.Add(time.Hour) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), correctionInput, nil) + require.NoError(err) + assert.NotZero(result.GenerationID) + assert.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_generations")) + assert.Equal(int64(2), personFactProjectionRowCount(t, st, "person_fact_claims")) + + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(current) + history, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel, IncludeHistory: true}) + require.NoError(err) + require.Len(history, 1) + require.NotNil(history[0].ActiveUntil) + assert.Equal(currentStart, *history[0].ActiveUntil) +} + +func TestApplyPersonFactGenerationRejectsUntrackedPersonWithoutWrites(t *testing.T) { + st, personID := newPersonFactLedgerStore(t) + target := projectionTargetBySlug(t, st, AttributeSlugPrimaryChannel) + input := personFactProjectionInput(personID, "untracked", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "untracked"), + }, nil) + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.ErrorIs(t, err, ErrPersonFactPersonNotTracked) + assert.Equal(t, int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) + assert.Equal(t, int64(0), personFactProjectionRowCount(t, st, "person_fact_claims")) +} + +func TestPersonTrackingUntrackWaitsForInFlightFactGeneration(t *testing.T) { + type generationOutcome struct { + result *personfacts.GenerationResult + err error + } + type trackingOutcome struct { + state *PersonTracking + err error + } + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "tracking-race", []personfacts.ProposedClaim{ + personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "tracking-race"), + }, nil) + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + + maintenanceReady := make(chan struct{}) + releaseMaintenance := make(chan struct{}) + released := false + release := func() { + if !released { + close(releaseMaintenance) + released = true + } + } + defer release() + runner := personFactTxRunner(func(ctx context.Context, fn func(*loggedTx) error) error { + return st.withTxContext(ctx, func(tx *loggedTx) error { + if err := fn(tx); err != nil { + return err + } + close(maintenanceReady) + select { + case <-releaseMaintenance: + return nil + case <-ctx.Done(): + return ctx.Err() + } + }) + }) + generationDone := make(chan generationOutcome, 1) + go func() { + result, err := st.applyPersonFactGenerationContext(ctx, input, nil, runner) + generationDone <- generationOutcome{result: result, err: err} + }() + select { + case <-maintenanceReady: + case <-ctx.Done(): + require.FailNow("generation maintenance did not reach the commit gate", ctx.Err()) + } + + untrackDone := make(chan trackingOutcome, 1) + go func() { + state, err := st.SetPersonTrackingContext(ctx, personID, false) + untrackDone <- trackingOutcome{state: state, err: err} + }() + var early *trackingOutcome + select { + case outcome := <-untrackDone: + early = &outcome + case <-time.After(150 * time.Millisecond): + } + release() + + var generation generationOutcome + select { + case generation = <-generationDone: + case <-ctx.Done(): + require.FailNow("in-flight generation did not finish", ctx.Err()) + } + require.NoError(generation.err) + require.NotNil(generation.result) + require.Len(generation.result.Projections, 1) + + var untracked trackingOutcome + if early != nil { + untracked = *early + } else { + select { + case untracked = <-untrackDone: + case <-ctx.Done(): + require.FailNow("untracking did not finish after generation commit", ctx.Err()) + } + } + assert.Nil(early, "untracking returned while generation maintenance was still in flight") + require.NoError(untracked.err) + require.NotNil(untracked.state) + assert.False(untracked.state.Tracked) + + next := personFactProjectionInput(personID, "tracking-race-after", []personfacts.ProposedClaim{ + personFactProjectionClaim( + personID, targets[AttributeSlugPrimaryChannel], `"email"`, "tracking-race-after"), + }, nil) + _, err := st.ApplyPersonFactGenerationContext(ctx, next, nil) + require.ErrorIs(err, ErrPersonFactPersonNotTracked) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_generations")) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_attribute_values")) +} + +func TestApplyPersonFactPreparationCompletesBeforeTransactionBegin(t *testing.T) { + st, personID, targets := newPersonFactProjectionStore(t) + transactionStarted := false + alignments := 0 + aligner := projectionAlignerFunc(func(_ context.Context, evidence personfacts.EvidenceInput) (personfacts.AlignmentResult, error) { + assert.False(t, transactionStarted) + alignments++ + return personfacts.AlignmentResult{ + Accepted: true, SourceVersion: evidence.SourceVersion, + ContentSHA256: strings.Repeat("b", 64), + }, nil + }) + claim := personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "alignment") + claim.Evidence[0].SourceClass = personfacts.EvidenceArchive + claim.Evidence[0].SourceURL = "" + claim.Evidence[0].SourceRef = "message:synthetic" + claim.Evidence[0].ContentSHA256 = strings.Repeat("a", 64) + spanStart, spanEnd := int64(0), int64(len(claim.Evidence[0].Excerpt)) + claim.Evidence[0].SpanStart, claim.Evidence[0].SpanEnd = &spanStart, &spanEnd + input := personFactProjectionInput(personID, "alignment", []personfacts.ProposedClaim{claim}, nil) + runnerCalls := 0 + runner := personFactTxRunner(func(ctx context.Context, fn func(*loggedTx) error) error { + transactionStarted = true + runnerCalls++ + return st.withTxContext(ctx, fn) + }) + + _, err := st.applyPersonFactGenerationContext(t.Context(), input, aligner, runner) + require.NoError(t, err) + assert.Equal(t, 1, alignments) + assert.Equal(t, 1, runnerCalls) +} + +func TestApplyPersonFactOperationalAlignmentFailureWritesNothing(t *testing.T) { + st, personID, targets := newPersonFactProjectionStore(t) + claim := personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "alignment-error") + claim.Evidence[0].SourceClass = personfacts.EvidenceArchive + claim.Evidence[0].SourceURL = "" + claim.Evidence[0].SourceRef = "message:synthetic-error" + claim.Evidence[0].ContentSHA256 = strings.Repeat("a", 64) + spanStart, spanEnd := int64(0), int64(len(claim.Evidence[0].Excerpt)) + claim.Evidence[0].SpanStart, claim.Evidence[0].SpanEnd = &spanStart, &spanEnd + input := personFactProjectionInput(personID, "alignment-error", []personfacts.ProposedClaim{claim}, nil) + runnerCalled := false + + _, err := st.applyPersonFactGenerationContext(t.Context(), input, + projectionAlignerFunc(func(context.Context, personfacts.EvidenceInput) (personfacts.AlignmentResult, error) { + return personfacts.AlignmentResult{}, errors.New("injected alignment failure") + }), func(context.Context, func(*loggedTx) error) error { + runnerCalled = true + return nil + }) + require.ErrorContains(t, err, "injected alignment failure") + assert.False(t, runnerCalled) + assert.Equal(t, int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestApplyPreparedPersonFactGenerationDoesNotEndCallerTransaction(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), + personFactProjectionInput(personID, "caller-tx", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "caller-tx"), + }, nil), nil) + require.NoError(err) + injected := errors.New("owner rolls back") + + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + _, applyErr := st.applyPreparedPersonFactGenerationTx(t.Context(), tx, prepared) + require.NoError(applyErr) + _, sentinelErr := tx.ExecContext(t.Context(), + `UPDATE persons SET display_name = ? WHERE id = ?`, "sentinel", personID) + require.NoError(sentinelErr) + return injected + }) + require.ErrorIs(err, injected) + person, err := st.GetPersonContext(t.Context(), personID) + require.NoError(err) + assert.Nil(person.DisplayName) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestApplyPreparedPersonFactGenerationIgnoresCallerMutation(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "immutable", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "immutable"), + }, nil) + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + wantCanonical := prepared.CanonicalJSON() + wantKey := prepared.GenerationKey() + + input.ProgramID = "mutated-original" + input.Claims[0].SubmittedValue[1] = 'X' + canonical := prepared.CanonicalJSON() + canonical[0] = 'X' + copyInput := prepared.Input() + copyInput.ProgramID = "mutated-accessor" + claims := prepared.Claims() + claims[0].SubmittedValue[1] = 'Y' + statuses := prepared.EvidenceStatusChanges() + statuses = append(statuses, personfacts.PreparedEvidenceStatusChange{}) + assert.Len(statuses, 1) + + var got *personfacts.GenerationResult + err = st.withTxContext(t.Context(), func(tx *loggedTx) error { + got, err = st.applyPreparedPersonFactGenerationTx(t.Context(), tx, prepared) + return err + }) + require.NoError(err) + assert.Equal(wantKey, got.GenerationKey) + assert.Equal(wantCanonical, prepared.CanonicalJSON()) + stored := persistLoadedPersonFactGeneration(t, st, personID, wantKey) + assert.Equal("fact-projection-fixture", stored.Generation.ProgramID) +} + +func TestApplyPreparedPersonFactGenerationRejectsZeroValue(t *testing.T) { + st, _, _ := newPersonFactProjectionStore(t) + err := st.withTxContext(t.Context(), func(tx *loggedTx) error { + _, applyErr := st.applyPreparedPersonFactGenerationTx(t.Context(), tx, personfacts.PreparedGeneration{}) + return applyErr + }) + require.Error(t, err) + assert.Equal(t, int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestApplyPersonFactReplayHydratesByteIdenticalResolutionResults(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "replay", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `"sailing"`, "z-last"), + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "a-first"), + }, nil) + + fresh, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + beforeCounts := personFactProjectionCounts(t, st) + beforeGenerationCounts := personFactGenerationRowCounts(t, st, fresh.GenerationID) + beforeRevision := personFactProjectionRevision(t, st, personID) + input.ResolvedAt = input.ResolvedAt.Add(24 * time.Hour) + replayed, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + freshJSON, err := json.Marshal(fresh) + require.NoError(err) + replayJSON, err := json.Marshal(replayed) + require.NoError(err) + assert.True(bytes.Equal(freshJSON, replayJSON), "generation replay bytes differ") + assert.NotNil(fresh.Resolutions) + for _, resolution := range fresh.Resolutions { + assert.NotZero(resolution.ID) + assert.NotNil(resolution.Projections) + } + assert.NotContains(string(freshJSON), "operation") + assert.Equal(beforeCounts, personFactProjectionCounts(t, st)) + assert.Equal(beforeGenerationCounts, personFactGenerationRowCounts(t, st, fresh.GenerationID)) + assert.Equal(beforeRevision, personFactProjectionRevision(t, st, personID)) +} + +func TestApplyPersonFactRechecksEveryTouchedDescriptorInTransaction(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "descriptor-race", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "descriptor-race-a"), + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `"sailing"`, "descriptor-race-b"), + }, nil) + prepared, err := personfacts.PreparePersonFactGeneration(t.Context(), input, nil) + require.NoError(err) + wantClaimKeys := make(map[string]string) + for _, claim := range prepared.Claims() { + claimKey, keyErr := personfacts.ClaimKey(prepared.GenerationKey(), claim) + require.NoError(keyErr) + wantClaimKeys[claim.Target.Key] = claimKey + } + definitions := make([]*AttributeDefinition, 0, 2) + for _, slug := range []string{AttributeSlugPrimaryChannel, AttributeSlugAskMeAbout} { + definition, loadErr := st.GetAttributeDefinitionBySlugContext(t.Context(), AttributeObjectPerson, slug) + require.NoError(loadErr) + definitions = append(definitions, definition) + } + runner := personFactTxRunner(func(ctx context.Context, fn func(*loggedTx) error) error { + for index, definition := range definitions { + update := AttributeDefinitionUpdate{} + if index == 0 { + changed := fmt.Sprintf("Changed inference description %d", index) + changedPtr := &changed + update.Description = &changedPtr + } else { + inactive := false + update.IsActive = &inactive + } + _, updateErr := st.UpdateAttributeDefinitionContext(ctx, definition.ID, definition.Revision, update) + require.NoError(updateErr) + } + return st.withTxContext(ctx, fn) + }) + + result, err := st.applyPersonFactGenerationContext(t.Context(), input, nil, runner) + require.NoError(err) + require.Len(result.Decisions, 2) + reasons := make([]personfacts.DecisionReason, 0, len(result.Decisions)) + for _, decision := range result.Decisions { + assert.Equal(personfacts.DecisionInvalid, decision.Action) + reasons = append(reasons, decision.Reason) + } + assert.ElementsMatch([]personfacts.DecisionReason{ + personfacts.ReasonStaleTargetRevision, personfacts.ReasonUnsupportedTarget, + }, reasons) + assert.Empty(result.Projections) + rows, err := st.db.Query(` + SELECT target_key, claim_key, submitted_value_json, normalized_value_json, value_fingerprint + FROM person_fact_claims ORDER BY target_key`) + require.NoError(err) + defer func() { + require.NoError(rows.Close()) + }() + storedSubmitted := map[string]string{ + targets[AttributeSlugPrimaryChannel].Key: `"chat"`, + targets[AttributeSlugAskMeAbout].Key: `"sailing"`, + } + seen := 0 + for rows.Next() { + var targetKey, claimKey, submitted string + var normalized, fingerprint sql.NullString + require.NoError(rows.Scan(&targetKey, &claimKey, &submitted, &normalized, &fingerprint)) + assert.Equal(wantClaimKeys[targetKey], claimKey) + assert.JSONEq(storedSubmitted[targetKey], submitted) + assert.False(normalized.Valid) + assert.False(fingerprint.Valid) + seen++ + } + require.NoError(rows.Err()) + assert.Equal(2, seen) +} + +func TestApplyPersonFactProgramFingerprintChangesGenerationIdentity(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "program", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "program"), + }, nil) + first, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + input.ProgramFingerprint = strings.Repeat("b", 64) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + assert.NotEqual(first.GenerationKey, second.GenerationKey) + assert.NotEqual(first.GenerationID, second.GenerationID) +} + +func TestApplyPersonFactMultipleTargetsBumpVCardOnce(t *testing.T) { + st, personID, targets := newPersonFactProjectionStore(t) + before := personFactProjectionRevision(t, st, personID) + input := personFactProjectionInput(personID, "one-bump", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "one-bump-a"), + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `"sailing"`, "one-bump-b"), + }, nil) + + _, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(t, err) + after := personFactProjectionRevision(t, st, personID) + assert.Equal(t, before+1, after) +} + +func TestApplyPersonFactProjectionChangesSemanticRenderHash(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + before, err := st.LoadPersonSemanticDocumentContext(t.Context(), personID) + require.NoError(err) + input := personFactProjectionInput(personID, "semantic", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `"sailing"`, "semantic"), + }, nil) + + _, err = st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(err) + after, err := st.LoadPersonSemanticDocumentContext(t.Context(), personID) + require.NoError(err) + assert.NotEqual(before.Revision, after.Revision) +} + +func TestApplyPersonFactProjectionFailureRollsBackAllTargets(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + if st.IsPostgreSQL() { + t.Skip("SQLite trigger supplies a deterministic projection failure; PostgreSQL atomicity is covered by caller-owned rollback") + } + _, err := st.db.Exec(` + CREATE TRIGGER fail_ask_me_about_projection + BEFORE INSERT ON person_attribute_values + WHEN NEW.definition_id = (SELECT id FROM attribute_definitions WHERE slug = 'ask_me_about') + BEGIN SELECT RAISE(ABORT, 'injected projection failure'); END`) + require.NoError(err) + beforeCounts := personFactProjectionCounts(t, st) + beforeRevision := personFactProjectionRevision(t, st, personID) + input := personFactProjectionInput(personID, "rollback", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "rollback-a"), + personFactProjectionClaim(personID, targets[AttributeSlugAskMeAbout], `"sailing"`, "rollback-b"), + }, nil) + + _, err = st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.ErrorContains(err, "injected projection failure") + assert.Equal(beforeCounts, personFactProjectionCounts(t, st)) + assert.Equal(beforeRevision, personFactProjectionRevision(t, st, personID)) + values, listErr := st.ListPersonAttributeValuesContext(t.Context(), personID, PersonAttributeQuery{}) + require.NoError(listErr) + assert.Empty(values) +} + +func TestApplyPersonFactStatusOnlyUnsupportedReresolvesTargets(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, target, first, evidenceKey := seedProjectedPersonFact(t) + beforeRevision := personFactProjectionRevision(t, st, personID) + status := personFactProjectionInput(personID, "status-unsupported", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + require.Len(result.Resolutions, 1) + require.Len(result.Decisions, 1) + assert.Equal(target.Key, result.Resolutions[0].Target.Key) + assert.Equal(personfacts.ReasonEvidenceUnsupported, result.Decisions[0].Reason) + assert.NotEqual(first.Resolutions[0].InputFingerprint, result.Resolutions[0].InputFingerprint) + assert.Equal(beforeRevision+1, personFactProjectionRevision(t, st, personID)) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(values) + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(err) + assert.Len(claims, 1, "status changes must not synthesize contradiction claims") +} + +func TestApplyPersonFactStatusPersistsForRejectedClaimAfterTargetDeletion(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _ := newPersonFactProjectionStore(t) + description := "Disposable rejected fact target" + definition, err := st.CreateAttributeDefinitionContext(t.Context(), AttributeDefinitionInput{ + UniversalID: "test-deleted-rejected-target", ObjectType: AttributeObjectPerson, + Slug: "deleted_rejected_target", Label: "Deleted rejected target", Description: &description, + ValueType: AttributeValueText, FieldType: AttributeFieldText, + Cardinality: AttributeCardinalitySingle, Ownership: AttributeOwnershipUser, + APIMutable: true, IsDeletable: true, + }) + require.NoError(err) + target := projectionTargetBySlug(t, st, definition.Slug) + seed := personFactProjectionInput(personID, "deleted-rejected-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `123`, "deleted-rejected-seed"), + }, nil) + seedResult, err := st.ApplyPersonFactGenerationContext(t.Context(), seed, nil) + require.NoError(err) + require.Len(seedResult.Decisions, 1) + assert.Equal(personfacts.ReasonMalformedValue, seedResult.Decisions[0].Reason) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + require.Len(evidence, 1) + require.NoError(st.DeleteAttributeDefinitionContext( + t.Context(), definition.ID, definition.Revision)) + + status := personFactProjectionInput(personID, "deleted-rejected-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidence[0].Key, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + require.Len(result.Resolutions, 1) + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_evidence_status_events")) +} + +func TestApplyPersonFactStatusUsesGenerationChronology(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _, _, evidenceKey := seedProjectedPersonFact(t) + newerResolvedAt := personFactLedgerNow.Add(2 * time.Hour) + newer := personFactProjectionInput(personID, "status-newer-first", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + newer.ResolvedAt = newerResolvedAt + _, err := st.ApplyPersonFactGenerationContext(t.Context(), newer, nil) + require.NoError(err) + older := personFactProjectionInput(personID, "status-older-second", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: true, + Reason: personfacts.EvidenceStatusSourceReimported, + }}) + older.ResolvedAt = personFactLedgerNow.Add(time.Hour) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), older, nil) + require.NoError(err) + require.Len(result.Resolutions, 1) + assert.Equal(newerResolvedAt, result.Resolutions[0].ResolvedAt) + + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugPrimaryChannel}) + require.NoError(err) + assert.Empty(values) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + require.Len(evidence, 1) + assert.False(evidence[0].Supported) + require.NotNil(evidence[0].LatestStatus) + assert.Equal(personfacts.EvidenceStatusSourceDeleted, evidence[0].LatestStatus.Reason) +} + +func TestApplyPersonFactStatusOnlySupportedReactivatesTargets(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugAskMeAbout] + seed := personFactProjectionInput(personID, "multi-status-seed", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"sailing"`, "multi-status-sailing"), + personFactProjectionClaim(personID, target, `"ceramics"`, "multi-status-ceramics"), + }, nil) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), seed, nil) + require.NoError(err) + initial, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugAskMeAbout}) + require.NoError(err) + require.Len(initial, 2) + initialByValue := make(map[string]PersonAttributeValue, len(initial)) + for _, value := range initial { + initialByValue[*value.Value.Text] = value + } + require.Contains(initialByValue, "sailing") + require.Contains(initialByValue, "ceramics") + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(err) + var evidenceKey string + for _, item := range evidence { + if strings.HasSuffix(item.Input.SourceURL, "/multi-status-sailing") { + evidenceKey = item.Key + } + } + require.NotEmpty(evidenceKey) + unsupported := personFactProjectionInput(personID, "status-off", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusScopeUnlinked, + }}) + _, err = st.ApplyPersonFactGenerationContext(t.Context(), unsupported, nil) + require.NoError(err) + remaining, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugAskMeAbout}) + require.NoError(err) + require.Len(remaining, 1) + assert.Equal("ceramics", *remaining[0].Value.Text) + assert.Equal(initialByValue["ceramics"].ID, remaining[0].ID, + "the matching current fingerprint must retain its row and ordinal") + assert.Equal(initialByValue["ceramics"].Ordinal, remaining[0].Ordinal) + beforeRevision := personFactProjectionRevision(t, st, personID) + supported := personFactProjectionInput(personID, "status-on", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: true, + Reason: personfacts.EvidenceStatusScopeRelinked, + }}) + + result, err := st.ApplyPersonFactGenerationContext(t.Context(), supported, nil) + require.NoError(err) + require.Len(result.Decisions, 2) + require.Len(result.Projections, 1) + for _, decision := range result.Decisions { + assert.Equal(personfacts.DecisionApplied, decision.Action) + } + assert.Equal(beforeRevision+1, personFactProjectionRevision(t, st, personID)) + values, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugAskMeAbout, IncludeHistory: true}) + require.NoError(err) + require.Len(values, 3) + current, err := st.ListPersonAttributeValuesContext(t.Context(), personID, + PersonAttributeQuery{DefinitionSlug: AttributeSlugAskMeAbout}) + require.NoError(err) + require.Len(current, 2) + currentByValue := make(map[string]PersonAttributeValue, len(current)) + for _, value := range current { + currentByValue[*value.Value.Text] = value + } + assert.Equal(initialByValue["ceramics"].ID, currentByValue["ceramics"].ID) + assert.Equal(initialByValue["ceramics"].Ordinal, currentByValue["ceramics"].Ordinal) + assert.Equal(int64(2), currentByValue["sailing"].Ordinal, + "reactivation allocates after the historical maximum instead of reusing ordinal zero") + assert.Equal(currentByValue["sailing"].ID, result.Projections[0].RowID) +} + +func TestApplyPersonFactStatusReplayIsIdempotent(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, _, _, evidenceKey := seedProjectedPersonFact(t) + status := personFactProjectionInput(personID, "status-replay", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceEdited, + }}) + first, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + status.ResolvedAt = status.ResolvedAt.Add(time.Hour) + second, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.NoError(err) + firstJSON, err := json.Marshal(first) + require.NoError(err) + secondJSON, err := json.Marshal(second) + require.NoError(err) + assert.True(bytes.Equal(firstJSON, secondJSON), "status replay bytes differ") + assert.Equal(int64(1), personFactProjectionRowCount(t, st, "person_fact_evidence_status_events")) +} + +func TestApplyPersonFactStatusRejectsUnknownEvidence(t *testing.T) { + st, personID, _ := newPersonFactProjectionStore(t) + status := personFactProjectionInput(personID, "status-unknown", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: "missing-evidence", SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.ErrorContains(t, err, "unknown evidence") + assert.Equal(t, int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestApplyPersonFactStatusRejectsSourceVersionMismatch(t *testing.T) { + st, personID, _, _, evidenceKey := seedProjectedPersonFact(t) + before := personFactProjectionRowCount(t, st, "person_fact_generations") + status := personFactProjectionInput(personID, "status-version", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "wrong-version", Supported: false, + Reason: personfacts.EvidenceStatusSourceDeleted, + }}) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), status, nil) + require.ErrorContains(t, err, "source version") + assert.Equal(t, before, personFactProjectionRowCount(t, st, "person_fact_generations")) +} + +func TestApplyPersonFactClaimsAndStatusesCommitAtomically(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + st, personID, targets := newPersonFactProjectionStore(t) + input := personFactProjectionInput(personID, "claims-status-atomic", []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, targets[AttributeSlugPrimaryChannel], `"chat"`, "atomic"), + }, []personfacts.EvidenceStatusChange{{ + EvidenceKey: "missing-evidence", SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusIdentityReassigned, + }}) + _, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.ErrorContains(err, "unknown evidence") + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "person_fact_generations")) + assert.Equal(int64(0), personFactProjectionRowCount(t, st, "person_fact_claims")) + values, listErr := st.ListPersonAttributeValuesContext(t.Context(), personID, PersonAttributeQuery{}) + require.NoError(listErr) + assert.Empty(values) +} + +type projectionAlignerFunc func(context.Context, personfacts.EvidenceInput) (personfacts.AlignmentResult, error) + +func (f projectionAlignerFunc) Align(ctx context.Context, input personfacts.EvidenceInput) (personfacts.AlignmentResult, error) { + return f(ctx, input) +} + +func newPersonFactProjectionStore(t *testing.T) (*Store, int64, map[string]personfacts.TargetDescriptor) { + t.Helper() + st, personID := newPersonFactLedgerStore(t) + _, err := st.SetPersonTrackingContext(t.Context(), personID, true) + require.NoError(t, err) + return st, personID, map[string]personfacts.TargetDescriptor{ + AttributeSlugPrimaryChannel: projectionTargetBySlug(t, st, AttributeSlugPrimaryChannel), + AttributeSlugAskMeAbout: projectionTargetBySlug(t, st, AttributeSlugAskMeAbout), + } +} + +func projectionTargetBySlug(t *testing.T, st *Store, slug string) personfacts.TargetDescriptor { + t.Helper() + catalog, err := st.BuildPersonFactCatalogContext(t.Context(), true) + require.NoError(t, err) + for _, target := range catalog.Targets { + if target.Slug == slug { + return target + } + } + require.FailNow(t, "target not found", slug) + return personfacts.TargetDescriptor{} +} + +func personFactProjectionInput( + personID int64, suffix string, claims []personfacts.ProposedClaim, + statuses []personfacts.EvidenceStatusChange, +) personfacts.GenerationInput { + return personfacts.GenerationInput{ + PersonID: personID, + SourceCursors: []personfacts.SourceCursor{{Lane: "fixture", Start: suffix, End: suffix + "-end"}}, + ProgramID: "fact-projection-fixture", ProgramVersion: "v1", + ProgramFingerprint: strings.Repeat("a", 64), CatalogFingerprint: "catalog-fixture", + Provider: "fixture", ProviderVersion: "v1", Model: "fixture", ModelVersion: "v1", + ResolvedAt: personFactLedgerNow, + Policy: personfacts.PolicyContext{ProviderPolicyFingerprint: "policy-v1"}, + Claims: claims, EvidenceStatusChanges: statuses, + } +} + +func personFactProjectionClaim( + personID int64, target personfacts.TargetDescriptor, submitted, suffix string, +) personfacts.ProposedClaim { + subject := personID + return personfacts.ProposedClaim{ + Target: target, Relation: personfacts.RelationSupport, + SubmittedValue: json.RawMessage(submitted), + Evidence: []personfacts.EvidenceInput{{ + PersonID: personID, SourceClass: personfacts.EvidencePublic, + Directness: personfacts.DirectSelf, Authority: personfacts.AuthorityAuthoritative, + SourceURL: "https://example.com/" + suffix, SubjectPersonID: &subject, + SubjectRef: "synthetic-person", Excerpt: "Synthetic evidence " + suffix, + SourceVersion: "source-v1", EventTime: personFactLedgerNow.Add(-time.Hour), + RecordedTime: personFactLedgerNow, IdentityScore: 990, + }}, + Origin: personfacts.OriginExtraction, + Confidence: personfacts.ConfidenceInputs{ReportedScore: 900}, + } +} + +func personFactProjectionRowCount(t *testing.T, st *Store, table string) int64 { + t.Helper() + var count int64 + require.NoError(t, st.db.QueryRow("SELECT COUNT(*) FROM "+table).Scan(&count)) + return count +} + +func personFactProjectionCounts(t *testing.T, st *Store) map[string]int64 { + t.Helper() + tables := []string{ + "person_fact_generations", + "person_fact_evidence", + "person_fact_claims", + "person_fact_claim_evidence", + "person_fact_evidence_status_events", + "person_fact_resolutions", + "person_fact_decisions", + "person_fact_pin_events", + "person_attribute_values", + } + counts := make(map[string]int64, len(tables)) + for _, table := range tables { + counts[table] = personFactProjectionRowCount(t, st, table) + } + return counts +} + +func personFactGenerationRowCounts(t *testing.T, st *Store, generationID int64) map[string]int64 { + t.Helper() + queries := map[string]string{ + "generations": `SELECT COUNT(*) FROM person_fact_generations WHERE id = ?`, + "claims": `SELECT COUNT(*) FROM person_fact_claims WHERE generation_id = ?`, + "claim_evidence": `SELECT COUNT(*) FROM person_fact_claim_evidence ce JOIN person_fact_claims c ON c.id = ce.claim_id WHERE c.generation_id = ?`, + "evidence": `SELECT COUNT(DISTINCT ce.evidence_id) FROM person_fact_claim_evidence ce JOIN person_fact_claims c ON c.id = ce.claim_id WHERE c.generation_id = ?`, + "status_events": `SELECT COUNT(*) FROM person_fact_evidence_status_events WHERE generation_id = ?`, + "resolutions": `SELECT COUNT(*) FROM person_fact_resolutions WHERE generation_id = ?`, + "decisions": `SELECT COUNT(*) FROM person_fact_decisions d JOIN person_fact_resolutions r ON r.id = d.resolution_id WHERE r.generation_id = ?`, + } + counts := make(map[string]int64, len(queries)) + for name, query := range queries { + var count int64 + require.NoError(t, st.db.QueryRow(query, generationID).Scan(&count)) + counts[name] = count + } + return counts +} + +func personFactProjectionRevision(t *testing.T, st *Store, personID int64) int64 { + t.Helper() + var revision int64 + require.NoError(t, st.db.QueryRow( + `SELECT vcard_projection_revision FROM persons WHERE id = ?`, personID).Scan(&revision)) + return revision +} + +func seedProjectedPersonFact(t *testing.T) ( + *Store, int64, personfacts.TargetDescriptor, *personfacts.GenerationResult, string, +) { + t.Helper() + st, personID, targets := newPersonFactProjectionStore(t) + target := targets[AttributeSlugPrimaryChannel] + input := personFactProjectionInput(personID, "seed-"+AttributeSlugPrimaryChannel, []personfacts.ProposedClaim{ + personFactProjectionClaim(personID, target, `"chat"`, "seed-"+AttributeSlugPrimaryChannel), + }, nil) + result, err := st.ApplyPersonFactGenerationContext(t.Context(), input, nil) + require.NoError(t, err) + claims, err := st.ListPersonFactClaimsContext(t.Context(), personID, personfacts.ClaimFilter{}) + require.NoError(t, err) + require.Len(t, claims, 1) + evidence, err := st.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{Limit: 10}) + require.NoError(t, err) + require.Len(t, evidence, 1) + return st, personID, target, result, evidence[0].Key +} + +func persistLoadedPersonFactGeneration( + t *testing.T, st *Store, personID int64, generationKey string, +) personFactLedgerGeneration { + t.Helper() + var stored personFactLedgerGeneration + require.NoError(t, st.withTxContext(t.Context(), func(tx *loggedTx) error { + var err error + stored, err = st.loadPersonFactGenerationTx(t.Context(), tx, personID, generationKey) + return err + })) + return stored +} diff --git a/internal/store/person_fact_schema_test.go b/internal/store/person_fact_schema_test.go new file mode 100644 index 000000000..194a860b3 --- /dev/null +++ b/internal/store/person_fact_schema_test.go @@ -0,0 +1,245 @@ +package store_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/store" + "go.kenn.io/msgvault/internal/testutil" +) + +func TestPersonFactSchemaInitializesTwice(t *testing.T) { + require := require.New(t) + + st := testutil.NewTestStore(t) + require.NoError(st.InitSchemaContext(t.Context())) + + for _, table := range []string{ + "person_fact_evidence", "person_fact_generations", "person_fact_claims", + "person_fact_claim_evidence", "person_fact_evidence_status_events", + "person_fact_resolutions", "person_fact_decisions", "person_fact_pin_events", + } { + assertPersonFactTableQueryable(t, st, table) + } +} + +func assertPersonFactTableQueryable(t *testing.T, st *store.Store, table string) { + t.Helper() + assertions := assert.New(t) + requirements := require.New(t) + + rows, err := st.DB().QueryContext(t.Context(), `SELECT * FROM `+table+` WHERE 1 = 0`) + requirements.NoError(err, table) + defer func() { + requirements.NoError(rows.Close(), table) + }() + columns, err := rows.Columns() + requirements.NoError(err, table) + requirements.NoError(rows.Err(), table) + assertions.NotEmpty(columns, table) +} + +func TestPersonFactSchemaHasScopedUniqueKeys(t *testing.T) { + assert := assert.New(t) + + st := testutil.NewTestStore(t) + firstPerson := personFactSchemaPerson(t, st, "first@example.com") + secondPerson := personFactSchemaPerson(t, st, "second@example.com") + + firstGeneration := insertPersonFactSchemaGeneration(t, st, firstPerson, "shared-generation") + secondGeneration := insertPersonFactSchemaGeneration(t, st, secondPerson, "shared-generation") + assert.NotEqual(firstGeneration, secondGeneration) + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_generations + (person_id, generation_key, source_cursors_json, program_id, program_version, + program_fingerprint, catalog_fingerprint, provider, provider_version, + model, model_version, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, '[]', 'program', 'v1', ?, 'catalog', 'provider', 'v1', + 'model', 'v1', 'policy', CURRENT_TIMESTAMP)`), + firstPerson, "shared-generation", strings.Repeat("a", 64)) + assert.Error(err) //nolint:testifylint // Independent uniqueness checks must remain nonfatal. + + firstEvidence := insertPersonFactSchemaEvidence(t, st, firstPerson, "shared-evidence") + secondEvidence := insertPersonFactSchemaEvidence(t, st, secondPerson, "shared-evidence") + assert.NotEqual(firstEvidence, secondEvidence) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_evidence + (person_id, evidence_key, source_class, directness, authority, + source_url, subject_person_id, subject_ref, excerpt, source_version, + event_time, recorded_time, identity_score) + VALUES (?, ?, 'public', 'direct-other', 'ordinary', 'https://example.com/evidence', + ?, 'subject', 'excerpt', 'source-v1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 900)`), + firstPerson, "shared-evidence", firstPerson) + assert.Error(err) //nolint:testifylint // Independent uniqueness checks must remain nonfatal. + + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_evidence_status_events + (person_id, generation_id, evidence_id, evidence_key, source_version, supported, reason) + VALUES (?, ?, ?, 'shared-evidence', 'source-v1', FALSE, 'source-edited')`), + firstPerson, firstGeneration, firstEvidence) + require.NoError(t, err) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_evidence_status_events + (person_id, generation_id, evidence_id, evidence_key, source_version, supported, reason) + VALUES (?, ?, ?, 'shared-evidence', 'source-v1', FALSE, 'source-deleted')`), + firstPerson, firstGeneration, firstEvidence) + assert.Error(err) //nolint:testifylint // Independent uniqueness checks must remain nonfatal. + + firstOtherGeneration := insertPersonFactSchemaGeneration(t, st, firstPerson, "other-generation") + firstClaim := insertPersonFactSchemaClaim(t, st, firstPerson, firstGeneration, "shared-claim") + secondClaim := insertPersonFactSchemaClaim(t, st, secondPerson, secondGeneration, "shared-claim") + assert.NotEqual(firstClaim, secondClaim) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_claims + (person_id, generation_id, claim_key, target_kind, target_key, target_revision, + relation, submitted_value_json, origin, confidence_json) + VALUES (?, ?, 'shared-claim', 'attribute', 'favorite', 'revision-2', + 'support', '"value"', 'extraction', '{}')`), + firstPerson, firstOtherGeneration) + assert.Error(err, //nolint:testifylint // Independent uniqueness checks must remain nonfatal. + "claim keys are scoped to a person, not a generation") + + firstResolution := insertPersonFactSchemaResolution(t, st, firstPerson, firstGeneration) + firstOtherResolution := insertPersonFactSchemaResolution(t, st, firstPerson, firstOtherGeneration) + secondResolution := insertPersonFactSchemaResolution(t, st, secondPerson, secondGeneration) + assert.NotEqual(firstResolution, firstOtherResolution) + assert.NotEqual(firstResolution, secondResolution) + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_resolutions + (person_id, generation_id, target_kind, target_key, target_revision, + resolver_version, input_fingerprint, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, 'attribute', 'favorite', 'revision-2', 'resolver-v1', + 'input-v1', 'policy-v1', CURRENT_TIMESTAMP)`), + firstPerson, firstGeneration) + assert.Error(err, //nolint:testifylint // Independent uniqueness checks must remain nonfatal. + "resolution identity is scoped to a generation") + + firstDecision := insertPersonFactSchemaDecision(t, st, firstPerson, firstResolution, + firstClaim, "shared-decision") + secondDecision := insertPersonFactSchemaDecision(t, st, secondPerson, secondResolution, + secondClaim, "shared-decision") + assert.NotEqual(firstDecision, secondDecision) + firstOtherClaim := insertPersonFactSchemaClaim(t, st, firstPerson, firstOtherGeneration, "other-claim") + _, err = st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_decisions + (person_id, resolution_id, claim_id, decision_key, action, reason, score_json) + VALUES (?, ?, ?, 'shared-decision', 'retained', 'below-threshold', '{}')`), + firstPerson, firstOtherResolution, firstOtherClaim) + assert.Error(err, "decision keys are scoped to a person, not a resolution") +} + +func TestPersonFactClaimSchemaRejectsUnknownRelationAndOriginTokens(t *testing.T) { + st := testutil.NewTestStore(t) + personID := personFactSchemaPerson(t, st, "closed-vocabulary@example.com") + generationID := insertPersonFactSchemaGeneration(t, st, personID, "closed-vocabulary") + tests := []struct { + name string + claimKey string + relation string + origin string + }{ + {name: "relation", claimKey: "unknown-relation", relation: "agrees", origin: "extraction"}, + {name: "origin", claimKey: "unknown-origin", relation: "support", origin: "crawler"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := st.DB().ExecContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_claims + (person_id, generation_id, claim_key, target_kind, target_key, target_revision, + relation, submitted_value_json, origin, confidence_json) + VALUES (?, ?, ?, 'attribute', 'favorite', 'revision-1', ?, '"value"', ?, '{}')`), + personID, generationID, test.claimKey, test.relation, test.origin) + assert.Error(t, err) + }) + } +} + +func personFactSchemaPerson(t *testing.T, st *store.Store, address string) int64 { + t.Helper() + participantID, err := st.EnsureParticipant(address, "Synthetic", "example.com") + require.NoError(t, err) + person, _, err := st.CreatePersonFromParticipant(participantID) + require.NoError(t, err) + return person.ID +} + +func insertPersonFactSchemaGeneration(t *testing.T, st *store.Store, personID int64, key string) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_generations + (person_id, generation_key, source_cursors_json, program_id, program_version, + program_fingerprint, catalog_fingerprint, provider, provider_version, + model, model_version, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, '[]', 'program', 'v1', ?, 'catalog', 'provider', 'v1', + 'model', 'v1', 'policy', CURRENT_TIMESTAMP) + RETURNING id`), personID, key, strings.Repeat("a", 64)).Scan(&id) + require.NoError(t, err) + return id +} + +func insertPersonFactSchemaEvidence(t *testing.T, st *store.Store, personID int64, key string) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_evidence + (person_id, evidence_key, source_class, directness, authority, + source_url, subject_person_id, subject_ref, excerpt, source_version, + event_time, recorded_time, identity_score) + VALUES (?, ?, 'public', 'direct-other', 'ordinary', 'https://example.com/evidence', + ?, 'subject', 'excerpt', 'source-v1', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP, 900) + RETURNING id`), personID, key, personID).Scan(&id) + require.NoError(t, err) + return id +} + +func insertPersonFactSchemaClaim( + t *testing.T, st *store.Store, personID, generationID int64, key string, +) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_claims + (person_id, generation_id, claim_key, target_kind, target_key, target_revision, + relation, submitted_value_json, origin, confidence_json) + VALUES (?, ?, ?, 'attribute', 'favorite', 'revision-1', + 'support', '"value"', 'extraction', '{}') + RETURNING id`), personID, generationID, key).Scan(&id) + require.NoError(t, err) + return id +} + +func insertPersonFactSchemaResolution( + t *testing.T, st *store.Store, personID, generationID int64, +) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_resolutions + (person_id, generation_id, target_kind, target_key, target_revision, + resolver_version, input_fingerprint, provider_policy_fingerprint, resolved_at) + VALUES (?, ?, 'attribute', 'favorite', 'revision-1', 'resolver-v1', + 'input-v1', 'policy-v1', CURRENT_TIMESTAMP) + RETURNING id`), personID, generationID).Scan(&id) + require.NoError(t, err) + return id +} + +func insertPersonFactSchemaDecision( + t *testing.T, + st *store.Store, + personID, resolutionID, claimID int64, + key string, +) int64 { + t.Helper() + var id int64 + err := st.DB().QueryRowContext(t.Context(), st.Rebind(` + INSERT INTO person_fact_decisions + (person_id, resolution_id, claim_id, decision_key, action, reason, score_json) + VALUES (?, ?, ?, ?, 'retained', 'below-threshold', '{}') + RETURNING id`), personID, resolutionID, claimID, key).Scan(&id) + require.NoError(t, err) + return id +} diff --git a/internal/store/person_fact_snapshot_test.go b/internal/store/person_fact_snapshot_test.go new file mode 100644 index 000000000..c514c87a4 --- /dev/null +++ b/internal/store/person_fact_snapshot_test.go @@ -0,0 +1,50 @@ +package store + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "go.kenn.io/msgvault/internal/personfacts" +) + +func TestPersonFactLedgerReadsOneRepeatableSnapshot(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + + reader, personID := newPersonFactLedgerStore(t) + evidenceKey := seedPersonFactEvidence(t, reader, personID, "snapshot") + + writer, err := OpenForTest(reader.dbPath) + require.NoError(err) + t.Cleanup(func() { _ = writer.Close() }) + + var inside []personfacts.Evidence + err = reader.withReadSnapshotContext(t.Context(), func(tx *loggedTx) error { + inside, err = reader.queryPersonFactEvidenceTx(t.Context(), tx, personID, + personfacts.EvidenceFilter{Limit: 50}) + if err != nil { + return err + } + status := preparePersonFactLedgerGeneration(t, personID, "snapshot-status", nil, + []personfacts.EvidenceStatusChange{{ + EvidenceKey: evidenceKey, SourceVersion: "source-v1", Supported: false, + Reason: personfacts.EvidenceStatusSourceEdited, + }}) + persistPersonFactLedgerGeneration(t, writer, status, nil) + return reader.hydratePersonFactEvidenceStatusTx(t.Context(), tx, inside) + }) + require.NoError(err) + require.Len(inside, 1) + assert.True(inside[0].Supported, + "the later status event must not leak into an already-established snapshot") + assert.Nil(inside[0].LatestStatus) + + after, err := reader.ListPersonFactEvidenceContext(t.Context(), personID, + personfacts.EvidenceFilter{}) + require.NoError(err) + require.Len(after, 1) + assert.False(after[0].Supported) + require.NotNil(after[0].LatestStatus) + assert.Equal(personfacts.EvidenceStatusSourceEdited, after[0].LatestStatus.Reason) +} diff --git a/internal/store/person_merge_inspection.go b/internal/store/person_merge_inspection.go index 00fe76550..38e1fe229 100644 --- a/internal/store/person_merge_inspection.go +++ b/internal/store/person_merge_inspection.go @@ -505,7 +505,7 @@ func (s *Store) acceptPersonMergeCandidateValueTx( PersonID: candidate.PersonID, DefinitionSlug: absorbed.DefinitionSlug, Ordinal: &ordinal, Value: absorbed.Value, Source: absorbed.Source, SourceRef: absorbed.SourceRef, Confidence: absorbed.Confidence, Actor: &actor, - }, absorbed.Ordinal, now) + }, absorbed.Ordinal, now, now) if err != nil { return 0, err } diff --git a/internal/store/person_merge_snapshot.go b/internal/store/person_merge_snapshot.go index c16548222..b4349e26c 100644 --- a/internal/store/person_merge_snapshot.go +++ b/internal/store/person_merge_snapshot.go @@ -213,6 +213,37 @@ var personMergeTableRegistry = map[string]personMergeTableSpec{ TableName: personMergeReviewCandidatesTableName, KeyColumn: "id", Snapshot: false, PersonReferences: []personMergeReference{directPersonReference("survivor_person_id")}, }, + // Fact-ledger history remains scoped to the person whose identity produced + // it, so an absorbed person's ledger cascades with that root. Explicit pin + // events are profile state, however, and must survive an exact split. + "person_fact_generations": { + TableName: "person_fact_generations", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_evidence": { + TableName: "person_fact_evidence", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_claims": { + TableName: "person_fact_claims", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_evidence_status_events": { + TableName: "person_fact_evidence_status_events", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_resolutions": { + TableName: "person_fact_resolutions", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_decisions": { + TableName: "person_fact_decisions", KeyColumn: "id", Snapshot: false, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, + "person_fact_pin_events": { + TableName: "person_fact_pin_events", KeyColumn: "id", Snapshot: true, + PersonReferences: []personMergeReference{directPersonReference("person_id")}, + }, "person_names": { TableName: "person_names", KeyColumn: "id", Snapshot: true, PersonReferences: []personMergeReference{directPersonReference("person_id")}, diff --git a/internal/store/person_merges.go b/internal/store/person_merges.go index e2e2e6da1..07341565e 100644 --- a/internal/store/person_merges.go +++ b/internal/store/person_merges.go @@ -429,6 +429,11 @@ func (s *Store) mergePersonsOnce( ); err != nil { return err } + if err := s.markAbsorbedPersonFactPinEventsDeletedTx( + ctx, tx, mergeID, absorbed.ID, + ); err != nil { + return err + } if _, err := tx.ExecContext(ctx, `DELETE FROM persons WHERE id = ?`, absorbed.ID); err != nil { return fmt.Errorf("delete absorbed person: %w", err) } @@ -472,6 +477,25 @@ func (s *Store) mergePersonsOnce( return result, nil } +func (s *Store) markAbsorbedPersonFactPinEventsDeletedTx( + ctx context.Context, tx *loggedTx, mergeID, absorbedID int64, +) error { + eventIDs, err := personMergeRowIDsTx(ctx, tx, `SELECT id + FROM person_fact_pin_events WHERE person_id = ? ORDER BY id`, absorbedID) + if err != nil { + return fmt.Errorf("load absorbed person fact pin events: %w", err) + } + for _, eventID := range eventIDs { + if err := s.setPersonMergeRowDispositionTx( + ctx, tx, mergeID, "person_fact_pin_events", eventID, + "deleted_snapshot", nil, + ); err != nil { + return err + } + } + return nil +} + func personMergeRequestHash(request PersonMergeRequest) (string, error) { canonical, err := json.Marshal(struct { SurvivorID int64 `json:"survivor_id"` diff --git a/internal/store/person_notes.go b/internal/store/person_notes.go index 659f0998b..00fa8ca59 100644 --- a/internal/store/person_notes.go +++ b/internal/store/person_notes.go @@ -6,6 +6,8 @@ import ( "fmt" "strings" "time" + + "go.kenn.io/msgvault/internal/personfacts" ) // PersonNoteAppendInput appends one fragment to a person's standard Notes value. @@ -38,6 +40,15 @@ func (s *Store) appendPersonNoteOnce( ) (*PersonAttributeWrite, error) { write := &PersonAttributeWrite{DryRun: input.DryRun} err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-generation", input.PersonID); err != nil { + return err + } + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-target", input.PersonID, + personfacts.TargetAttribute, AttributeUniversalIDNotes); err != nil { + return err + } definition, err := s.getAttributeDefinitionByUniversalIDTx( ctx, tx, AttributeUniversalIDNotes, ) @@ -110,12 +121,19 @@ func (s *Store) appendPersonNoteOnce( PersonID: input.PersonID, DefinitionSlug: definition.Slug, Ordinal: &ordinal, Value: value, Source: input.Source, SourceRef: input.SourceRef, Confidence: input.Confidence, Actor: input.Actor, - }, ordinal, activeFrom, + }, ordinal, activeFrom, now, ) if err != nil { return err } write.Value = inserted + if input.Source.IsDeclared() { + if err := s.appendManualPersonFactAttributePinTx( + ctx, tx, *definition, input.PersonID, + personAttributeActor(input.Actor, input.Source)); err != nil { + return err + } + } if err := s.bumpPersonVCardProjectionsTx(ctx, tx, input.PersonID); err != nil { return err } diff --git a/internal/store/person_splits.go b/internal/store/person_splits.go index 3b0855a08..c0c53b56f 100644 --- a/internal/store/person_splits.go +++ b/internal/store/person_splits.go @@ -741,7 +741,20 @@ func (s *Store) restorePersonSplitRowsTx( // Recreate deleted and removed deduplicated parent rows first. Existing dependent // rows may need their merge-time foreign-key remaps reversed afterward. slices.SortStableFunc(move, func(left, right personSplitJournalRow) int { - return personSplitRestorePriority(left.tableName) - personSplitRestorePriority(right.tableName) + priority := personSplitRestorePriority(left.tableName) - + personSplitRestorePriority(right.tableName) + if priority != 0 || left.tableName != "person_fact_pin_events" || + right.tableName != "person_fact_pin_events" || + !left.originalRowID.Valid || !right.originalRowID.Valid { + return priority + } + if left.originalRowID.Int64 < right.originalRowID.Int64 { + return -1 + } + if left.originalRowID.Int64 > right.originalRowID.Int64 { + return 1 + } + return 0 }) for _, row := range move { if row.provenance == personMergeProvenanceDerived || row.action == "recomputed" { @@ -1667,6 +1680,9 @@ func (s *Store) insertPersonSplitSnapshotRowTx( columns := make([]string, 0, len(row.Columns)) args := make([]any, 0, len(row.Columns)) for _, column := range row.Columns { + if row.TableName == "person_fact_pin_events" && column.Name == "id" { + continue + } if personSplitRevisionedTable(row.TableName) && column.Name == "updated_at" { continue } @@ -1701,7 +1717,7 @@ func (s *Store) insertPersonSplitSnapshotRowTx( } table := personSplitIdentifier(row.TableName) insert := `INSERT INTO ` + table - if s.IsPostgreSQL() { + if s.IsPostgreSQL() && row.TableName != "person_fact_pin_events" { insert += ` (` + strings.Join(columns, ", ") + `) OVERRIDING SYSTEM VALUE` } else { insert += ` (` + strings.Join(columns, ", ") + `)` @@ -1995,6 +2011,24 @@ func (s *Store) personSplitRestoredRowLocatorTx( sourceID, newPersonID, survivorSnapshotID, absorbedSnapshotID int64, sourceUID, newUID string, ) (sql.NullInt64, sql.NullString, bool, error) { + if spec.TableName == "person_fact_pin_events" { + var restoredID int64 + err := tx.QueryRowContext(ctx, `SELECT id FROM person_fact_pin_events + WHERE person_id = ? ORDER BY id DESC LIMIT 1`, newPersonID).Scan(&restoredID) + if errors.Is(err, sql.ErrNoRows) { + return sql.NullInt64{}, sql.NullString{}, false, nil + } + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, + fmt.Errorf("locate restored person fact pin event: %w", err) + } + encoded, err := personMergeIntegerRowKey(spec.keyColumns(), restoredID) + if err != nil { + return sql.NullInt64{}, sql.NullString{}, false, err + } + return sql.NullInt64{Int64: restoredID, Valid: true}, + sql.NullString{String: encoded, Valid: true}, true, nil + } columnsByName := make(map[string]personMergeSnapshotColumn, len(row.Columns)) for _, column := range row.Columns { columnsByName[column.Name] = column diff --git a/internal/store/person_splits_test.go b/internal/store/person_splits_test.go index 851bcc5d2..ab24ca53b 100644 --- a/internal/store/person_splits_test.go +++ b/internal/store/person_splits_test.go @@ -10,6 +10,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.kenn.io/msgvault/internal/activity" + "go.kenn.io/msgvault/internal/personfacts" "go.kenn.io/msgvault/internal/store" "go.kenn.io/msgvault/internal/testutil" "go.kenn.io/msgvault/internal/testutil/storetest" @@ -26,6 +27,13 @@ type personSplitFixture struct { } func newPersonSplitFixture(t *testing.T) personSplitFixture { + t.Helper() + return newPersonSplitFixtureWithAbsorbedSetup(t, nil) +} + +func newPersonSplitFixtureWithAbsorbedSetup( + t *testing.T, setup func(*store.Store, *store.Person), +) personSplitFixture { t.Helper() require := require.New(t) ctx := context.Background() @@ -55,6 +63,9 @@ func newPersonSplitFixture(t *testing.T) personSplitFixture { TypeSlug: "friend", Source: store.ProvenanceUser, Actor: "test", }) require.NoError(err) + if setup != nil { + setup(st, absorbed) + } survivor, err = st.GetPersonContext(ctx, survivor.ID) require.NoError(err) absorbed, err = st.GetPersonContext(ctx, absorbed.ID) @@ -74,6 +85,75 @@ func newPersonSplitFixture(t *testing.T) personSplitFixture { } } +func TestSplitPersonMerge_ExactReversalRestoresExplicitFactPinState(t *testing.T) { + assert := assert.New(t) + require := require.New(t) + ctx := t.Context() + f := newPersonSplitFixtureWithAbsorbedSetup(t, func(st *store.Store, absorbed *store.Person) { + for _, event := range []struct { + pinned bool + actor string + }{ + {pinned: true, actor: "split-pin-test-initial"}, + {pinned: false, actor: "split-pin-test"}, + } { + _, err := st.DB().ExecContext(ctx, st.Rebind(` + INSERT INTO person_fact_pin_events + (person_id, target_kind, target_key, target_revision, pinned, actor) + VALUES (?, ?, ?, ?, ?, ?)`), + absorbed.ID, personfacts.TargetAttribute, store.AttributeUniversalIDPrimaryChannel, + "test-revision", event.pinned, event.actor) + require.NoError(err) + } + }) + + var mergedEvents int + err := f.store.DB().QueryRowContext(ctx, f.store.Rebind(` + SELECT COUNT(*) FROM person_fact_pin_events + WHERE person_id = ? AND target_kind = ? AND target_key = ?`), + f.survivor.ID, personfacts.TargetAttribute, + store.AttributeUniversalIDPrimaryChannel).Scan(&mergedEvents) + require.NoError(err) + assert.Zero(mergedEvents) + var postMergeEventID int64 + err = f.store.DB().QueryRowContext(ctx, f.store.Rebind(` + INSERT INTO person_fact_pin_events + (person_id, target_kind, target_key, target_revision, pinned, actor) + VALUES (?, ?, ?, ?, ?, ?) + RETURNING id`), + f.survivor.ID, personfacts.TargetAttribute, store.AttributeUniversalIDPrimaryChannel, + "post-merge-revision", true, "post-merge-pin-test").Scan(&postMergeEventID) + require.NoError(err) + + result, err := f.store.SplitPersonMergeContext(ctx, store.PersonSplitRequest{ + SourcePersonID: f.survivor.ID, MergeID: f.merge.Merge.ID, + ParticipantIDs: f.absorbedParticipants, + ExpectedSourceRevision: f.survivor.Revision, + IdempotencyKey: "split-explicit-fact-pin", Actor: "test", + }) + require.NoError(err) + assert.True(result.ExactReversal) + assert.Empty(result.UnrestoredRows) + + pins, err := f.store.ListPersonFactPinsContext(ctx, result.NewPerson.ID) + require.NoError(err) + require.Len(pins, 1) + assert.Equal(personfacts.TargetAttribute, pins[0].Target.Kind) + assert.Equal(store.AttributeUniversalIDPrimaryChannel, pins[0].Target.Key) + assert.False(pins[0].Pinned) + assert.Equal("split-pin-test", pins[0].Actor) + require.NotNil(pins[0].EventID) + assert.NotEqual(postMergeEventID, *pins[0].EventID) + + sourcePins, err := f.store.ListPersonFactPinsContext(ctx, result.SourcePerson.ID) + require.NoError(err) + require.Len(sourcePins, 1) + assert.True(sourcePins[0].Pinned) + assert.Equal("post-merge-pin-test", sourcePins[0].Actor) + require.NotNil(sourcePins[0].EventID) + assert.Equal(postMergeEventID, *sourcePins[0].EventID) +} + func TestSplitPersonMerge_ExactReversal(t *testing.T) { require := require.New(t) assert := assert.New(t) diff --git a/internal/store/person_tracking.go b/internal/store/person_tracking.go index d90b54581..c1a92ecdf 100644 --- a/internal/store/person_tracking.go +++ b/internal/store/person_tracking.go @@ -38,6 +38,10 @@ func (s *Store) SetPersonTrackingContext( ) (*PersonTracking, error) { var state *PersonTracking err := s.withTxContext(ctx, func(tx *loggedTx) error { + if err := s.lockProfileIdentityKeyTxContext( + ctx, tx, "person-fact-generation", personID); err != nil { + return err + } var exists int if err := tx.QueryRowContext(ctx, `SELECT COUNT(*) FROM persons WHERE id = ?`, personID).Scan(&exists); err != nil { diff --git a/internal/store/schema.sql b/internal/store/schema.sql index 8eb1d7fbc..25c03694f 100644 --- a/internal/store/schema.sql +++ b/internal/store/schema.sql @@ -320,6 +320,186 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_person_semantic_embedding_consents_active ON person_semantic_embedding_consents(profile_fingerprint) WHERE revoked_at IS NULL; +-- Immutable evidence and generation envelopes for automatic person facts. +-- Rows remain append-only for the lifetime of their owning person. +CREATE TABLE IF NOT EXISTS person_fact_evidence ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + evidence_key TEXT NOT NULL, + source_class TEXT NOT NULL + CHECK (source_class IN ('archive', 'public', 'system', 'provider_assertion')), + directness TEXT NOT NULL + CHECK (directness IN ('direct-self', 'direct-other', 'indirect')), + authority TEXT NOT NULL + CHECK (authority IN ('authoritative', 'ordinary', 'aggregator')), + source_ref TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + subject_person_id INTEGER, + subject_ref TEXT NOT NULL DEFAULT '', + span_start INTEGER, + span_end INTEGER, + excerpt TEXT NOT NULL DEFAULT '', + content_sha256 TEXT NOT NULL DEFAULT '', + source_version TEXT NOT NULL DEFAULT '', + event_time DATETIME NOT NULL, + recorded_time DATETIME NOT NULL, + identity_score INTEGER NOT NULL CHECK (identity_score BETWEEN 0 AND 1000), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, evidence_key), + CHECK ((span_start IS NULL) = (span_end IS NULL)), + CHECK (span_start IS NULL OR (span_start >= 0 AND span_end >= span_start)) +); + +CREATE TABLE IF NOT EXISTS person_fact_generations ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_key TEXT NOT NULL, + source_cursors_json JSON NOT NULL, + program_id TEXT NOT NULL, + program_version TEXT NOT NULL, + program_fingerprint TEXT NOT NULL + CHECK (length(program_fingerprint) = 64 + AND program_fingerprint NOT GLOB '*[^0-9a-f]*'), + catalog_fingerprint TEXT NOT NULL, + provider TEXT NOT NULL, + provider_version TEXT NOT NULL, + model TEXT NOT NULL, + model_version TEXT NOT NULL, + provider_policy_fingerprint TEXT NOT NULL, + resolved_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, generation_key) +); + +CREATE TABLE IF NOT EXISTS person_fact_claims ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id INTEGER NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + claim_key TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + relation TEXT NOT NULL CHECK (relation IN ('support', 'contradict', 'supersede', 'invalid')), + submitted_value_json TEXT NOT NULL, + normalized_value_json JSON, + value_fingerprint TEXT, + valid_from DATETIME, + valid_until DATETIME, + origin TEXT NOT NULL CHECK (origin IN ('extraction', 'enrichment', 'system', 'invalid')), + confidence_json JSON NOT NULL, + rejection_action TEXT CHECK (rejection_action IN ( + 'applied', 'retained', 'superseded', 'invalid', 'identity-rejected', + 'policy-rejected', 'conflict-rejected', 'ambiguous-retained')), + rejection_reason TEXT CHECK (rejection_reason IN ( + 'malformed-value', 'unsupported-target', 'stale-target-revision', + 'unaligned-evidence', 'identity-mismatch', 'sensitive-policy', + 'pin-retained', 'below-threshold', 'insufficient-margin', + 'competing-tie', 'explicit-contradiction', 'explicit-supersession', + 'organization-ambiguous', 'applied-projection', 'evidence-unsupported', + 'outside-validity')), + rejection_detail TEXT, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ((rejection_action IS NULL AND rejection_reason IS NULL AND rejection_detail IS NULL) OR + (rejection_action IS NOT NULL AND rejection_reason IS NOT NULL AND + rejection_detail IS NOT NULL AND rejection_detail <> '')), + UNIQUE(person_id, claim_key), + CHECK ((normalized_value_json IS NULL) = (value_fingerprint IS NULL)), + CHECK (valid_from IS NULL OR valid_until IS NULL OR valid_until >= valid_from) +); + +CREATE TABLE IF NOT EXISTS person_fact_claim_evidence ( + claim_id INTEGER NOT NULL REFERENCES person_fact_claims(id) ON DELETE CASCADE, + evidence_id INTEGER NOT NULL REFERENCES person_fact_evidence(id) ON DELETE CASCADE, + PRIMARY KEY (claim_id, evidence_id) +); + +CREATE TABLE IF NOT EXISTS person_fact_evidence_status_events ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id INTEGER NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + evidence_id INTEGER NOT NULL REFERENCES person_fact_evidence(id) ON DELETE CASCADE, + evidence_key TEXT NOT NULL, + source_version TEXT NOT NULL, + supported INTEGER NOT NULL CHECK (supported IN (0, 1)), + reason TEXT NOT NULL CHECK (reason IN ( + 'source-deleted', 'source-edited', 'scope-unlinked', + 'identity-reassigned', 'source-reimported', 'scope-relinked')), + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, generation_id, evidence_key, source_version) +); + +CREATE TABLE IF NOT EXISTS person_fact_resolutions ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id INTEGER NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL, + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + resolver_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + provider_policy_fingerprint TEXT NOT NULL, + resolved_at DATETIME NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(generation_id, target_kind, target_key, resolver_version, input_fingerprint) +); + +CREATE TABLE IF NOT EXISTS person_fact_decisions ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + resolution_id INTEGER NOT NULL REFERENCES person_fact_resolutions(id) ON DELETE CASCADE, + claim_id INTEGER REFERENCES person_fact_claims(id) ON DELETE CASCADE, + decision_key TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ( + 'applied', 'retained', 'superseded', 'invalid', + 'identity-rejected', 'policy-rejected', 'conflict-rejected', + 'ambiguous-retained')), + reason TEXT NOT NULL CHECK (reason IN ( + 'malformed-value', 'unsupported-target', 'stale-target-revision', + 'unaligned-evidence', 'identity-mismatch', 'sensitive-policy', + 'pin-retained', 'below-threshold', 'insufficient-margin', + 'competing-tie', 'explicit-contradiction', 'explicit-supersession', + 'organization-ambiguous', 'applied-projection', 'evidence-unsupported', + 'outside-validity')), + score_json JSON NOT NULL, + competing_claim_id INTEGER REFERENCES person_fact_claims(id) ON DELETE SET NULL, + projection_kind TEXT, + projection_row_id INTEGER, + resolved_organization_id INTEGER, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, decision_key), + CHECK ((projection_kind IS NULL) = (projection_row_id IS NULL)) +); + +CREATE TABLE IF NOT EXISTS person_fact_pin_events ( + id INTEGER PRIMARY KEY, + person_id INTEGER NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL CHECK (target_kind IN ('attribute', 'employment')), + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + pinned INTEGER NOT NULL CHECK (pinned IN (0, 1)), + actor TEXT NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_person_fact_generations_person + ON person_fact_generations(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_evidence_person + ON person_fact_evidence(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_evidence_status_latest + ON person_fact_evidence_status_events(person_id, evidence_key, source_version, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_claims_person_target + ON person_fact_claims(person_id, target_kind, target_key, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_claim_evidence_evidence + ON person_fact_claim_evidence(evidence_id, claim_id); +CREATE INDEX IF NOT EXISTS idx_person_fact_resolutions_person_target + ON person_fact_resolutions(person_id, target_kind, target_key, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_decisions_person + ON person_fact_decisions(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_decisions_projection + ON person_fact_decisions(person_id, projection_kind, projection_row_id); +CREATE INDEX IF NOT EXISTS idx_person_fact_pin_events_latest + ON person_fact_pin_events(person_id, target_kind, target_key, id DESC); + -- Lossless native vCard resources. Typed profile tables remain the semantic -- source of truth; this table retains exact wire bodies and normalized -- occurrence metadata for future CardDAV layers. diff --git a/internal/store/schema_pg.sql b/internal/store/schema_pg.sql index 84dab7f7e..2f0bc2809 100644 --- a/internal/store/schema_pg.sql +++ b/internal/store/schema_pg.sql @@ -302,6 +302,185 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_person_semantic_embedding_consents_active ON person_semantic_embedding_consents(profile_fingerprint) WHERE revoked_at IS NULL; +-- Immutable evidence and generation envelopes for automatic person facts. +-- Rows remain append-only for the lifetime of their owning person. +CREATE TABLE IF NOT EXISTS person_fact_evidence ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + evidence_key TEXT NOT NULL, + source_class TEXT NOT NULL + CHECK (source_class IN ('archive', 'public', 'system', 'provider_assertion')), + directness TEXT NOT NULL + CHECK (directness IN ('direct-self', 'direct-other', 'indirect')), + authority TEXT NOT NULL + CHECK (authority IN ('authoritative', 'ordinary', 'aggregator')), + source_ref TEXT NOT NULL DEFAULT '', + source_url TEXT NOT NULL DEFAULT '', + subject_person_id BIGINT, + subject_ref TEXT NOT NULL DEFAULT '', + span_start BIGINT, + span_end BIGINT, + excerpt TEXT NOT NULL DEFAULT '', + content_sha256 TEXT NOT NULL DEFAULT '', + source_version TEXT NOT NULL DEFAULT '', + event_time TIMESTAMPTZ NOT NULL, + recorded_time TIMESTAMPTZ NOT NULL, + identity_score INTEGER NOT NULL CHECK (identity_score BETWEEN 0 AND 1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, evidence_key), + CHECK ((span_start IS NULL) = (span_end IS NULL)), + CHECK (span_start IS NULL OR (span_start >= 0 AND span_end >= span_start)) +); + +CREATE TABLE IF NOT EXISTS person_fact_generations ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_key TEXT NOT NULL, + source_cursors_json JSONB NOT NULL, + program_id TEXT NOT NULL, + program_version TEXT NOT NULL, + program_fingerprint TEXT NOT NULL + CHECK (program_fingerprint ~ '^[0-9a-f]{64}$'), + catalog_fingerprint TEXT NOT NULL, + provider TEXT NOT NULL, + provider_version TEXT NOT NULL, + model TEXT NOT NULL, + model_version TEXT NOT NULL, + provider_policy_fingerprint TEXT NOT NULL, + resolved_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, generation_key) +); + +CREATE TABLE IF NOT EXISTS person_fact_claims ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id BIGINT NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + claim_key TEXT NOT NULL, + target_kind TEXT NOT NULL, + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + relation TEXT NOT NULL CHECK (relation IN ('support', 'contradict', 'supersede', 'invalid')), + submitted_value_json TEXT NOT NULL, + normalized_value_json JSONB, + value_fingerprint TEXT, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + origin TEXT NOT NULL CHECK (origin IN ('extraction', 'enrichment', 'system', 'invalid')), + confidence_json JSONB NOT NULL, + rejection_action TEXT CHECK (rejection_action IN ( + 'applied', 'retained', 'superseded', 'invalid', 'identity-rejected', + 'policy-rejected', 'conflict-rejected', 'ambiguous-retained')), + rejection_reason TEXT CHECK (rejection_reason IN ( + 'malformed-value', 'unsupported-target', 'stale-target-revision', + 'unaligned-evidence', 'identity-mismatch', 'sensitive-policy', + 'pin-retained', 'below-threshold', 'insufficient-margin', + 'competing-tie', 'explicit-contradiction', 'explicit-supersession', + 'organization-ambiguous', 'applied-projection', 'evidence-unsupported', + 'outside-validity')), + rejection_detail TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + CHECK ((rejection_action IS NULL AND rejection_reason IS NULL AND rejection_detail IS NULL) OR + (rejection_action IS NOT NULL AND rejection_reason IS NOT NULL AND + rejection_detail IS NOT NULL AND rejection_detail <> '')), + UNIQUE(person_id, claim_key), + CHECK ((normalized_value_json IS NULL) = (value_fingerprint IS NULL)), + CHECK (valid_from IS NULL OR valid_until IS NULL OR valid_until >= valid_from) +); + +CREATE TABLE IF NOT EXISTS person_fact_claim_evidence ( + claim_id BIGINT NOT NULL REFERENCES person_fact_claims(id) ON DELETE CASCADE, + evidence_id BIGINT NOT NULL REFERENCES person_fact_evidence(id) ON DELETE CASCADE, + PRIMARY KEY (claim_id, evidence_id) +); + +CREATE TABLE IF NOT EXISTS person_fact_evidence_status_events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id BIGINT NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + evidence_id BIGINT NOT NULL REFERENCES person_fact_evidence(id) ON DELETE CASCADE, + evidence_key TEXT NOT NULL, + source_version TEXT NOT NULL, + supported BOOLEAN NOT NULL, + reason TEXT NOT NULL CHECK (reason IN ( + 'source-deleted', 'source-edited', 'scope-unlinked', + 'identity-reassigned', 'source-reimported', 'scope-relinked')), + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, generation_id, evidence_key, source_version) +); + +CREATE TABLE IF NOT EXISTS person_fact_resolutions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + generation_id BIGINT NOT NULL REFERENCES person_fact_generations(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL, + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + resolver_version TEXT NOT NULL, + input_fingerprint TEXT NOT NULL, + provider_policy_fingerprint TEXT NOT NULL, + resolved_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(generation_id, target_kind, target_key, resolver_version, input_fingerprint) +); + +CREATE TABLE IF NOT EXISTS person_fact_decisions ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + resolution_id BIGINT NOT NULL REFERENCES person_fact_resolutions(id) ON DELETE CASCADE, + claim_id BIGINT REFERENCES person_fact_claims(id) ON DELETE CASCADE, + decision_key TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ( + 'applied', 'retained', 'superseded', 'invalid', + 'identity-rejected', 'policy-rejected', 'conflict-rejected', + 'ambiguous-retained')), + reason TEXT NOT NULL CHECK (reason IN ( + 'malformed-value', 'unsupported-target', 'stale-target-revision', + 'unaligned-evidence', 'identity-mismatch', 'sensitive-policy', + 'pin-retained', 'below-threshold', 'insufficient-margin', + 'competing-tie', 'explicit-contradiction', 'explicit-supersession', + 'organization-ambiguous', 'applied-projection', 'evidence-unsupported', + 'outside-validity')), + score_json JSONB NOT NULL, + competing_claim_id BIGINT REFERENCES person_fact_claims(id) ON DELETE SET NULL, + projection_kind TEXT, + projection_row_id BIGINT, + resolved_organization_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE(person_id, decision_key), + CHECK ((projection_kind IS NULL) = (projection_row_id IS NULL)) +); + +CREATE TABLE IF NOT EXISTS person_fact_pin_events ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + person_id BIGINT NOT NULL REFERENCES persons(id) ON DELETE CASCADE, + target_kind TEXT NOT NULL CHECK (target_kind IN ('attribute', 'employment')), + target_key TEXT NOT NULL, + target_revision TEXT NOT NULL, + pinned BOOLEAN NOT NULL, + actor TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_person_fact_generations_person + ON person_fact_generations(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_evidence_person + ON person_fact_evidence(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_evidence_status_latest + ON person_fact_evidence_status_events(person_id, evidence_key, source_version, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_claims_person_target + ON person_fact_claims(person_id, target_kind, target_key, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_claim_evidence_evidence + ON person_fact_claim_evidence(evidence_id, claim_id); +CREATE INDEX IF NOT EXISTS idx_person_fact_resolutions_person_target + ON person_fact_resolutions(person_id, target_kind, target_key, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_decisions_person + ON person_fact_decisions(person_id, id DESC); +CREATE INDEX IF NOT EXISTS idx_person_fact_decisions_projection + ON person_fact_decisions(person_id, projection_kind, projection_row_id); +CREATE INDEX IF NOT EXISTS idx_person_fact_pin_events_latest + ON person_fact_pin_events(person_id, target_kind, target_key, id DESC); + -- Lossless native vCard resources. Typed profile tables remain the semantic -- source of truth; this table retains exact wire bodies and normalized -- occurrence metadata for future CardDAV layers. diff --git a/internal/store/store.go b/internal/store/store.go index 61d863199..db7463822 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -147,6 +147,7 @@ func openSQLite(dbPath, params string) (*Store, error) { // Ensure directory exists (skip for in-memory databases) if dbPath != ":memory:" && !strings.Contains(dbPath, ":memory:") { dir := filepath.Dir(dbPath) + // #nosec G703 -- dbPath is the caller-selected database location; creating its parent is intentional. if err := os.MkdirAll(dir, 0755); err != nil { return nil, fmt.Errorf("create db directory: %w", err) } @@ -1226,6 +1227,19 @@ func (s *Store) InitSchemaContext(ctx context.Context) error { if err := s.ensureVCardSourceResourceIdentityIndexes(ctx); err != nil { return fmt.Errorf("scope vCard identities to source resources: %w", err) } + // Organization domains written before IDNA normalization may still contain + // Unicode. Canonicalize them before fact resolution compares incoming ASCII + // references with persisted roots and identifiers. + if err := s.runOnceMigration( + ctx, migrationOrganizationDomainIDNA, false, + func(ctx context.Context) error { + return s.runMaintenance(ctx, func(ctx context.Context, tx *loggedTx) error { + return s.canonicalizeLegacyOrganizationDomains(ctx, tx) + }) + }, + ); err != nil { + return fmt.Errorf("canonicalize organization domains: %w", err) + } // Recovery reads only acceptances whose link transaction may not have // committed. Create this after the legacy-column loop: schema.sql is // executed before that loop, so an upgraded table does not have the column diff --git a/internal/store/subset.go b/internal/store/subset.go index f1d317c33..8f83529fa 100644 --- a/internal/store/subset.go +++ b/internal/store/subset.go @@ -2118,6 +2118,7 @@ func copyStructuredProfiles(tx *sql.Tx) (int64, error) { for _, table := range []string{ "person_names", "person_addresses", "person_dates", "person_categories", "person_media", + "person_fact_pin_events", } { if _, err := copyByName(tx, table, `person_id IN (SELECT id FROM persons)`); err != nil { return 0, fmt.Errorf("copy %s: %w", table, err) diff --git a/pkg/client/client_test.go b/pkg/client/client_test.go index 49591bcb8..bec8ca1d6 100644 --- a/pkg/client/client_test.go +++ b/pkg/client/client_test.go @@ -376,6 +376,58 @@ func TestGeneratedFileMetadataRequiresPresenceButAcceptsEmptyLegacyStrings(t *te }) } +func TestGeneratedPersonFactEvidenceRequiresPresenceButAcceptsEmptyStrings(t *testing.T) { + requirements := require.New(t) + assertions := assert.New(t) + const payload = `{ + "id":1,"evidence_key":"public:1","source_class":"public_web", + "directness":"direct","authority":"first_party","source_ref":"", + "source_url":"https://example.test/profile","subject_ref":"", + "excerpt":"","content_sha256":"","source_version":"", + "event_time":"2026-08-24T12:00:00Z","recorded_time":"2026-08-24T12:00:00Z", + "identity_score":10,"supported":true,"created_at":"2026-08-24T12:00:00Z", + "latest_status":{"id":2,"generation_id":3,"evidence_key":"public:1", + "source_version":"","supported":true,"reason":"observed", + "created_at":"2026-08-24T12:00:00Z"} + }` + var public generated.PersonFactEvidence + requirements.NoError(json.Unmarshal([]byte(payload), &public)) + requirements.NotNil(public.SourceRef) + requirements.NotNil(public.ContentSha256) + requirements.NotNil(public.SourceVersion) + requirements.NotNil(public.SubjectRef) + requirements.NotNil(public.Excerpt) + requirements.NotNil(public.LatestStatus) + requirements.NotNil(public.LatestStatus.SourceVersion) + assertions.Empty(*public.SourceRef) + assertions.Empty(*public.ContentSha256) + assertions.Empty(*public.SourceVersion) + assertions.Empty(*public.LatestStatus.SourceVersion) + assertions.Empty(*public.SubjectRef) + assertions.Empty(*public.Excerpt) + requirements.NoError(public.Validate()) + requirements.NoError(public.LatestStatus.Validate()) + + archive := public + archive.SourceClass = "archive" + empty := "" + archive.SourceURL = &empty + requirements.NoError(archive.Validate(), "archive evidence may omit a source URL value") + + missing := public + missing.SourceRef = nil + requirements.Error(missing.Validate(), "required source_ref must still be present") + missing = public + missing.SubjectRef = nil + requirements.Error(missing.Validate(), "required subject_ref must still be present") + missing = public + missing.Excerpt = nil + requirements.Error(missing.Validate(), "required excerpt must still be present") + missingStatus := *public.LatestStatus + missingStatus.SourceVersion = nil + requirements.Error(missingStatus.Validate(), "required status source_version must still be present") +} + // TestGeneratedChangesResponseAcceptsTheFeedsOrdinaryPages holds the // content-change feed to the same "required means present, not non-empty" // distinction the file-metadata models above are held to. diff --git a/pkg/client/generated/client.go b/pkg/client/generated/client.go index 2c6980d74..e54c17f59 100644 --- a/pkg/client/generated/client.go +++ b/pkg/client/generated/client.go @@ -647,6 +647,30 @@ type ClientInterface interface { ListPersonEmployments(ctx context.Context, options *ListPersonEmploymentsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonEmploymentsResponse, error) ListPersonEmploymentsWithResponse(ctx context.Context, options *ListPersonEmploymentsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonEmploymentsResp, error) + // ListPersonFactClaims List immutable person fact claims + ListPersonFactClaims(ctx context.Context, options *ListPersonFactClaimsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactClaimsResponse, error) + ListPersonFactClaimsWithResponse(ctx context.Context, options *ListPersonFactClaimsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactClaimsResp, error) + + // ListPersonFactDecisions List immutable person fact decisions + ListPersonFactDecisions(ctx context.Context, options *ListPersonFactDecisionsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactDecisionsResponse, error) + ListPersonFactDecisionsWithResponse(ctx context.Context, options *ListPersonFactDecisionsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactDecisionsResp, error) + + // ListPersonFactEvidence List immutable person fact evidence + ListPersonFactEvidence(ctx context.Context, options *ListPersonFactEvidenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceResponse, error) + ListPersonFactEvidenceWithResponse(ctx context.Context, options *ListPersonFactEvidenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceResp, error) + + // ListPersonFactEvidenceStatusEvents List person fact evidence status history + ListPersonFactEvidenceStatusEvents(ctx context.Context, options *ListPersonFactEvidenceStatusEventsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceStatusEventsResponse, error) + ListPersonFactEvidenceStatusEventsWithResponse(ctx context.Context, options *ListPersonFactEvidenceStatusEventsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceStatusEventsResp, error) + + // ListPersonFactPins List effective person fact pins + ListPersonFactPins(ctx context.Context, options *ListPersonFactPinsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactPinsResponse, error) + ListPersonFactPinsWithResponse(ctx context.Context, options *ListPersonFactPinsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactPinsResp, error) + + // SetPersonFactPin Replace an effective person fact pin + SetPersonFactPin(ctx context.Context, options *SetPersonFactPinRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonFactPinResponse, error) + SetPersonFactPinWithResponse(ctx context.Context, options *SetPersonFactPinRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonFactPinResp, error) + // SearchPersonFiles Search one durable person's analytical files SearchPersonFiles(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResponse, error) SearchPersonFilesWithResponse(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResp, error) @@ -695,6 +719,10 @@ type ClientInterface interface { SetPersonTracking(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResponse, error) SetPersonTrackingWithResponse(ctx context.Context, options *SetPersonTrackingRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonTrackingResp, error) + // ListPersonFactTargets List eligible automatic person fact targets + ListPersonFactTargets(ctx context.Context, options *ListPersonFactTargetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactTargetsResponse, error) + ListPersonFactTargetsWithResponse(ctx context.Context, options *ListPersonFactTargetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactTargetsResp, error) + // DecidePersonMergeCandidate Accept or reject a person merge attribute candidate DecidePersonMergeCandidate(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResponse, error) DecidePersonMergeCandidateWithResponse(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResp, error) @@ -10325,6 +10353,385 @@ func (c *Client) ListPersonEmployments(ctx context.Context, options *ListPersonE return responseParser(ctx, resp) } +// ListPersonFactClaims List immutable person fact claims +func (c *Client) ListPersonFactClaims(ctx context.Context, options *ListPersonFactClaimsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactClaimsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-claims", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactClaimsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactClaimsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactClaimsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-claims") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListPersonFactDecisions List immutable person fact decisions +func (c *Client) ListPersonFactDecisions(ctx context.Context, options *ListPersonFactDecisionsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactDecisionsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-decisions", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactDecisionsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactDecisionsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactDecisionsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-decisions") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListPersonFactEvidence List immutable person fact evidence +func (c *Client) ListPersonFactEvidence(ctx context.Context, options *ListPersonFactEvidenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-evidence", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactEvidenceResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactEvidenceErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactEvidenceResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-evidence") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListPersonFactEvidenceStatusEvents List person fact evidence status history +func (c *Client) ListPersonFactEvidenceStatusEvents(ctx context.Context, options *ListPersonFactEvidenceStatusEventsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceStatusEventsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-evidence-status-events", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactEvidenceStatusEventsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactEvidenceStatusEventsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactEvidenceStatusEventsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-evidence-status-events") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// ListPersonFactPins List effective person fact pins +func (c *Client) ListPersonFactPins(ctx context.Context, options *ListPersonFactPinsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactPinsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-pins", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactPinsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactPinsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactPinsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-pins") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + +// SetPersonFactPin Replace an effective person fact pin +func (c *Client) SetPersonFactPin(ctx context.Context, options *SetPersonFactPinRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonFactPinResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-pins/{kind}/{key}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*SetPersonFactPinResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(SetPersonFactPinErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(SetPersonFactPinResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-pins/{kind}/{key}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // SearchPersonFiles Search one durable person's analytical files func (c *Client) SearchPersonFiles(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResponse, error) { var err error @@ -11073,6 +11480,69 @@ func (c *Client) SetPersonTracking(ctx context.Context, options *SetPersonTracki return responseParser(ctx, resp) } +// ListPersonFactTargets List eligible automatic person fact targets +func (c *Client) ListPersonFactTargets(ctx context.Context, options *ListPersonFactTargetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactTargetsResponse, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-fact-targets", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + responseParser := func(ctx context.Context, resp *runtime.Response) (*ListPersonFactTargetsResponse, error) { + bodyBytes := resp.Content + if resp.StatusCode != 200 { + target := new(ListPersonFactTargetsErrorResponse) + // Handle empty error response body gracefully - skip unmarshal if no content + if len(bodyBytes) > 0 { + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactTargetsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + // Return error with (possibly empty) target + if errTarget, ok := any(*target).(error); ok { + return nil, runtime.NewClientAPIError(errTarget, runtime.WithStatusCode(resp.StatusCode)) + } + return nil, runtime.NewClientAPIError(fmt.Errorf("API error (status %d): %v", resp.StatusCode, *target), + runtime.WithStatusCode(resp.StatusCode)) + } + target := new(ListPersonFactTargetsResponse) + // Handle empty response body gracefully + if len(bodyBytes) == 0 { + return target, nil + } + if err = json.Unmarshal(bodyBytes, target); err != nil { + return nil, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactTargetsResponse", + Body: bodyBytes, + Err: err, + } + } + return target, nil + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-fact-targets") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + return responseParser(ctx, resp) +} + // DecidePersonMergeCandidate Accept or reject a person merge attribute candidate func (c *Client) DecidePersonMergeCandidate(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResponse, error) { var err error diff --git a/pkg/client/generated/client_options.go b/pkg/client/generated/client_options.go index 141c6b320..76ee61be3 100644 --- a/pkg/client/generated/client_options.go +++ b/pkg/client/generated/client_options.go @@ -6396,6 +6396,315 @@ func (o *ListPersonEmploymentsRequestOptions) GetHeader() (map[string]string, er return nil, nil } +// ListPersonFactClaimsRequestOptions is the options needed to make a request to ListPersonFactClaims. +type ListPersonFactClaimsRequestOptions struct { + PathParams *ListPersonFactClaimsPath + Query *ListPersonFactClaimsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactClaimsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactClaimsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactClaimsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactClaimsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactClaimsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListPersonFactDecisionsRequestOptions is the options needed to make a request to ListPersonFactDecisions. +type ListPersonFactDecisionsRequestOptions struct { + PathParams *ListPersonFactDecisionsPath + Query *ListPersonFactDecisionsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactDecisionsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactDecisionsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactDecisionsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactDecisionsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactDecisionsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListPersonFactEvidenceRequestOptions is the options needed to make a request to ListPersonFactEvidence. +type ListPersonFactEvidenceRequestOptions struct { + PathParams *ListPersonFactEvidencePath + Query *ListPersonFactEvidenceQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactEvidenceRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactEvidenceRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactEvidenceRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactEvidenceRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactEvidenceRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListPersonFactEvidenceStatusEventsRequestOptions is the options needed to make a request to ListPersonFactEvidenceStatusEvents. +type ListPersonFactEvidenceStatusEventsRequestOptions struct { + PathParams *ListPersonFactEvidenceStatusEventsPath + Query *ListPersonFactEvidenceStatusEventsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactEvidenceStatusEventsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactEvidenceStatusEventsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactEvidenceStatusEventsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactEvidenceStatusEventsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactEvidenceStatusEventsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// ListPersonFactPinsRequestOptions is the options needed to make a request to ListPersonFactPins. +type ListPersonFactPinsRequestOptions struct { + PathParams *ListPersonFactPinsPath +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactPinsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactPinsRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactPinsRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactPinsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactPinsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + +// SetPersonFactPinRequestOptions is the options needed to make a request to SetPersonFactPin. +type SetPersonFactPinRequestOptions struct { + PathParams *SetPersonFactPinPath + Body *SetPersonFactPinBody +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *SetPersonFactPinRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.PathParams != nil { + if v, ok := any(o.PathParams).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("PathParams", err) + } + } + } + + if o.Body != nil { + if v, ok := any(o.Body).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Body", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *SetPersonFactPinRequestOptions) GetPathParams() (map[string]any, error) { + return runtime.AsMap[any](o.PathParams) +} + +// GetQuery returns the query params as a map. +func (o *SetPersonFactPinRequestOptions) GetQuery() (map[string]any, error) { + return nil, nil +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *SetPersonFactPinRequestOptions) GetBody() any { + return o.Body +} + +// GetHeader returns the headers as a map. +func (o *SetPersonFactPinRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // SearchPersonFilesRequestOptions is the options needed to make a request to SearchPersonFiles. type SearchPersonFilesRequestOptions struct { PathParams *SearchPersonFilesPath @@ -7032,6 +7341,50 @@ func (o *SetPersonTrackingRequestOptions) GetHeader() (map[string]string, error) return nil, nil } +// ListPersonFactTargetsRequestOptions is the options needed to make a request to ListPersonFactTargets. +type ListPersonFactTargetsRequestOptions struct { + Query *ListPersonFactTargetsQuery +} + +// Validate validates all the fields in the options. +// Use it if fields validation was not run. +func (o *ListPersonFactTargetsRequestOptions) Validate() error { + var errors runtime.ValidationErrors + + if o.Query != nil { + if v, ok := any(o.Query).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Query", err) + } + } + } + if len(errors) == 0 { + return nil + } + + return errors +} + +// GetPathParams returns the path params as a map. +func (o *ListPersonFactTargetsRequestOptions) GetPathParams() (map[string]any, error) { + return nil, nil +} + +// GetQuery returns the query params as a map. +func (o *ListPersonFactTargetsRequestOptions) GetQuery() (map[string]any, error) { + return runtime.AsMap[any](o.Query) +} + +// GetBody returns the payload in any type that can be marshalled to JSON by the client. +func (o *ListPersonFactTargetsRequestOptions) GetBody() any { + return nil +} + +// GetHeader returns the headers as a map. +func (o *ListPersonFactTargetsRequestOptions) GetHeader() (map[string]string, error) { + return nil, nil +} + // DecidePersonMergeCandidateRequestOptions is the options needed to make a request to DecidePersonMergeCandidate. type DecidePersonMergeCandidateRequestOptions struct { PathParams *DecidePersonMergeCandidatePath diff --git a/pkg/client/generated/client_with_response.go b/pkg/client/generated/client_with_response.go index 7c0931867..2181d8953 100644 --- a/pkg/client/generated/client_with_response.go +++ b/pkg/client/generated/client_with_response.go @@ -13362,6 +13362,593 @@ func (c *Client) ListPersonEmploymentsWithResponse(ctx context.Context, options } } +// ListPersonFactClaims List immutable person fact claims +func (c *Client) ListPersonFactClaimsWithResponse(ctx context.Context, options *ListPersonFactClaimsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactClaimsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-claims", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-claims") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactClaimsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactClaimsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactClaimsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(ListPersonFactClaimsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactClaimsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactClaimsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListPersonFactDecisions List immutable person fact decisions +func (c *Client) ListPersonFactDecisionsWithResponse(ctx context.Context, options *ListPersonFactDecisionsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactDecisionsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-decisions", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-decisions") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactDecisionsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactDecisionsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactDecisionsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(ListPersonFactDecisionsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactDecisionsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactDecisionsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListPersonFactEvidence List immutable person fact evidence +func (c *Client) ListPersonFactEvidenceWithResponse(ctx context.Context, options *ListPersonFactEvidenceRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-evidence", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-evidence") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactEvidenceResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactEvidenceResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactEvidenceErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(ListPersonFactEvidenceErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactEvidenceErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListPersonFactEvidenceStatusEvents List person fact evidence status history +func (c *Client) ListPersonFactEvidenceStatusEventsWithResponse(ctx context.Context, options *ListPersonFactEvidenceStatusEventsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactEvidenceStatusEventsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-evidence-status-events", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-evidence-status-events") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactEvidenceStatusEventsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactEvidenceStatusEventsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactEvidenceStatusEventsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(ListPersonFactEvidenceStatusEventsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactEvidenceStatusEventsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactEvidenceStatusEventsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// ListPersonFactPins List effective person fact pins +func (c *Client) ListPersonFactPinsWithResponse(ctx context.Context, options *ListPersonFactPinsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactPinsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-pins", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-pins") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactPinsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactPinsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactPinsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(ListPersonFactPinsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactPinsErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactPinsErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + +// SetPersonFactPin Replace an effective person fact pin +func (c *Client) SetPersonFactPinWithResponse(ctx context.Context, options *SetPersonFactPinRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SetPersonFactPinResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/people/{id}/fact-pins/{kind}/{key}", + Method: "PUT", + Options: options, + ContentType: "application/json", + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/people/{id}/fact-pins/{kind}/{key}") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &SetPersonFactPinResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(SetPersonFactPinResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(SetPersonFactPinErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 404: + out.JSON404 = new(SetPersonFactPinErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON404); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 409: + out.JSON409 = new(SetPersonFactPinErrorResponseJSON409) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON409); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinErrorResponseJSON409", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(SetPersonFactPinErrorResponseJSON503) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "SetPersonFactPinErrorResponseJSON503", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // SearchPersonFiles Search one durable person's analytical files func (c *Client) SearchPersonFilesWithResponse(ctx context.Context, options *SearchPersonFilesRequestOptions, reqEditors ...runtime.RequestEditorFn) (*SearchPersonFilesResp, error) { var err error @@ -14699,6 +15286,85 @@ func (c *Client) SetPersonTrackingWithResponse(ctx context.Context, options *Set } } +// ListPersonFactTargets List eligible automatic person fact targets +func (c *Client) ListPersonFactTargetsWithResponse(ctx context.Context, options *ListPersonFactTargetsRequestOptions, reqEditors ...runtime.RequestEditorFn) (*ListPersonFactTargetsResp, error) { + var err error + reqParams := runtime.RequestOptionsParameters{ + RequestURL: c.apiClient.GetBaseURL() + "/api/v1/person-fact-targets", + Method: "GET", + Options: options, + } + + req, err := c.apiClient.CreateRequest(ctx, reqParams, reqEditors...) + if err != nil { + return nil, fmt.Errorf("error creating request: %w", err) + } + + resp, err := c.apiClient.ExecuteRequest(ctx, req, "/api/v1/person-fact-targets") + if err != nil { + return nil, fmt.Errorf("error executing request: %w", err) + } + + out := &ListPersonFactTargetsResp{ + HTTPResponse: resp.Raw, + Body: resp.Content, + StatusCode: resp.StatusCode, + } + + switch resp.StatusCode { + case 200: + out.JSON200 = new(ListPersonFactTargetsResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON200); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactTargetsResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, nil + case 400: + out.JSON400 = new(ListPersonFactTargetsErrorResponse) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON400); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactTargetsErrorResponse", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + case 503: + out.JSON503 = new(ListPersonFactTargetsErrorResponseJSON) + bodyBytes := resp.Content + if len(bodyBytes) > 0 { + if err := json.Unmarshal(bodyBytes, out.JSON503); err != nil { + return out, &runtime.ResponseDecodeError{ + StatusCode: resp.StatusCode, + ContentType: resp.Headers.Get("Content-Type"), + ContentLength: len(bodyBytes), + TargetType: "ListPersonFactTargetsErrorResponseJSON", + Body: bodyBytes, + Err: err, + } + } + } + return out, runtime.NewClientAPIError(fmt.Errorf("API error (status %d)", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + default: + return out, runtime.NewClientAPIError(fmt.Errorf("unexpected status code: %d", resp.StatusCode), runtime.WithStatusCode(resp.StatusCode)) + } +} + // DecidePersonMergeCandidate Accept or reject a person merge attribute candidate func (c *Client) DecidePersonMergeCandidateWithResponse(ctx context.Context, options *DecidePersonMergeCandidateRequestOptions, reqEditors ...runtime.RequestEditorFn) (*DecidePersonMergeCandidateResp, error) { var err error diff --git a/pkg/client/generated/enums.go b/pkg/client/generated/enums.go index 88d8c6315..c242fc4ef 100644 --- a/pkg/client/generated/enums.go +++ b/pkg/client/generated/enums.go @@ -1257,6 +1257,23 @@ func (t TaskIntegrationStatusResponseState) Validate() error { } } +type SetPersonFactPinPathKind string + +const ( + Attribute SetPersonFactPinPathKind = "attribute" + SetPersonFactPinPathKindEmployment SetPersonFactPinPathKind = "employment" +) + +// Validate checks if the SetPersonFactPinPathKind value is valid +func (s SetPersonFactPinPathKind) Validate() error { + switch s { + case Attribute, SetPersonFactPinPathKindEmployment: + return nil + default: + return runtime.NewValidationErrorsFromString("Enum", fmt.Sprintf("must be a valid SetPersonFactPinPathKind value, got: %v", s)) + } +} + type ListPersonRelationshipReviewsQueryStatus string const ( diff --git a/pkg/client/generated/paths.go b/pkg/client/generated/paths.go index 39103d021..80460765f 100644 --- a/pkg/client/generated/paths.go +++ b/pkg/client/generated/paths.go @@ -460,6 +460,58 @@ type ListPersonEmploymentsPath struct { ID int64 `json:"id"` } +type ListPersonFactClaimsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type ListPersonFactDecisionsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type ListPersonFactEvidencePath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type ListPersonFactEvidenceStatusEventsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type ListPersonFactPinsPath struct { + // ID Durable person ID + ID int64 `json:"id"` +} + +type SetPersonFactPinPath struct { + // ID Durable person ID + ID int64 `json:"id"` + + // Kind Closed person fact target kind + Kind SetPersonFactPinPathKind `json:"kind" validate:"required"` + + // Key Exact person fact target key + Key string `json:"key" validate:"required"` +} + +func (s SetPersonFactPinPath) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(s.Kind).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Kind", err) + } + } + if err := typesValidator.Var(s.Key, "required"); err != nil { + errors = errors.Append("Key", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type SearchPersonFilesPath struct { // ID Durable person ID ID int64 `json:"id"` diff --git a/pkg/client/generated/payloads.go b/pkg/client/generated/payloads.go index d32750c81..a1d642f54 100644 --- a/pkg/client/generated/payloads.go +++ b/pkg/client/generated/payloads.go @@ -132,6 +132,8 @@ type PatchPersonBody = PatchPersonRequest type SetPersonAttributeBody = SetPersonAttributeRequest +type SetPersonFactPinBody = SetPersonFactPinRequest + type SearchPersonFilesBody = PersonFileSearchHTTPRequest type MergePersonsBody = MergePersonRequest diff --git a/pkg/client/generated/queries.go b/pkg/client/generated/queries.go index 593d16f19..acd11df2a 100644 --- a/pkg/client/generated/queries.go +++ b/pkg/client/generated/queries.go @@ -676,6 +676,53 @@ type ListPersonEmploymentsQuery struct { Offset *int64 `json:"offset,omitempty"` } +type ListPersonFactClaimsQuery struct { + // Target Exact target as kind:key:sha256:<64 lowercase hex characters> + Target *string `json:"target,omitempty"` + + // Limit Maximum rows to return (default 50, max 200) + Limit *int64 `json:"limit,omitempty"` + + // Offset Zero-based row offset + Offset *int64 `json:"offset,omitempty"` +} + +type ListPersonFactDecisionsQuery struct { + // Target Exact target as kind:key:sha256:<64 lowercase hex characters> + Target *string `json:"target,omitempty"` + + // Limit Maximum rows to return (default 50, max 200) + Limit *int64 `json:"limit,omitempty"` + + // Offset Zero-based row offset + Offset *int64 `json:"offset,omitempty"` +} + +type ListPersonFactEvidenceQuery struct { + // Target Exact target as kind:key:sha256:<64 lowercase hex characters> + Target *string `json:"target,omitempty"` + + // Limit Maximum rows to return (default 50, max 200) + Limit *int64 `json:"limit,omitempty"` + + // Offset Zero-based row offset + Offset *int64 `json:"offset,omitempty"` +} + +type ListPersonFactEvidenceStatusEventsQuery struct { + // EvidenceKey Restrict to one immutable evidence key + EvidenceKey *string `json:"evidence_key,omitempty"` + + // Supported Restrict to supported or unsupported events + Supported *bool `json:"supported,omitempty"` + + // Limit Maximum events to return (default 50, max 200) + Limit *int64 `json:"limit,omitempty"` + + // Offset Zero-based event offset + Offset *int64 `json:"offset,omitempty"` +} + type ListPersonMergesQuery struct { // Limit Maximum results Limit *int64 `json:"limit,omitempty"` @@ -693,6 +740,11 @@ type ListPersonRelationshipsQuery struct { IncludeEnded *bool `json:"include_ended,omitempty"` } +type ListPersonFactTargetsQuery struct { + // IncludeSensitive Include sensitive targets + IncludeSensitive *bool `json:"include_sensitive,omitempty"` +} + type ListPersonRelationshipReviewsQuery struct { Status *ListPersonRelationshipReviewsQueryStatus `json:"status,omitempty"` PersonID *int64 `json:"person_id,omitempty"` diff --git a/pkg/client/generated/responses.go b/pkg/client/generated/responses.go index aec821c74..3f8aa7654 100644 --- a/pkg/client/generated/responses.go +++ b/pkg/client/generated/responses.go @@ -1921,6 +1921,56 @@ type ListPersonEmploymentsErrorResponseJSON = ErrorResponse type ListPersonEmploymentsErrorResponseJSON503 = ErrorResponse +type ListPersonFactClaimsResponse = PersonFactClaimsResponse + +type ListPersonFactClaimsErrorResponse = ErrorResponse + +type ListPersonFactClaimsErrorResponseJSON = ErrorResponse + +type ListPersonFactClaimsErrorResponseJSON503 = ErrorResponse + +type ListPersonFactDecisionsResponse = PersonFactDecisionsResponse + +type ListPersonFactDecisionsErrorResponse = ErrorResponse + +type ListPersonFactDecisionsErrorResponseJSON = ErrorResponse + +type ListPersonFactDecisionsErrorResponseJSON503 = ErrorResponse + +type ListPersonFactEvidenceResponse = PersonFactEvidenceResponse + +type ListPersonFactEvidenceErrorResponse = ErrorResponse + +type ListPersonFactEvidenceErrorResponseJSON = ErrorResponse + +type ListPersonFactEvidenceErrorResponseJSON503 = ErrorResponse + +type ListPersonFactEvidenceStatusEventsResponse = PersonFactEvidenceStatusEventsResponse + +type ListPersonFactEvidenceStatusEventsErrorResponse = ErrorResponse + +type ListPersonFactEvidenceStatusEventsErrorResponseJSON = ErrorResponse + +type ListPersonFactEvidenceStatusEventsErrorResponseJSON503 = ErrorResponse + +type ListPersonFactPinsResponse = PersonFactPinsResponse + +type ListPersonFactPinsErrorResponse = ErrorResponse + +type ListPersonFactPinsErrorResponseJSON = ErrorResponse + +type ListPersonFactPinsErrorResponseJSON503 = ErrorResponse + +type SetPersonFactPinResponse = PersonFactPinWrite + +type SetPersonFactPinErrorResponse = ErrorResponse + +type SetPersonFactPinErrorResponseJSON = ErrorResponse + +type SetPersonFactPinErrorResponseJSON409 = ErrorResponse + +type SetPersonFactPinErrorResponseJSON503 = ErrorResponse + type SearchPersonFilesResponse = PersonFileSearchHTTPResponse type SearchPersonFilesErrorResponse = ErrorResponse @@ -2077,6 +2127,12 @@ type SetPersonTrackingErrorResponseJSON = ErrorResponse type SetPersonTrackingErrorResponseJSON503 = ErrorResponse +type ListPersonFactTargetsResponse = Catalog + +type ListPersonFactTargetsErrorResponse = ErrorResponse + +type ListPersonFactTargetsErrorResponseJSON = ErrorResponse + type DecidePersonMergeCandidateResponse = PersonMergeReviewCandidate type DecidePersonMergeCandidateErrorResponse = ErrorResponse @@ -4136,6 +4192,67 @@ type ListPersonEmploymentsResp struct { JSON503 *ListPersonEmploymentsErrorResponseJSON503 } +type ListPersonFactClaimsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactClaimsResponse + JSON400 *ListPersonFactClaimsErrorResponse + JSON404 *ListPersonFactClaimsErrorResponseJSON + JSON503 *ListPersonFactClaimsErrorResponseJSON503 +} + +type ListPersonFactDecisionsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactDecisionsResponse + JSON400 *ListPersonFactDecisionsErrorResponse + JSON404 *ListPersonFactDecisionsErrorResponseJSON + JSON503 *ListPersonFactDecisionsErrorResponseJSON503 +} + +type ListPersonFactEvidenceResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactEvidenceResponse + JSON400 *ListPersonFactEvidenceErrorResponse + JSON404 *ListPersonFactEvidenceErrorResponseJSON + JSON503 *ListPersonFactEvidenceErrorResponseJSON503 +} + +type ListPersonFactEvidenceStatusEventsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactEvidenceStatusEventsResponse + JSON400 *ListPersonFactEvidenceStatusEventsErrorResponse + JSON404 *ListPersonFactEvidenceStatusEventsErrorResponseJSON + JSON503 *ListPersonFactEvidenceStatusEventsErrorResponseJSON503 +} + +type ListPersonFactPinsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactPinsResponse + JSON400 *ListPersonFactPinsErrorResponse + JSON404 *ListPersonFactPinsErrorResponseJSON + JSON503 *ListPersonFactPinsErrorResponseJSON503 +} + +type SetPersonFactPinResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *SetPersonFactPinResponse + JSON400 *SetPersonFactPinErrorResponse + JSON404 *SetPersonFactPinErrorResponseJSON + JSON409 *SetPersonFactPinErrorResponseJSON409 + JSON503 *SetPersonFactPinErrorResponseJSON503 +} + type SearchPersonFilesResp struct { HTTPResponse *http.Response Body []byte @@ -4288,6 +4405,15 @@ type SetPersonTrackingResp struct { JSON503 *SetPersonTrackingErrorResponseJSON503 } +type ListPersonFactTargetsResp struct { + HTTPResponse *http.Response + Body []byte + StatusCode int + JSON200 *ListPersonFactTargetsResponse + JSON400 *ListPersonFactTargetsErrorResponse + JSON503 *ListPersonFactTargetsErrorResponseJSON +} + type DecidePersonMergeCandidateResp200Headers struct { ETag string `header:"ETag"` } diff --git a/pkg/client/generated/types.go b/pkg/client/generated/types.go index fd2f6e452..72731258b 100644 --- a/pkg/client/generated/types.go +++ b/pkg/client/generated/types.go @@ -961,6 +961,33 @@ type CardDAVSyncRequest struct { Full *bool `json:"full,omitempty"` } +type Catalog struct { + Fingerprint string `json:"fingerprint" validate:"required"` + Targets []TargetDescriptor `json:"targets,omitempty" validate:"required"` + Version string `json:"version" validate:"required"` +} + +func (c Catalog) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(c.Fingerprint, "required"); err != nil { + errors = errors.Append("Fingerprint", err) + } + for i, item := range c.Targets { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Targets[%d]", i), err) + } + } + } + if err := typesValidator.Var(c.Version, "required"); err != nil { + errors = errors.Append("Version", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + type ChangedMessageJSON struct { AttachmentCount int64 `json:"attachment_count"` ContentChangedAt time.Time `json:"content_changed_at" validate:"required"` @@ -1022,6 +1049,15 @@ func (c ChangesResponse) Validate() error { return errors } +type ChoiceDescriptor struct { + Label string `json:"label" validate:"required"` + Value string `json:"value" validate:"required"` +} + +func (c ChoiceDescriptor) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(c)) +} + type CliAccountResponse struct { DisplayName string `json:"display_name" validate:"required"` Email string `json:"email" validate:"required"` @@ -1470,6 +1506,10 @@ func (c CommunicationServicesResponse) Validate() error { return errors } +type ConfidenceInputs struct { + ReportedScore int64 `json:"reported_score"` +} + type ContactState struct { CadenceDueAt *time.Time `json:"cadence_due_at,omitempty"` CadenceStatus string `json:"cadence_status" validate:"required"` @@ -3109,6 +3149,17 @@ func (e ExploreUnavailableAction) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(e)) } +type FieldDescriptor struct { + Cardinality string `json:"cardinality" validate:"required"` + Name string `json:"name" validate:"required"` + Required bool `json:"required"` + ValueType string `json:"value_type" validate:"required"` +} + +func (f FieldDescriptor) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(f)) +} + type FileGroupsHTTPRequest struct { Cursor *string `json:"cursor,omitempty"` FilenameQuery *string `json:"filename_query,omitempty"` @@ -5856,6 +5907,394 @@ func (p PersonDaysPage) Validate() error { return errors } +type PersonFactClaim struct { + ClaimKey string `json:"claim_key" validate:"required"` + Confidence ConfidenceInputs `json:"confidence"` + CreatedAt time.Time `json:"created_at" validate:"required"` + EvidenceIds []int64 `json:"evidence_ids,omitempty" validate:"required"` + GenerationID int64 `json:"generation_id"` + ID int64 `json:"id"` + NormalizedValue *string `json:"normalized_value,omitempty"` + Origin string `json:"origin" validate:"required"` + ProgramFingerprint string `json:"program_fingerprint" validate:"required"` + ProgramID string `json:"program_id" validate:"required"` + ProgramVersion string `json:"program_version" validate:"required"` + Relation string `json:"relation" validate:"required"` + SubmittedValue string `json:"submitted_value" validate:"required"` + Target TargetRef `json:"target"` + ValidFrom *time.Time `json:"valid_from,omitempty"` + ValidUntil *time.Time `json:"valid_until,omitempty"` + ValueFingerprint *string `json:"value_fingerprint,omitempty"` +} + +func (p PersonFactClaim) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.ClaimKey, "required"); err != nil { + errors = errors.Append("ClaimKey", err) + } + if v, ok := any(p.Confidence).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Confidence", err) + } + } + if err := typesValidator.Var(p.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(p.EvidenceIds, "required"); err != nil { + errors = errors.Append("EvidenceIds", err) + } + if err := typesValidator.Var(p.Origin, "required"); err != nil { + errors = errors.Append("Origin", err) + } + if err := typesValidator.Var(p.ProgramFingerprint, "required"); err != nil { + errors = errors.Append("ProgramFingerprint", err) + } + if err := typesValidator.Var(p.ProgramID, "required"); err != nil { + errors = errors.Append("ProgramID", err) + } + if err := typesValidator.Var(p.ProgramVersion, "required"); err != nil { + errors = errors.Append("ProgramVersion", err) + } + if err := typesValidator.Var(p.Relation, "required"); err != nil { + errors = errors.Append("Relation", err) + } + if err := typesValidator.Var(p.SubmittedValue, "required"); err != nil { + errors = errors.Append("SubmittedValue", err) + } + if v, ok := any(p.Target).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Target", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactClaimsResponse struct { + Claims []PersonFactClaim `json:"claims" validate:"required"` +} + +func (p PersonFactClaimsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Claims { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Claims[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactDecision struct { + Action string `json:"action" validate:"required"` + ClaimKey string `json:"claim_key" validate:"required"` + CompetingClaimKey *string `json:"competing_claim_key,omitempty"` + CreatedAt time.Time `json:"created_at" validate:"required"` + DecisionKey string `json:"decision_key" validate:"required"` + ID int64 `json:"id"` + Projection *ProjectionRef `json:"projection,omitempty"` + Reason string `json:"reason" validate:"required"` + ResolutionID int64 `json:"resolution_id"` + Score PersonFactScoreBreakdown `json:"score"` +} + +func (p PersonFactDecision) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.Action, "required"); err != nil { + errors = errors.Append("Action", err) + } + if err := typesValidator.Var(p.ClaimKey, "required"); err != nil { + errors = errors.Append("ClaimKey", err) + } + if err := typesValidator.Var(p.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(p.DecisionKey, "required"); err != nil { + errors = errors.Append("DecisionKey", err) + } + if p.Projection != nil { + if v, ok := any(p.Projection).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Projection", err) + } + } + } + if err := typesValidator.Var(p.Reason, "required"); err != nil { + errors = errors.Append("Reason", err) + } + if v, ok := any(p.Score).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Score", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactDecisionsResponse struct { + Decisions []PersonFactDecision `json:"decisions" validate:"required"` +} + +func (p PersonFactDecisionsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Decisions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Decisions[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactEvidence struct { + Authority string `json:"authority" validate:"required"` + ContentSha256 *string `json:"content_sha256,omitempty" validate:"required"` + CreatedAt time.Time `json:"created_at" validate:"required"` + Directness string `json:"directness" validate:"required"` + EventTime time.Time `json:"event_time" validate:"required"` + EvidenceKey string `json:"evidence_key" validate:"required"` + Excerpt *string `json:"excerpt,omitempty" validate:"required"` + ID int64 `json:"id"` + IdentityScore int64 `json:"identity_score"` + LatestStatus *PersonFactEvidenceStatusEvent `json:"latest_status,omitempty"` + RecordedTime time.Time `json:"recorded_time" validate:"required"` + SourceClass string `json:"source_class" validate:"required"` + SourceRef *string `json:"source_ref,omitempty" validate:"required"` + SourceURL *string `json:"source_url,omitempty" validate:"required"` + SourceVersion *string `json:"source_version,omitempty" validate:"required"` + SpanEnd *int64 `json:"span_end,omitempty"` + SpanStart *int64 `json:"span_start,omitempty"` + SubjectPersonID *int64 `json:"subject_person_id,omitempty"` + SubjectRef *string `json:"subject_ref,omitempty" validate:"required"` + Supported bool `json:"supported"` +} + +func (p PersonFactEvidence) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(p.Authority, "required"); err != nil { + errors = errors.Append("Authority", err) + } + if err := typesValidator.Var(p.ContentSha256, "required"); err != nil { + errors = errors.Append("ContentSha256", err) + } + if err := typesValidator.Var(p.CreatedAt, "required"); err != nil { + errors = errors.Append("CreatedAt", err) + } + if err := typesValidator.Var(p.Directness, "required"); err != nil { + errors = errors.Append("Directness", err) + } + if err := typesValidator.Var(p.EventTime, "required"); err != nil { + errors = errors.Append("EventTime", err) + } + if err := typesValidator.Var(p.EvidenceKey, "required"); err != nil { + errors = errors.Append("EvidenceKey", err) + } + if err := typesValidator.Var(p.Excerpt, "required"); err != nil { + errors = errors.Append("Excerpt", err) + } + if p.LatestStatus != nil { + if v, ok := any(p.LatestStatus).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("LatestStatus", err) + } + } + } + if err := typesValidator.Var(p.RecordedTime, "required"); err != nil { + errors = errors.Append("RecordedTime", err) + } + if err := typesValidator.Var(p.SourceClass, "required"); err != nil { + errors = errors.Append("SourceClass", err) + } + if err := typesValidator.Var(p.SourceRef, "required"); err != nil { + errors = errors.Append("SourceRef", err) + } + if err := typesValidator.Var(p.SourceURL, "required"); err != nil { + errors = errors.Append("SourceURL", err) + } + if err := typesValidator.Var(p.SourceVersion, "required"); err != nil { + errors = errors.Append("SourceVersion", err) + } + if err := typesValidator.Var(p.SubjectRef, "required"); err != nil { + errors = errors.Append("SubjectRef", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactEvidenceResponse struct { + Evidence []PersonFactEvidence `json:"evidence" validate:"required"` +} + +func (p PersonFactEvidenceResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Evidence { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Evidence[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactEvidenceStatusEvent struct { + CreatedAt time.Time `json:"created_at" validate:"required"` + EvidenceKey string `json:"evidence_key" validate:"required"` + GenerationID int64 `json:"generation_id"` + ID int64 `json:"id"` + Reason string `json:"reason" validate:"required"` + SourceVersion *string `json:"source_version,omitempty" validate:"required"` + Supported bool `json:"supported"` +} + +func (p PersonFactEvidenceStatusEvent) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + +type PersonFactEvidenceStatusEventsResponse struct { + Events []PersonFactEvidenceStatusEvent `json:"events" validate:"required"` +} + +func (p PersonFactEvidenceStatusEventsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Events { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Events[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactPinWrite struct { + Projections []ProjectionRef `json:"projections,omitempty" validate:"required"` + Resolutions []PersonFactResolutionResult `json:"resolutions,omitempty" validate:"required"` + State PinState `json:"state"` +} + +func (p PersonFactPinWrite) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Projections { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Projections[%d]", i), err) + } + } + } + for i, item := range p.Resolutions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Resolutions[%d]", i), err) + } + } + } + if v, ok := any(p.State).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("State", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactPinsResponse struct { + Pins []PinState `json:"pins,omitempty" validate:"required"` +} + +func (p PersonFactPinsResponse) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Pins { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Pins[%d]", i), err) + } + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactResolutionResult struct { + Decisions []PersonFactDecision `json:"decisions,omitempty" validate:"required"` + ID int64 `json:"id"` + InputFingerprint string `json:"input_fingerprint" validate:"required"` + Projections []ProjectionRef `json:"projections,omitempty" validate:"required"` + ResolvedAt time.Time `json:"resolved_at" validate:"required"` + ResolverVersion string `json:"resolver_version" validate:"required"` + Target TargetRef `json:"target"` +} + +func (p PersonFactResolutionResult) Validate() error { + var errors runtime.ValidationErrors + for i, item := range p.Decisions { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Decisions[%d]", i), err) + } + } + } + if err := typesValidator.Var(p.InputFingerprint, "required"); err != nil { + errors = errors.Append("InputFingerprint", err) + } + for i, item := range p.Projections { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Projections[%d]", i), err) + } + } + } + if err := typesValidator.Var(p.ResolvedAt, "required"); err != nil { + errors = errors.Append("ResolvedAt", err) + } + if err := typesValidator.Var(p.ResolverVersion, "required"); err != nil { + errors = errors.Append("ResolverVersion", err) + } + if v, ok := any(p.Target).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Target", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + +type PersonFactScoreBreakdown struct { + Authority int64 `json:"authority"` + Confidence int64 `json:"confidence"` + Corroboration int64 `json:"corroboration"` + Directness int64 `json:"directness"` + Freshness int64 `json:"freshness"` + SourceClass int64 `json:"source_class"` + Total int64 `json:"total"` +} + type PersonFileProvenance struct { Directions []PersonFileProvenanceDirections `json:"directions,omitempty" validate:"required"` ParticipantIds []int64 `json:"participant_ids,omitempty" validate:"required"` @@ -7006,6 +7445,26 @@ func (p PersonTracking) Validate() error { return runtime.ConvertValidatorError(typesValidator.Struct(p)) } +type PinState struct { + Actor *string `json:"actor,omitempty"` + EventID *int64 `json:"event_id,omitempty"` + Pinned bool `json:"pinned"` + Target TargetRef `json:"target"` +} + +func (p PinState) Validate() error { + var errors runtime.ValidationErrors + if v, ok := any(p.Target).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append("Target", err) + } + } + if len(errors) == 0 { + return nil + } + return errors +} + type PingInfo struct { Ok bool `json:"ok"` Pid *int64 `json:"pid,omitempty"` @@ -7018,6 +7477,15 @@ type Progress struct { Total int64 `json:"total"` } +type ProjectionRef struct { + Kind string `json:"kind" validate:"required"` + RowID int64 `json:"row_id"` +} + +func (p ProjectionRef) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(p)) +} + type Provenance struct { Directions []ProvenanceDirections `json:"directions,omitempty" validate:"required"` ParticipantIds []int64 `json:"participant_ids,omitempty" validate:"required"` @@ -7933,6 +8401,10 @@ func (s SetPersonAttributeRequest) Validate() error { return errors } +type SetPersonFactPinRequest struct { + Pinned bool `json:"pinned"` +} + type Setting struct { Group SettingGroup `json:"group" validate:"required"` Key string `json:"key" validate:"required"` @@ -8661,6 +9133,78 @@ func (s SyncRunStatus) Validate() error { return errors } +type TargetDescriptor struct { + Cardinality string `json:"cardinality" validate:"required"` + Choices []ChoiceDescriptor `json:"choices,omitempty" validate:"required"` + Description string `json:"description" validate:"required"` + Fields []FieldDescriptor `json:"fields,omitempty" validate:"required"` + Key string `json:"key" validate:"required"` + Kind string `json:"kind" validate:"required"` + MaxLength *int64 `json:"max_length,omitempty"` + RecordTarget *string `json:"record_target,omitempty"` + Revision string `json:"revision" validate:"required"` + Sensitive bool `json:"sensitive"` + Slug string `json:"slug" validate:"required"` + UniversalID string `json:"universal_id" validate:"required"` + ValueType string `json:"value_type" validate:"required"` +} + +func (t TargetDescriptor) Validate() error { + var errors runtime.ValidationErrors + if err := typesValidator.Var(t.Cardinality, "required"); err != nil { + errors = errors.Append("Cardinality", err) + } + for i, item := range t.Choices { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Choices[%d]", i), err) + } + } + } + if err := typesValidator.Var(t.Description, "required"); err != nil { + errors = errors.Append("Description", err) + } + for i, item := range t.Fields { + if v, ok := any(item).(runtime.Validator); ok { + if err := v.Validate(); err != nil { + errors = errors.Append(fmt.Sprintf("Fields[%d]", i), err) + } + } + } + if err := typesValidator.Var(t.Key, "required"); err != nil { + errors = errors.Append("Key", err) + } + if err := typesValidator.Var(t.Kind, "required"); err != nil { + errors = errors.Append("Kind", err) + } + if err := typesValidator.Var(t.Revision, "required"); err != nil { + errors = errors.Append("Revision", err) + } + if err := typesValidator.Var(t.Slug, "required"); err != nil { + errors = errors.Append("Slug", err) + } + if err := typesValidator.Var(t.UniversalID, "required"); err != nil { + errors = errors.Append("UniversalID", err) + } + if err := typesValidator.Var(t.ValueType, "required"); err != nil { + errors = errors.Append("ValueType", err) + } + if len(errors) == 0 { + return nil + } + return errors +} + +type TargetRef struct { + Key string `json:"key" validate:"required"` + Kind string `json:"kind" validate:"required"` + Revision string `json:"revision" validate:"required"` +} + +func (t TargetRef) Validate() error { + return runtime.ConvertValidatorError(typesValidator.Struct(t)) +} + type TaskIntegrationStatusResponse struct { // Message Credential-free status explanation Message string `json:"message" validate:"required"` diff --git a/pkg/client/openapi.yaml b/pkg/client/openapi.yaml index 67c23a549..2a83b1efd 100644 --- a/pkg/client/openapi.yaml +++ b/pkg/client/openapi.yaml @@ -1183,6 +1183,22 @@ components: full: type: boolean type: object + Catalog: + properties: + fingerprint: + type: string + targets: + items: + $ref: "#/components/schemas/TargetDescriptor" + nullable: true + type: array + version: + type: string + required: + - version + - fingerprint + - targets + type: object ChangedMessageJSON: properties: attachment_count: @@ -1267,6 +1283,16 @@ components: - server_time - complete_through type: object + ChoiceDescriptor: + properties: + label: + type: string + value: + type: string + required: + - value + - label + type: object CliAccountResponse: properties: display_name: @@ -1737,6 +1763,14 @@ components: required: - services type: object + ConfidenceInputs: + properties: + reported_score: + format: int64 + type: integer + required: + - reported_score + type: object ContactState: properties: cadence_due_at: @@ -3534,6 +3568,22 @@ components: - action - reason type: object + FieldDescriptor: + properties: + cardinality: + type: string + name: + type: string + required: + type: boolean + value_type: + type: string + required: + - name + - value_type + - cardinality + - required + type: object FileGroupsHTTPRequest: additionalProperties: false properties: @@ -6393,6 +6443,341 @@ components: - days - total_count type: object + PersonFactClaim: + properties: + claim_key: + type: string + confidence: + $ref: "#/components/schemas/ConfidenceInputs" + created_at: + format: date-time + type: string + evidence_ids: + items: + format: int64 + type: integer + nullable: true + type: array + generation_id: + format: int64 + type: integer + id: + format: int64 + type: integer + normalized_value: + nullable: true + type: string + origin: + type: string + program_fingerprint: + type: string + program_id: + type: string + program_version: + type: string + relation: + type: string + submitted_value: + type: string + target: + $ref: "#/components/schemas/TargetRef" + valid_from: + format: date-time + nullable: true + type: string + valid_until: + format: date-time + nullable: true + type: string + value_fingerprint: + nullable: true + type: string + required: + - id + - generation_id + - claim_key + - program_id + - program_version + - program_fingerprint + - target + - relation + - submitted_value + - evidence_ids + - origin + - confidence + - created_at + type: object + PersonFactClaimsResponse: + properties: + claims: + items: + $ref: "#/components/schemas/PersonFactClaim" + type: array + required: + - claims + type: object + PersonFactDecision: + properties: + action: + type: string + claim_key: + type: string + competing_claim_key: + type: string + created_at: + format: date-time + type: string + decision_key: + type: string + id: + format: int64 + type: integer + projection: + $ref: "#/components/schemas/ProjectionRef" + reason: + type: string + resolution_id: + format: int64 + type: integer + score: + $ref: "#/components/schemas/PersonFactScoreBreakdown" + required: + - id + - resolution_id + - decision_key + - claim_key + - action + - reason + - score + - created_at + type: object + PersonFactDecisionsResponse: + properties: + decisions: + items: + $ref: "#/components/schemas/PersonFactDecision" + type: array + required: + - decisions + type: object + PersonFactEvidence: + properties: + authority: + type: string + content_sha256: + nullable: true + type: string + created_at: + format: date-time + type: string + directness: + type: string + event_time: + format: date-time + type: string + evidence_key: + type: string + excerpt: + nullable: true + type: string + id: + format: int64 + type: integer + identity_score: + format: int64 + type: integer + latest_status: + $ref: "#/components/schemas/PersonFactEvidenceStatusEvent" + recorded_time: + format: date-time + type: string + source_class: + type: string + source_ref: + nullable: true + type: string + source_url: + nullable: true + type: string + source_version: + nullable: true + type: string + span_end: + format: int64 + nullable: true + type: integer + span_start: + format: int64 + nullable: true + type: integer + subject_person_id: + format: int64 + nullable: true + type: integer + subject_ref: + nullable: true + type: string + supported: + type: boolean + required: + - id + - evidence_key + - source_class + - directness + - authority + - source_ref + - source_url + - subject_ref + - excerpt + - content_sha256 + - source_version + - event_time + - recorded_time + - identity_score + - supported + - created_at + type: object + PersonFactEvidenceResponse: + properties: + evidence: + items: + $ref: "#/components/schemas/PersonFactEvidence" + type: array + required: + - evidence + type: object + PersonFactEvidenceStatusEvent: + properties: + created_at: + format: date-time + type: string + evidence_key: + type: string + generation_id: + format: int64 + type: integer + id: + format: int64 + type: integer + reason: + type: string + source_version: + nullable: true + type: string + supported: + type: boolean + required: + - id + - generation_id + - evidence_key + - source_version + - supported + - reason + - created_at + type: object + PersonFactEvidenceStatusEventsResponse: + properties: + events: + items: + $ref: "#/components/schemas/PersonFactEvidenceStatusEvent" + type: array + required: + - events + type: object + PersonFactPinWrite: + properties: + projections: + items: + $ref: "#/components/schemas/ProjectionRef" + nullable: true + type: array + resolutions: + items: + $ref: "#/components/schemas/PersonFactResolutionResult" + nullable: true + type: array + state: + $ref: "#/components/schemas/PinState" + required: + - state + - resolutions + - projections + type: object + PersonFactPinsResponse: + properties: + pins: + items: + $ref: "#/components/schemas/PinState" + nullable: true + type: array + required: + - pins + type: object + PersonFactResolutionResult: + properties: + decisions: + items: + $ref: "#/components/schemas/PersonFactDecision" + nullable: true + type: array + id: + format: int64 + type: integer + input_fingerprint: + type: string + projections: + items: + $ref: "#/components/schemas/ProjectionRef" + nullable: true + type: array + resolved_at: + format: date-time + type: string + resolver_version: + type: string + target: + $ref: "#/components/schemas/TargetRef" + required: + - id + - target + - resolver_version + - input_fingerprint + - resolved_at + - decisions + - projections + type: object + PersonFactScoreBreakdown: + properties: + authority: + format: int64 + type: integer + confidence: + format: int64 + type: integer + corroboration: + format: int64 + type: integer + directness: + format: int64 + type: integer + freshness: + format: int64 + type: integer + source_class: + format: int64 + type: integer + total: + format: int64 + type: integer + required: + - source_class + - directness + - authority + - confidence + - freshness + - corroboration + - total + type: object PersonFileProvenance: properties: directions: @@ -7521,6 +7906,21 @@ components: - tracked - tracked_at type: object + PinState: + properties: + actor: + type: string + event_id: + format: int64 + type: integer + pinned: + type: boolean + target: + $ref: "#/components/schemas/TargetRef" + required: + - target + - pinned + type: object PingInfo: properties: ok: @@ -7547,6 +7947,17 @@ components: - done - total type: object + ProjectionRef: + properties: + kind: + type: string + row_id: + format: int64 + type: integer + required: + - kind + - row_id + type: object Provenance: properties: directions: @@ -8501,6 +8912,14 @@ components: required: - value type: object + SetPersonFactPinRequest: + additionalProperties: false + properties: + pinned: + type: boolean + required: + - pinned + type: object Setting: properties: group: @@ -9209,6 +9628,67 @@ components: - cursor_before - cursor_after type: object + TargetDescriptor: + properties: + cardinality: + type: string + choices: + items: + $ref: "#/components/schemas/ChoiceDescriptor" + nullable: true + type: array + description: + type: string + fields: + items: + $ref: "#/components/schemas/FieldDescriptor" + nullable: true + type: array + key: + type: string + kind: + type: string + max_length: + format: int64 + type: integer + record_target: + type: string + revision: + type: string + sensitive: + type: boolean + slug: + type: string + universal_id: + type: string + value_type: + type: string + required: + - kind + - key + - revision + - universal_id + - slug + - description + - value_type + - cardinality + - choices + - fields + - sensitive + type: object + TargetRef: + properties: + key: + type: string + kind: + type: string + revision: + type: string + required: + - kind + - key + - revision + type: object TaskIntegrationStatusResponse: properties: message: @@ -9893,7 +10373,7 @@ components: type: apiKey info: title: msgvault API - version: 2.10.0 + version: 2.11.0 openapi: 3.0.3 paths: /api/ping: @@ -17640,6 +18120,388 @@ paths: summary: List a person's employment history tags: - API + /api/v1/people/{id}/fact-claims: + get: + operationId: listPersonFactClaims + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> + in: query + name: target + schema: + type: string + - description: Maximum rows to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based row offset + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactClaimsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List immutable person fact claims + tags: + - API + /api/v1/people/{id}/fact-decisions: + get: + operationId: listPersonFactDecisions + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> + in: query + name: target + schema: + type: string + - description: Maximum rows to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based row offset + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactDecisionsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List immutable person fact decisions + tags: + - API + /api/v1/people/{id}/fact-evidence: + get: + operationId: listPersonFactEvidence + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Exact target as kind:key:sha256:<64 lowercase hex characters> + in: query + name: target + schema: + type: string + - description: Maximum rows to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based row offset + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactEvidenceResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List immutable person fact evidence + tags: + - API + /api/v1/people/{id}/fact-evidence-status-events: + get: + operationId: listPersonFactEvidenceStatusEvents + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Restrict to one immutable evidence key + in: query + name: evidence_key + schema: + type: string + - description: Restrict to supported or unsupported events + in: query + name: supported + schema: + type: boolean + - description: Maximum events to return (default 50, max 200) + in: query + name: limit + schema: + format: int64 + type: integer + - description: Zero-based event offset + in: query + name: offset + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactEvidenceStatusEventsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List person fact evidence status history + tags: + - API + /api/v1/people/{id}/fact-pins: + get: + operationId: listPersonFactPins + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactPinsResponse" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List effective person fact pins + tags: + - API + /api/v1/people/{id}/fact-pins/{kind}/{key}: + put: + operationId: setPersonFactPin + parameters: + - description: Durable person ID + in: path + name: id + required: true + schema: + format: int64 + type: integer + - description: Closed person fact target kind + in: path + name: kind + required: true + schema: + enum: + - attribute + - employment + type: string + - description: Exact person fact target key + in: path + name: key + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: "#/components/schemas/SetPersonFactPinRequest" + required: true + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/PersonFactPinWrite" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "404": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "409": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: Replace an effective person fact pin + tags: + - API /api/v1/people/{id}/files/search: post: operationId: searchPersonFiles @@ -18404,6 +19266,45 @@ paths: summary: Replace a person's tracking state tags: - API + /api/v1/person-fact-targets: + get: + operationId: listPersonFactTargets + parameters: + - description: Include sensitive targets + in: query + name: include_sensitive + schema: + type: boolean + responses: + "200": + content: + application/json: + schema: + $ref: "#/components/schemas/Catalog" + description: OK + "400": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + "503": + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + default: + content: + application/json: + schema: + $ref: "#/components/schemas/ErrorResponse" + description: Error + security: + - apiKey: [] + summary: List eligible automatic person fact targets + tags: + - API /api/v1/person-merge-candidates/{candidate_id}/decision: post: operationId: decidePersonMergeCandidate diff --git a/web/src/lib/api/generated/schema.d.ts b/web/src/lib/api/generated/schema.d.ts index 9aaf9574b..917756baa 100644 --- a/web/src/lib/api/generated/schema.d.ts +++ b/web/src/lib/api/generated/schema.d.ts @@ -2242,6 +2242,108 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/people/{id}/fact-claims": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List immutable person fact claims */ + get: operations["listPersonFactClaims"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/fact-decisions": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List immutable person fact decisions */ + get: operations["listPersonFactDecisions"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/fact-evidence": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List immutable person fact evidence */ + get: operations["listPersonFactEvidence"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/fact-evidence-status-events": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List person fact evidence status history */ + get: operations["listPersonFactEvidenceStatusEvents"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/fact-pins": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List effective person fact pins */ + get: operations["listPersonFactPins"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/people/{id}/fact-pins/{kind}/{key}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Replace an effective person fact pin */ + put: operations["setPersonFactPin"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/people/{id}/files/search": { parameters: { query?: never; @@ -2426,6 +2528,23 @@ export interface paths { patch?: never; trace?: never; }; + "/api/v1/person-fact-targets": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** List eligible automatic person fact targets */ + get: operations["listPersonFactTargets"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/person-merge-candidates/{candidate_id}/decision": { parameters: { query?: never; @@ -3600,6 +3719,13 @@ export interface components { CardDAVSyncRequest: { full?: boolean; }; + Catalog: { + fingerprint: string; + targets: components["schemas"]["TargetDescriptor"][] | null; + version: string; + } & { + [key: string]: unknown; + }; ChangedMessageJSON: { /** Format: int64 */ attachment_count: number; @@ -3648,6 +3774,12 @@ export interface components { } & { [key: string]: unknown; }; + ChoiceDescriptor: { + label: string; + value: string; + } & { + [key: string]: unknown; + }; CliAccountResponse: { display_name: string; email: string; @@ -3871,6 +4003,12 @@ export interface components { } & { [key: string]: unknown; }; + ConfidenceInputs: { + /** Format: int64 */ + reported_score: number; + } & { + [key: string]: unknown; + }; ContactState: { /** Format: date-time */ cadence_due_at?: string; @@ -4643,6 +4781,14 @@ export interface components { } & { [key: string]: unknown; }; + FieldDescriptor: { + cardinality: string; + name: string; + required: boolean; + value_type: string; + } & { + [key: string]: unknown; + }; FileGroupsHTTPRequest: { cursor?: string; filename_query?: string; @@ -5875,6 +6021,158 @@ export interface components { } & { [key: string]: unknown; }; + PersonFactClaim: { + claim_key: string; + confidence: components["schemas"]["ConfidenceInputs"]; + /** Format: date-time */ + created_at: string; + evidence_ids: number[] | null; + /** Format: int64 */ + generation_id: number; + /** Format: int64 */ + id: number; + normalized_value?: string | null; + origin: string; + program_fingerprint: string; + program_id: string; + program_version: string; + relation: string; + submitted_value: string; + target: components["schemas"]["TargetRef"]; + /** Format: date-time */ + valid_from?: string | null; + /** Format: date-time */ + valid_until?: string | null; + value_fingerprint?: string | null; + } & { + [key: string]: unknown; + }; + PersonFactClaimsResponse: { + claims: components["schemas"]["PersonFactClaim"][]; + } & { + [key: string]: unknown; + }; + PersonFactDecision: { + action: string; + claim_key: string; + competing_claim_key?: string; + /** Format: date-time */ + created_at: string; + decision_key: string; + /** Format: int64 */ + id: number; + projection?: components["schemas"]["ProjectionRef"]; + reason: string; + /** Format: int64 */ + resolution_id: number; + score: components["schemas"]["PersonFactScoreBreakdown"]; + } & { + [key: string]: unknown; + }; + PersonFactDecisionsResponse: { + decisions: components["schemas"]["PersonFactDecision"][]; + } & { + [key: string]: unknown; + }; + PersonFactEvidence: { + authority: string; + content_sha256: string | null; + /** Format: date-time */ + created_at: string; + directness: string; + /** Format: date-time */ + event_time: string; + evidence_key: string; + excerpt: string | null; + /** Format: int64 */ + id: number; + /** Format: int64 */ + identity_score: number; + latest_status?: components["schemas"]["PersonFactEvidenceStatusEvent"]; + /** Format: date-time */ + recorded_time: string; + source_class: string; + source_ref: string | null; + source_url: string | null; + source_version: string | null; + /** Format: int64 */ + span_end?: number | null; + /** Format: int64 */ + span_start?: number | null; + /** Format: int64 */ + subject_person_id?: number | null; + subject_ref: string | null; + supported: boolean; + } & { + [key: string]: unknown; + }; + PersonFactEvidenceResponse: { + evidence: components["schemas"]["PersonFactEvidence"][]; + } & { + [key: string]: unknown; + }; + PersonFactEvidenceStatusEvent: { + /** Format: date-time */ + created_at: string; + evidence_key: string; + /** Format: int64 */ + generation_id: number; + /** Format: int64 */ + id: number; + reason: string; + source_version: string | null; + supported: boolean; + } & { + [key: string]: unknown; + }; + PersonFactEvidenceStatusEventsResponse: { + events: components["schemas"]["PersonFactEvidenceStatusEvent"][]; + } & { + [key: string]: unknown; + }; + PersonFactPinWrite: { + projections: components["schemas"]["ProjectionRef"][] | null; + resolutions: components["schemas"]["PersonFactResolutionResult"][] | null; + state: components["schemas"]["PinState"]; + } & { + [key: string]: unknown; + }; + PersonFactPinsResponse: { + pins: components["schemas"]["PinState"][] | null; + } & { + [key: string]: unknown; + }; + PersonFactResolutionResult: { + decisions: components["schemas"]["PersonFactDecision"][] | null; + /** Format: int64 */ + id: number; + input_fingerprint: string; + projections: components["schemas"]["ProjectionRef"][] | null; + /** Format: date-time */ + resolved_at: string; + resolver_version: string; + target: components["schemas"]["TargetRef"]; + } & { + [key: string]: unknown; + }; + PersonFactScoreBreakdown: { + /** Format: int64 */ + authority: number; + /** Format: int64 */ + confidence: number; + /** Format: int64 */ + corroboration: number; + /** Format: int64 */ + directness: number; + /** Format: int64 */ + freshness: number; + /** Format: int64 */ + source_class: number; + /** Format: int64 */ + total: number; + } & { + [key: string]: unknown; + }; PersonFileProvenance: { directions: ("from_person" | "to_person" | "group")[] | null; participant_ids: number[] | null; @@ -6380,6 +6678,15 @@ export interface components { } & { [key: string]: unknown; }; + PinState: { + actor?: string; + /** Format: int64 */ + event_id?: number; + pinned: boolean; + target: components["schemas"]["TargetRef"]; + } & { + [key: string]: unknown; + }; PingInfo: { ok: boolean; /** Format: int64 */ @@ -6397,6 +6704,13 @@ export interface components { } & { [key: string]: unknown; }; + ProjectionRef: { + kind: string; + /** Format: int64 */ + row_id: number; + } & { + [key: string]: unknown; + }; Provenance: { directions: ("from_person" | "to_person" | "group")[] | null; participant_ids: number[] | null; @@ -6823,6 +7137,9 @@ export interface components { source_ref?: string; value: components["schemas"]["AttributeValue"]; }; + SetPersonFactPinRequest: { + pinned: boolean; + }; Setting: { /** @enum {string} */ group: "browser" | "server" | "archive" | "search" | "sources" | "integrations"; @@ -7123,6 +7440,31 @@ export interface components { } & { [key: string]: unknown; }; + TargetDescriptor: { + cardinality: string; + choices: components["schemas"]["ChoiceDescriptor"][] | null; + description: string; + fields: components["schemas"]["FieldDescriptor"][] | null; + key: string; + kind: string; + /** Format: int64 */ + max_length?: number; + record_target?: string; + revision: string; + sensitive: boolean; + slug: string; + universal_id: string; + value_type: string; + } & { + [key: string]: unknown; + }; + TargetRef: { + key: string; + kind: string; + revision: string; + } & { + [key: string]: unknown; + }; TaskIntegrationStatusResponse: { /** @description Credential-free status explanation */ message: string; @@ -16257,9 +16599,16 @@ export interface operations { }; }; }; - searchPersonFiles: { + listPersonFactClaims: { parameters: { - query?: never; + query?: { + /** @description Exact target as kind:key:sha256:<64 lowercase hex characters> */ + target?: string; + /** @description Maximum rows to return (default 50, max 200) */ + limit?: number; + /** @description Zero-based row offset */ + offset?: number; + }; header?: never; path: { /** @description Durable person ID */ @@ -16267,11 +16616,7 @@ export interface operations { }; cookie?: never; }; - requestBody: { - content: { - "application/json": components["schemas"]["PersonFileSearchHTTPRequest"]; - }; - }; + requestBody?: never; responses: { /** @description OK */ 200: { @@ -16279,7 +16624,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PersonFileSearchHTTPResponse"]; + "application/json": components["schemas"]["PersonFactClaimsResponse"]; }; }; /** @description Error */ @@ -16301,7 +16646,7 @@ export interface operations { }; }; /** @description Error */ - 409: { + 503: { headers: { [name: string]: unknown; }; @@ -16310,7 +16655,7 @@ export interface operations { }; }; /** @description Error */ - 422: { + default: { headers: { [name: string]: unknown; }; @@ -16318,19 +16663,417 @@ export interface operations { "application/json": components["schemas"]["ErrorResponse"]; }; }; - /** @description Service Unavailable */ - 503: { - headers: { - [name: string]: unknown; - }; - content: { - "application/json": components["schemas"]["ExploreCacheUnavailableResponse"] | components["schemas"]["ErrorResponse"]; - }; - }; - /** @description Error */ - default: { - headers: { - [name: string]: unknown; + }; + }; + listPersonFactDecisions: { + parameters: { + query?: { + /** @description Exact target as kind:key:sha256:<64 lowercase hex characters> */ + target?: string; + /** @description Maximum rows to return (default 50, max 200) */ + limit?: number; + /** @description Zero-based row offset */ + offset?: number; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFactDecisionsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listPersonFactEvidence: { + parameters: { + query?: { + /** @description Exact target as kind:key:sha256:<64 lowercase hex characters> */ + target?: string; + /** @description Maximum rows to return (default 50, max 200) */ + limit?: number; + /** @description Zero-based row offset */ + offset?: number; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFactEvidenceResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listPersonFactEvidenceStatusEvents: { + parameters: { + query?: { + /** @description Restrict to one immutable evidence key */ + evidence_key?: string; + /** @description Restrict to supported or unsupported events */ + supported?: boolean; + /** @description Maximum events to return (default 50, max 200) */ + limit?: number; + /** @description Zero-based event offset */ + offset?: number; + }; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFactEvidenceStatusEventsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + listPersonFactPins: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFactPinsResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + setPersonFactPin: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + /** @description Closed person fact target kind */ + kind: "attribute" | "employment"; + /** @description Exact person fact target key */ + key: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["SetPersonFactPinRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFactPinWrite"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; + searchPersonFiles: { + parameters: { + query?: never; + header?: never; + path: { + /** @description Durable person ID */ + id: number; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["PersonFileSearchHTTPRequest"]; + }; + }; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["PersonFileSearchHTTPResponse"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 404: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 409: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Service Unavailable */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ExploreCacheUnavailableResponse"] | components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; }; content: { "application/json": components["schemas"]["ErrorResponse"]; @@ -17132,6 +17875,56 @@ export interface operations { }; }; }; + listPersonFactTargets: { + parameters: { + query?: { + /** @description Include sensitive targets */ + include_sensitive?: boolean; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Catalog"]; + }; + }; + /** @description Error */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + 503: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + /** @description Error */ + default: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ErrorResponse"]; + }; + }; + }; + }; decidePersonMergeCandidate: { parameters: { query?: never;