From 6455362f676d599b30d6f290b80e24937cd2eda8 Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Thu, 6 Aug 2026 13:11:07 -0700 Subject: [PATCH 01/10] feat(personhog): add FoldPersonDocument leader RPC The merge saga's document write: fold sealed source snapshots into the target person's document under the owner's per-person lock and produce the folded result to the changelog. Properties fold target-wins with snapshot order as precedence, then the merge event's $set/$set_once; created_at folds to the min, is_identified to true, and the version is a max-merge over the target's floor and every sealed version plus one, so re-applying a fold changes no content and only bumps the version (safe under at-least-once delivery). An oversized fold trims instead of rejecting: a rejection would wedge the saga's re-drive loop. Routed to the owning leader like every person write; RouterClient grows the fold wrapper the saga runner will call. No caller yet: the merge saga wires it up next. --- .../personhog/service/v1/service_pb2.py | 4 +- .../personhog/service/v1/service_pb2_grpc.py | 47 +++ .../personhog/types/v1/person_pb2.py | 14 +- .../personhog/types/v1/person_pb2.pyi | 32 ++ proto/personhog/leader/v1/leader.proto | 19 ++ proto/personhog/service/v1/service.proto | 1 + proto/personhog/types/v1/person.proto | 36 +++ rust/personhog-common/src/client.rs | 23 +- rust/personhog-leader/src/service.rs | 230 +++++++++++++- .../tests/fence_integration.rs | 286 +++++++++++++++++- rust/personhog-router/src/proxy.rs | 7 + rust/personhog-router/tests/common/mod.rs | 9 + .../tests/group_type_resolver.rs | 8 + 13 files changed, 701 insertions(+), 15 deletions(-) diff --git a/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2.py b/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2.py index 7d387f217cde..97f43c00680d 100644 --- a/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2.py +++ b/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2.py @@ -20,7 +20,7 @@ ) DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b"\n\"personhog/service/v1/service.proto\x12\x14personhog.service.v1\x1a\x1fpersonhog/types/v1/person.proto\x1a\x1epersonhog/types/v1/group.proto\x1a\x1fpersonhog/types/v1/cohort.proto\x1a%personhog/types/v1/feature_flag.proto2\xce)\n\x10PersonHogService\x12X\n\tGetPerson\x12$.personhog.types.v1.GetPersonRequest\x1a%.personhog.types.v1.GetPersonResponse\x12X\n\nGetPersons\x12%.personhog.types.v1.GetPersonsRequest\x1a#.personhog.types.v1.PersonsResponse\x12d\n\x0fGetPersonByUuid\x12*.personhog.types.v1.GetPersonByUuidRequest\x1a%.personhog.types.v1.GetPersonResponse\x12f\n\x11GetPersonsByUuids\x12,.personhog.types.v1.GetPersonsByUuidsRequest\x1a#.personhog.types.v1.PersonsResponse\x12p\n\x15GetPersonByDistinctId\x120.personhog.types.v1.GetPersonByDistinctIdRequest\x1a%.personhog.types.v1.GetPersonResponse\x12\x91\x01\n\x1dGetPersonsByDistinctIdsInTeam\x128.personhog.types.v1.GetPersonsByDistinctIdsInTeamRequest\x1a6.personhog.types.v1.PersonsByDistinctIdsInTeamResponse\x12\x7f\n\x17GetPersonsByDistinctIds\x122.personhog.types.v1.GetPersonsByDistinctIdsRequest\x1a0.personhog.types.v1.PersonsByDistinctIdsResponse\x12\x82\x01\n\x17GetDistinctIdsForPerson\x122.personhog.types.v1.GetDistinctIdsForPersonRequest\x1a3.personhog.types.v1.GetDistinctIdsForPersonResponse\x12\x85\x01\n\x18GetDistinctIdsForPersons\x123.personhog.types.v1.GetDistinctIdsForPersonsRequest\x1a4.personhog.types.v1.GetDistinctIdsForPersonsResponse\x12\x88\x01\n\x19GetHashKeyOverrideContext\x124.personhog.types.v1.GetHashKeyOverrideContextRequest\x1a5.personhog.types.v1.GetHashKeyOverrideContextResponse\x12\x7f\n\x16UpsertHashKeyOverrides\x121.personhog.types.v1.UpsertHashKeyOverridesRequest\x1a2.personhog.types.v1.UpsertHashKeyOverridesResponse\x12\x94\x01\n\x1dDeleteHashKeyOverridesByTeams\x128.personhog.types.v1.DeleteHashKeyOverridesByTeamsRequest\x1a9.personhog.types.v1.DeleteHashKeyOverridesByTeamsResponse\x12w\n\x15CheckCohortMembership\x120.personhog.types.v1.CheckCohortMembershipRequest\x1a,.personhog.types.v1.CohortMembershipResponse\x12s\n\x12CountCohortMembers\x12-.personhog.types.v1.CountCohortMembersRequest\x1a..personhog.types.v1.CountCohortMembersResponse\x12s\n\x12DeleteCohortMember\x12-.personhog.types.v1.DeleteCohortMemberRequest\x1a..personhog.types.v1.DeleteCohortMemberResponse\x12\x82\x01\n\x17DeleteCohortMembersBulk\x122.personhog.types.v1.DeleteCohortMembersBulkRequest\x1a3.personhog.types.v1.DeleteCohortMembersBulkResponse\x12v\n\x13InsertCohortMembers\x12..personhog.types.v1.InsertCohortMembersRequest\x1a/.personhog.types.v1.InsertCohortMembersResponse\x12v\n\x13ListCohortMemberIds\x12..personhog.types.v1.ListCohortMemberIdsRequest\x1a/.personhog.types.v1.ListCohortMemberIdsResponse\x12U\n\x08GetGroup\x12#.personhog.types.v1.GetGroupRequest\x1a$.personhog.types.v1.GetGroupResponse\x12U\n\tGetGroups\x12$.personhog.types.v1.GetGroupsRequest\x1a\".personhog.types.v1.GroupsResponse\x12g\n\x0eGetGroupsBatch\x12).personhog.types.v1.GetGroupsBatchRequest\x1a*.personhog.types.v1.GetGroupsBatchResponse\x12[\n\nListGroups\x12%.personhog.types.v1.ListGroupsRequest\x1a&.personhog.types.v1.ListGroupsResponse\x12\x86\x01\n\x1cGetGroupTypeMappingsByTeamId\x127.personhog.types.v1.GetGroupTypeMappingsByTeamIdRequest\x1a-.personhog.types.v1.GroupTypeMappingsResponse\x12\x8d\x01\n\x1dGetGroupTypeMappingsByTeamIds\x128.personhog.types.v1.GetGroupTypeMappingsByTeamIdsRequest\x1a2.personhog.types.v1.GroupTypeMappingsBatchResponse\x12\x8c\x01\n\x1fGetGroupTypeMappingsByProjectId\x12:.personhog.types.v1.GetGroupTypeMappingsByProjectIdRequest\x1a-.personhog.types.v1.GroupTypeMappingsResponse\x12\x93\x01\n GetGroupTypeMappingsByProjectIds\x12;.personhog.types.v1.GetGroupTypeMappingsByProjectIdsRequest\x1a2.personhog.types.v1.GroupTypeMappingsBatchResponse\x12\x9d\x01\n GetGroupTypeMappingByDashboardId\x12;.personhog.types.v1.GetGroupTypeMappingByDashboardIdRequest\x1a<.personhog.types.v1.GetGroupTypeMappingByDashboardIdResponse\x12\x7f\n\x16CountGroupTypeMappings\x121.personhog.types.v1.CountGroupTypeMappingsRequest\x1a2.personhog.types.v1.CountGroupTypeMappingsResponse\x12^\n\x0bCreateGroup\x12&.personhog.types.v1.CreateGroupRequest\x1a'.personhog.types.v1.CreateGroupResponse\x12^\n\x0bUpdateGroup\x12&.personhog.types.v1.UpdateGroupRequest\x1a'.personhog.types.v1.UpdateGroupResponse\x12\x85\x01\n\x18DeleteGroupsBatchForTeam\x123.personhog.types.v1.DeleteGroupsBatchForTeamRequest\x1a4.personhog.types.v1.DeleteGroupsBatchForTeamResponse\x12\x7f\n\x16UpdateGroupTypeMapping\x121.personhog.types.v1.UpdateGroupTypeMappingRequest\x1a2.personhog.types.v1.UpdateGroupTypeMappingResponse\x12\x7f\n\x16DeleteGroupTypeMapping\x121.personhog.types.v1.DeleteGroupTypeMappingRequest\x1a2.personhog.types.v1.DeleteGroupTypeMappingResponse\x12\xa6\x01\n#DeleteGroupTypeMappingsBatchForTeam\x12>.personhog.types.v1.DeleteGroupTypeMappingsBatchForTeamRequest\x1a?.personhog.types.v1.DeleteGroupTypeMappingsBatchForTeamResponse\x12\x7f\n\x16UpdatePersonProperties\x121.personhog.types.v1.UpdatePersonPropertiesRequest\x1a2.personhog.types.v1.UpdatePersonPropertiesResponse\x12^\n\x0bFencePerson\x12&.personhog.types.v1.FencePersonRequest\x1a'.personhog.types.v1.FencePersonResponse\x12a\n\x0cReleaseFence\x12'.personhog.types.v1.ReleaseFenceRequest\x1a(.personhog.types.v1.ReleaseFenceResponse\x12d\n\rDeletePersons\x12(.personhog.types.v1.DeletePersonsRequest\x1a).personhog.types.v1.DeletePersonsResponse\x12\x88\x01\n\x19DeletePersonsBatchForTeam\x124.personhog.types.v1.DeletePersonsBatchForTeamRequest\x1a5.personhog.types.v1.DeletePersonsBatchForTeamResponse\x12\xb2\x01\n'DeletePersonlessDistinctIdsBatchForTeam\x12B.personhog.types.v1.DeletePersonlessDistinctIdsBatchForTeamRequest\x1aC.personhog.types.v1.DeletePersonlessDistinctIdsBatchForTeamResponse\x12^\n\x0bSplitPerson\x12&.personhog.types.v1.SplitPersonRequest\x1a'.personhog.types.v1.SplitPersonResponse\x12\x9a\x01\n\x1fSetPersonDistinctIdVersionFloor\x12:.personhog.types.v1.SetPersonDistinctIdVersionFloorRequest\x1a;.personhog.types.v1.SetPersonDistinctIdVersionFloorResponse\x12|\n\x15SetPersonVersionFloor\x120.personhog.types.v1.SetPersonVersionFloorRequest\x1a1.personhog.types.v1.SetPersonVersionFloorResponseb\x06proto3" + b"\n\"personhog/service/v1/service.proto\x12\x14personhog.service.v1\x1a\x1fpersonhog/types/v1/person.proto\x1a\x1epersonhog/types/v1/group.proto\x1a\x1fpersonhog/types/v1/cohort.proto\x1a%personhog/types/v1/feature_flag.proto2\xc3*\n\x10PersonHogService\x12X\n\tGetPerson\x12$.personhog.types.v1.GetPersonRequest\x1a%.personhog.types.v1.GetPersonResponse\x12X\n\nGetPersons\x12%.personhog.types.v1.GetPersonsRequest\x1a#.personhog.types.v1.PersonsResponse\x12d\n\x0fGetPersonByUuid\x12*.personhog.types.v1.GetPersonByUuidRequest\x1a%.personhog.types.v1.GetPersonResponse\x12f\n\x11GetPersonsByUuids\x12,.personhog.types.v1.GetPersonsByUuidsRequest\x1a#.personhog.types.v1.PersonsResponse\x12p\n\x15GetPersonByDistinctId\x120.personhog.types.v1.GetPersonByDistinctIdRequest\x1a%.personhog.types.v1.GetPersonResponse\x12\x91\x01\n\x1dGetPersonsByDistinctIdsInTeam\x128.personhog.types.v1.GetPersonsByDistinctIdsInTeamRequest\x1a6.personhog.types.v1.PersonsByDistinctIdsInTeamResponse\x12\x7f\n\x17GetPersonsByDistinctIds\x122.personhog.types.v1.GetPersonsByDistinctIdsRequest\x1a0.personhog.types.v1.PersonsByDistinctIdsResponse\x12\x82\x01\n\x17GetDistinctIdsForPerson\x122.personhog.types.v1.GetDistinctIdsForPersonRequest\x1a3.personhog.types.v1.GetDistinctIdsForPersonResponse\x12\x85\x01\n\x18GetDistinctIdsForPersons\x123.personhog.types.v1.GetDistinctIdsForPersonsRequest\x1a4.personhog.types.v1.GetDistinctIdsForPersonsResponse\x12\x88\x01\n\x19GetHashKeyOverrideContext\x124.personhog.types.v1.GetHashKeyOverrideContextRequest\x1a5.personhog.types.v1.GetHashKeyOverrideContextResponse\x12\x7f\n\x16UpsertHashKeyOverrides\x121.personhog.types.v1.UpsertHashKeyOverridesRequest\x1a2.personhog.types.v1.UpsertHashKeyOverridesResponse\x12\x94\x01\n\x1dDeleteHashKeyOverridesByTeams\x128.personhog.types.v1.DeleteHashKeyOverridesByTeamsRequest\x1a9.personhog.types.v1.DeleteHashKeyOverridesByTeamsResponse\x12w\n\x15CheckCohortMembership\x120.personhog.types.v1.CheckCohortMembershipRequest\x1a,.personhog.types.v1.CohortMembershipResponse\x12s\n\x12CountCohortMembers\x12-.personhog.types.v1.CountCohortMembersRequest\x1a..personhog.types.v1.CountCohortMembersResponse\x12s\n\x12DeleteCohortMember\x12-.personhog.types.v1.DeleteCohortMemberRequest\x1a..personhog.types.v1.DeleteCohortMemberResponse\x12\x82\x01\n\x17DeleteCohortMembersBulk\x122.personhog.types.v1.DeleteCohortMembersBulkRequest\x1a3.personhog.types.v1.DeleteCohortMembersBulkResponse\x12v\n\x13InsertCohortMembers\x12..personhog.types.v1.InsertCohortMembersRequest\x1a/.personhog.types.v1.InsertCohortMembersResponse\x12v\n\x13ListCohortMemberIds\x12..personhog.types.v1.ListCohortMemberIdsRequest\x1a/.personhog.types.v1.ListCohortMemberIdsResponse\x12U\n\x08GetGroup\x12#.personhog.types.v1.GetGroupRequest\x1a$.personhog.types.v1.GetGroupResponse\x12U\n\tGetGroups\x12$.personhog.types.v1.GetGroupsRequest\x1a\".personhog.types.v1.GroupsResponse\x12g\n\x0eGetGroupsBatch\x12).personhog.types.v1.GetGroupsBatchRequest\x1a*.personhog.types.v1.GetGroupsBatchResponse\x12[\n\nListGroups\x12%.personhog.types.v1.ListGroupsRequest\x1a&.personhog.types.v1.ListGroupsResponse\x12\x86\x01\n\x1cGetGroupTypeMappingsByTeamId\x127.personhog.types.v1.GetGroupTypeMappingsByTeamIdRequest\x1a-.personhog.types.v1.GroupTypeMappingsResponse\x12\x8d\x01\n\x1dGetGroupTypeMappingsByTeamIds\x128.personhog.types.v1.GetGroupTypeMappingsByTeamIdsRequest\x1a2.personhog.types.v1.GroupTypeMappingsBatchResponse\x12\x8c\x01\n\x1fGetGroupTypeMappingsByProjectId\x12:.personhog.types.v1.GetGroupTypeMappingsByProjectIdRequest\x1a-.personhog.types.v1.GroupTypeMappingsResponse\x12\x93\x01\n GetGroupTypeMappingsByProjectIds\x12;.personhog.types.v1.GetGroupTypeMappingsByProjectIdsRequest\x1a2.personhog.types.v1.GroupTypeMappingsBatchResponse\x12\x9d\x01\n GetGroupTypeMappingByDashboardId\x12;.personhog.types.v1.GetGroupTypeMappingByDashboardIdRequest\x1a<.personhog.types.v1.GetGroupTypeMappingByDashboardIdResponse\x12\x7f\n\x16CountGroupTypeMappings\x121.personhog.types.v1.CountGroupTypeMappingsRequest\x1a2.personhog.types.v1.CountGroupTypeMappingsResponse\x12^\n\x0bCreateGroup\x12&.personhog.types.v1.CreateGroupRequest\x1a'.personhog.types.v1.CreateGroupResponse\x12^\n\x0bUpdateGroup\x12&.personhog.types.v1.UpdateGroupRequest\x1a'.personhog.types.v1.UpdateGroupResponse\x12\x85\x01\n\x18DeleteGroupsBatchForTeam\x123.personhog.types.v1.DeleteGroupsBatchForTeamRequest\x1a4.personhog.types.v1.DeleteGroupsBatchForTeamResponse\x12\x7f\n\x16UpdateGroupTypeMapping\x121.personhog.types.v1.UpdateGroupTypeMappingRequest\x1a2.personhog.types.v1.UpdateGroupTypeMappingResponse\x12\x7f\n\x16DeleteGroupTypeMapping\x121.personhog.types.v1.DeleteGroupTypeMappingRequest\x1a2.personhog.types.v1.DeleteGroupTypeMappingResponse\x12\xa6\x01\n#DeleteGroupTypeMappingsBatchForTeam\x12>.personhog.types.v1.DeleteGroupTypeMappingsBatchForTeamRequest\x1a?.personhog.types.v1.DeleteGroupTypeMappingsBatchForTeamResponse\x12\x7f\n\x16UpdatePersonProperties\x121.personhog.types.v1.UpdatePersonPropertiesRequest\x1a2.personhog.types.v1.UpdatePersonPropertiesResponse\x12^\n\x0bFencePerson\x12&.personhog.types.v1.FencePersonRequest\x1a'.personhog.types.v1.FencePersonResponse\x12a\n\x0cReleaseFence\x12'.personhog.types.v1.ReleaseFenceRequest\x1a(.personhog.types.v1.ReleaseFenceResponse\x12s\n\x12FoldPersonDocument\x12-.personhog.types.v1.FoldPersonDocumentRequest\x1a..personhog.types.v1.FoldPersonDocumentResponse\x12d\n\rDeletePersons\x12(.personhog.types.v1.DeletePersonsRequest\x1a).personhog.types.v1.DeletePersonsResponse\x12\x88\x01\n\x19DeletePersonsBatchForTeam\x124.personhog.types.v1.DeletePersonsBatchForTeamRequest\x1a5.personhog.types.v1.DeletePersonsBatchForTeamResponse\x12\xb2\x01\n'DeletePersonlessDistinctIdsBatchForTeam\x12B.personhog.types.v1.DeletePersonlessDistinctIdsBatchForTeamRequest\x1aC.personhog.types.v1.DeletePersonlessDistinctIdsBatchForTeamResponse\x12^\n\x0bSplitPerson\x12&.personhog.types.v1.SplitPersonRequest\x1a'.personhog.types.v1.SplitPersonResponse\x12\x9a\x01\n\x1fSetPersonDistinctIdVersionFloor\x12:.personhog.types.v1.SetPersonDistinctIdVersionFloorRequest\x1a;.personhog.types.v1.SetPersonDistinctIdVersionFloorResponse\x12|\n\x15SetPersonVersionFloor\x120.personhog.types.v1.SetPersonVersionFloorRequest\x1a1.personhog.types.v1.SetPersonVersionFloorResponseb\x06proto3" ) _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -28,4 +28,4 @@ if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals["_PERSONHOGSERVICE"]._serialized_start = 198 - _globals["_PERSONHOGSERVICE"]._serialized_end = 5524 + _globals["_PERSONHOGSERVICE"]._serialized_end = 5641 diff --git a/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2_grpc.py b/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2_grpc.py index a1370fa8afc9..afd1eabd9231 100644 --- a/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2_grpc.py +++ b/posthog/personhog_client/proto/generated/personhog/service/v1/service_pb2_grpc.py @@ -261,6 +261,12 @@ def __init__(self, channel): response_deserializer=personhog_dot_types_dot_v1_dot_person__pb2.ReleaseFenceResponse.FromString, _registered_method=True, ) + self.FoldPersonDocument = channel.unary_unary( + "/personhog.service.v1.PersonHogService/FoldPersonDocument", + request_serializer=personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentRequest.SerializeToString, + response_deserializer=personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentResponse.FromString, + _registered_method=True, + ) self.DeletePersons = channel.unary_unary( "/personhog.service.v1.PersonHogService/DeletePersons", request_serializer=personhog_dot_types_dot_v1_dot_person__pb2.DeletePersonsRequest.SerializeToString, @@ -529,6 +535,12 @@ def ReleaseFence(self, request, context): context.set_details("Method not implemented!") raise NotImplementedError("Method not implemented!") + def FoldPersonDocument(self, request, context): + """Missing associated documentation comment in .proto file.""" + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details("Method not implemented!") + raise NotImplementedError("Method not implemented!") + def DeletePersons(self, request, context): """Person deletes WARNING: This is a write operation on person data. It should route to the leader @@ -761,6 +773,11 @@ def add_PersonHogServiceServicer_to_server(servicer, server): request_deserializer=personhog_dot_types_dot_v1_dot_person__pb2.ReleaseFenceRequest.FromString, response_serializer=personhog_dot_types_dot_v1_dot_person__pb2.ReleaseFenceResponse.SerializeToString, ), + "FoldPersonDocument": grpc.unary_unary_rpc_method_handler( + servicer.FoldPersonDocument, + request_deserializer=personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentRequest.FromString, + response_serializer=personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentResponse.SerializeToString, + ), "DeletePersons": grpc.unary_unary_rpc_method_handler( servicer.DeletePersons, request_deserializer=personhog_dot_types_dot_v1_dot_person__pb2.DeletePersonsRequest.FromString, @@ -1912,6 +1929,36 @@ def ReleaseFence( _registered_method=True, ) + @staticmethod + def FoldPersonDocument( + request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None, + ): + return grpc.experimental.unary_unary( + request, + target, + "/personhog.service.v1.PersonHogService/FoldPersonDocument", + personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentRequest.SerializeToString, + personhog_dot_types_dot_v1_dot_person__pb2.FoldPersonDocumentResponse.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True, + ) + @staticmethod def DeletePersons( request, diff --git a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py index c87faf3cd876..c59a3bf1c345 100644 --- a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py +++ b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py @@ -15,17 +15,17 @@ from ....personhog.types.v1 import common_pb2 as personhog_dot_types_dot_v1_dot_common__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x1fpersonhog/types/v1/person.proto\x12\x12personhog.types.v1\x1a\x1fpersonhog/types/v1/common.proto"\xb2\x02\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x12\x0f\n\x07team_id\x18\x03 \x01(\x03\x12\x12\n\nproperties\x18\x04 \x01(\x0c\x12"\n\x1aproperties_last_updated_at\x18\x05 \x01(\x0c\x12!\n\x19properties_last_operation\x18\x06 \x01(\x0c\x12\x12\n\ncreated_at\x18\x07 \x01(\x03\x12\x0f\n\x07version\x18\x08 \x01(\x03\x12\x15\n\ris_identified\x18\t \x01(\x08\x12\x17\n\nis_user_id\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x0b \x01(\x03H\x01\x88\x01\x01\x12\x12\n\nis_deleted\x18\x0c \x01(\x08B\r\n\x0b_is_user_idB\x0f\n\r_last_seen_at"N\n\x15DistinctIdWithVersion\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x14\n\x07version\x18\x02 \x01(\x03H\x00\x88\x01\x01B\n\n\x08_version"h\n\x15PersonWithDistinctIds\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"g\n\x11PersonDistinctIds\x12\x11\n\tperson_id\x18\x01 \x01(\x03\x12?\n\x0cdistinct_ids\x18\x02 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\x87\x01\n\x18PersonWithTeamDistinctId\x12/\n\x03key\x18\x01 \x01(\x0b2".personhog.types.v1.TeamDistinctId\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"m\n\x10GetPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"O\n\x11GetPersonResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"o\n\x11GetPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"S\n\x0fPersonsResponse\x12+\n\x07persons\x18\x01 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x13\n\x0bmissing_ids\x18\x02 \x03(\x03"n\n\x16GetPersonByUuidRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"q\n\x18GetPersonsByUuidsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\r\n\x05uuids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"{\n\x1cGetPersonByDistinctIdRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"\x84\x01\n$GetPersonsByDistinctIdsInTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cdistinct_ids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"`\n"PersonsByDistinctIdsInTeamResponse\x12:\n\x07results\x18\x01 \x03(\x0b2).personhog.types.v1.PersonWithDistinctIds"\x96\x01\n\x1eGetPersonsByDistinctIdsRequest\x12=\n\x11team_distinct_ids\x18\x01 \x03(\x0b2".personhog.types.v1.TeamDistinctId\x125\n\x0cread_options\x18\x02 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"]\n\x1cPersonsByDistinctIdsResponse\x12=\n\x07results\x18\x01 \x03(\x0b2,.personhog.types.v1.PersonWithTeamDistinctId"\x99\x01\n\x1eGetDistinctIdsForPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x12\n\x05limit\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x08\n\x06_limit"b\n\x1fGetDistinctIdsForPersonResponse\x12?\n\x0cdistinct_ids\x18\x01 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\xb1\x01\n\x1fGetDistinctIdsForPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x1d\n\x10limit_per_person\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x13\n\x11_limit_per_person"f\n GetDistinctIdsForPersonsResponse\x12B\n\x13person_distinct_ids\x18\x01 \x03(\x0b2%.personhog.types.v1.PersonDistinctIds"\x80\x02\n\x1dUpdatePersonPropertiesRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x12\n\nevent_name\x18\x03 \x01(\t\x12\x16\n\x0eset_properties\x18\x04 \x01(\x0c\x12\x1b\n\x13set_once_properties\x18\x05 \x01(\x0c\x12\x18\n\x10unset_properties\x18\x06 \x03(\t\x12\x1a\n\ris_identified\x18\x07 \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x08 \x01(\x03H\x01\x88\x01\x01B\x10\n\x0e_is_identifiedB\x0f\n\r_last_seen_at"m\n\x1eUpdatePersonPropertiesResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01\x12\x0f\n\x07updated\x18\x02 \x01(\x08B\t\n\x07_person"=\n\x14DeletePersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cperson_uuids\x18\x02 \x03(\t".\n\x15DeletePersonsResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"G\n DeletePersonsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03":\n!DeletePersonsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"U\n.DeletePersonlessDistinctIdsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03"H\n/DeletePersonlessDistinctIdsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"W\n\x12SplitPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x1d\n\x15distinct_ids_to_split\x18\x03 \x03(\t"\x8e\x01\n\x0bSplitResult\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x17\n\x0fnew_person_uuid\x18\x02 \x01(\t\x12\x1a\n\x12new_person_version\x18\x03 \x01(\x03\x12\x13\n\x0bpdi_version\x18\x04 \x01(\x03\x12 \n\x18new_person_created_at_ms\x18\x05 \x01(\x03"F\n\x13SplitPersonResponse\x12/\n\x06splits\x18\x01 \x03(\x0b2\x1f.personhog.types.v1.SplitResult"c\n&SetPersonDistinctIdVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"e\n\'SetPersonDistinctIdVersionFloorResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"W\n\x1cSetPersonVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"0\n\x1dSetPersonVersionFloorResponse\x12\x0f\n\x07updated\x18\x01 \x01(\x08"}\n\x12FencePersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\r\n\x05op_id\x18\x03 \x01(\t\x124\n\x07op_type\x18\x04 \x01(\x0e2#.personhog.types.v1.LifecycleOpType"A\n\x13FencePersonResponse\x12*\n\x06sealed\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person"\xd6\x01\n\x13ReleaseFenceRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bperson_uuid\x18\x03 \x01(\t\x12\r\n\x05op_id\x18\x04 \x01(\t\x123\n\x07outcome\x18\x05 \x01(\x0e2".personhog.types.v1.ReleaseOutcome\x12\x1b\n\x0esealed_version\x18\x06 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\ncreated_at\x18\x07 \x01(\x03B\x11\n\x0f_sealed_version"\x16\n\x14ReleaseFenceResponse*o\n\x0fLifecycleOpType\x12!\n\x1dLIFECYCLE_OP_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18LIFECYCLE_OP_TYPE_DELETE\x10\x01\x12\x1b\n\x17LIFECYCLE_OP_TYPE_MERGE\x10\x02*m\n\x0eReleaseOutcome\x12\x1f\n\x1bRELEASE_OUTCOME_UNSPECIFIED\x10\x00\x12\x1d\n\x19RELEASE_OUTCOME_COMMITTED\x10\x01\x12\x1b\n\x17RELEASE_OUTCOME_ABORTED\x10\x02b\x06proto3' + b'\n\x1fpersonhog/types/v1/person.proto\x12\x12personhog.types.v1\x1a\x1fpersonhog/types/v1/common.proto"\xb2\x02\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x12\x0f\n\x07team_id\x18\x03 \x01(\x03\x12\x12\n\nproperties\x18\x04 \x01(\x0c\x12"\n\x1aproperties_last_updated_at\x18\x05 \x01(\x0c\x12!\n\x19properties_last_operation\x18\x06 \x01(\x0c\x12\x12\n\ncreated_at\x18\x07 \x01(\x03\x12\x0f\n\x07version\x18\x08 \x01(\x03\x12\x15\n\ris_identified\x18\t \x01(\x08\x12\x17\n\nis_user_id\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x0b \x01(\x03H\x01\x88\x01\x01\x12\x12\n\nis_deleted\x18\x0c \x01(\x08B\r\n\x0b_is_user_idB\x0f\n\r_last_seen_at"N\n\x15DistinctIdWithVersion\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x14\n\x07version\x18\x02 \x01(\x03H\x00\x88\x01\x01B\n\n\x08_version"h\n\x15PersonWithDistinctIds\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"g\n\x11PersonDistinctIds\x12\x11\n\tperson_id\x18\x01 \x01(\x03\x12?\n\x0cdistinct_ids\x18\x02 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\x87\x01\n\x18PersonWithTeamDistinctId\x12/\n\x03key\x18\x01 \x01(\x0b2".personhog.types.v1.TeamDistinctId\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"m\n\x10GetPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"O\n\x11GetPersonResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"o\n\x11GetPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"S\n\x0fPersonsResponse\x12+\n\x07persons\x18\x01 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x13\n\x0bmissing_ids\x18\x02 \x03(\x03"n\n\x16GetPersonByUuidRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"q\n\x18GetPersonsByUuidsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\r\n\x05uuids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"{\n\x1cGetPersonByDistinctIdRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"\x84\x01\n$GetPersonsByDistinctIdsInTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cdistinct_ids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"`\n"PersonsByDistinctIdsInTeamResponse\x12:\n\x07results\x18\x01 \x03(\x0b2).personhog.types.v1.PersonWithDistinctIds"\x96\x01\n\x1eGetPersonsByDistinctIdsRequest\x12=\n\x11team_distinct_ids\x18\x01 \x03(\x0b2".personhog.types.v1.TeamDistinctId\x125\n\x0cread_options\x18\x02 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"]\n\x1cPersonsByDistinctIdsResponse\x12=\n\x07results\x18\x01 \x03(\x0b2,.personhog.types.v1.PersonWithTeamDistinctId"\x99\x01\n\x1eGetDistinctIdsForPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x12\n\x05limit\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x08\n\x06_limit"b\n\x1fGetDistinctIdsForPersonResponse\x12?\n\x0cdistinct_ids\x18\x01 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\xb1\x01\n\x1fGetDistinctIdsForPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x1d\n\x10limit_per_person\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x13\n\x11_limit_per_person"f\n GetDistinctIdsForPersonsResponse\x12B\n\x13person_distinct_ids\x18\x01 \x03(\x0b2%.personhog.types.v1.PersonDistinctIds"\x80\x02\n\x1dUpdatePersonPropertiesRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x12\n\nevent_name\x18\x03 \x01(\t\x12\x16\n\x0eset_properties\x18\x04 \x01(\x0c\x12\x1b\n\x13set_once_properties\x18\x05 \x01(\x0c\x12\x18\n\x10unset_properties\x18\x06 \x03(\t\x12\x1a\n\ris_identified\x18\x07 \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x08 \x01(\x03H\x01\x88\x01\x01B\x10\n\x0e_is_identifiedB\x0f\n\r_last_seen_at"m\n\x1eUpdatePersonPropertiesResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01\x12\x0f\n\x07updated\x18\x02 \x01(\x08B\t\n\x07_person"=\n\x14DeletePersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cperson_uuids\x18\x02 \x03(\t".\n\x15DeletePersonsResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"G\n DeletePersonsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03":\n!DeletePersonsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"U\n.DeletePersonlessDistinctIdsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03"H\n/DeletePersonlessDistinctIdsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"W\n\x12SplitPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x1d\n\x15distinct_ids_to_split\x18\x03 \x03(\t"\x8e\x01\n\x0bSplitResult\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x17\n\x0fnew_person_uuid\x18\x02 \x01(\t\x12\x1a\n\x12new_person_version\x18\x03 \x01(\x03\x12\x13\n\x0bpdi_version\x18\x04 \x01(\x03\x12 \n\x18new_person_created_at_ms\x18\x05 \x01(\x03"F\n\x13SplitPersonResponse\x12/\n\x06splits\x18\x01 \x03(\x0b2\x1f.personhog.types.v1.SplitResult"c\n&SetPersonDistinctIdVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"e\n\'SetPersonDistinctIdVersionFloorResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"W\n\x1cSetPersonVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"0\n\x1dSetPersonVersionFloorResponse\x12\x0f\n\x07updated\x18\x01 \x01(\x08"}\n\x12FencePersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\r\n\x05op_id\x18\x03 \x01(\t\x124\n\x07op_type\x18\x04 \x01(\x0e2#.personhog.types.v1.LifecycleOpType"A\n\x13FencePersonResponse\x12*\n\x06sealed\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person"\xd6\x01\n\x13ReleaseFenceRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bperson_uuid\x18\x03 \x01(\t\x12\r\n\x05op_id\x18\x04 \x01(\t\x123\n\x07outcome\x18\x05 \x01(\x0e2".personhog.types.v1.ReleaseOutcome\x12\x1b\n\x0esealed_version\x18\x06 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\ncreated_at\x18\x07 \x01(\x03B\x11\n\x0f_sealed_version"\x16\n\x14ReleaseFenceResponse"\xaf\x01\n\x19FoldPersonDocumentRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x124\n\x10sealed_snapshots\x18\x03 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x11\n\tevent_set\x18\x04 \x01(\x0c\x12\x16\n\x0eevent_set_once\x18\x05 \x01(\x0c\x12\r\n\x05op_id\x18\x06 \x01(\t"H\n\x1aFoldPersonDocumentResponse\x12*\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person*o\n\x0fLifecycleOpType\x12!\n\x1dLIFECYCLE_OP_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18LIFECYCLE_OP_TYPE_DELETE\x10\x01\x12\x1b\n\x17LIFECYCLE_OP_TYPE_MERGE\x10\x02*m\n\x0eReleaseOutcome\x12\x1f\n\x1bRELEASE_OUTCOME_UNSPECIFIED\x10\x00\x12\x1d\n\x19RELEASE_OUTCOME_COMMITTED\x10\x01\x12\x1b\n\x17RELEASE_OUTCOME_ABORTED\x10\x02b\x06proto3' ) _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "personhog.types.v1.person_pb2", _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals["_LIFECYCLEOPTYPE"]._serialized_start = 4448 - _globals["_LIFECYCLEOPTYPE"]._serialized_end = 4559 - _globals["_RELEASEOUTCOME"]._serialized_start = 4561 - _globals["_RELEASEOUTCOME"]._serialized_end = 4670 + _globals["_LIFECYCLEOPTYPE"]._serialized_start = 4700 + _globals["_LIFECYCLEOPTYPE"]._serialized_end = 4811 + _globals["_RELEASEOUTCOME"]._serialized_start = 4813 + _globals["_RELEASEOUTCOME"]._serialized_end = 4922 _globals["_PERSON"]._serialized_start = 89 _globals["_PERSON"]._serialized_end = 395 _globals["_DISTINCTIDWITHVERSION"]._serialized_start = 397 @@ -104,3 +104,7 @@ _globals["_RELEASEFENCEREQUEST"]._serialized_end = 4422 _globals["_RELEASEFENCERESPONSE"]._serialized_start = 4424 _globals["_RELEASEFENCERESPONSE"]._serialized_end = 4446 + _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_start = 4449 + _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_end = 4624 + _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_start = 4626 + _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_end = 4698 diff --git a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi index 4f38318f6df6..f1e941f5e89b 100644 --- a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi +++ b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi @@ -578,3 +578,35 @@ class ReleaseFenceResponse(_message.Message): __slots__ = () def __init__(self) -> None: ... + +class FoldPersonDocumentRequest(_message.Message): + __slots__ = ("team_id", "person_id", "sealed_snapshots", "event_set", "event_set_once", "op_id") + TEAM_ID_FIELD_NUMBER: _ClassVar[int] + PERSON_ID_FIELD_NUMBER: _ClassVar[int] + SEALED_SNAPSHOTS_FIELD_NUMBER: _ClassVar[int] + EVENT_SET_FIELD_NUMBER: _ClassVar[int] + EVENT_SET_ONCE_FIELD_NUMBER: _ClassVar[int] + OP_ID_FIELD_NUMBER: _ClassVar[int] + team_id: int + person_id: int + sealed_snapshots: _containers.RepeatedCompositeFieldContainer[Person] + event_set: bytes + event_set_once: bytes + op_id: str + + def __init__( + self, + team_id: _Optional[int] = ..., + person_id: _Optional[int] = ..., + sealed_snapshots: _Optional[_Iterable[_Union[Person, _Mapping]]] = ..., + event_set: _Optional[bytes] = ..., + event_set_once: _Optional[bytes] = ..., + op_id: _Optional[str] = ..., + ) -> None: ... + +class FoldPersonDocumentResponse(_message.Message): + __slots__ = ("person",) + PERSON_FIELD_NUMBER: _ClassVar[int] + person: Person + + def __init__(self, person: _Optional[_Union[Person, _Mapping]] = ...) -> None: ... diff --git a/proto/personhog/leader/v1/leader.proto b/proto/personhog/leader/v1/leader.proto index d6a4adf91a05..188754000903 100644 --- a/proto/personhog/leader/v1/leader.proto +++ b/proto/personhog/leader/v1/leader.proto @@ -49,4 +49,23 @@ service PersonHogLeader { // a mark already settled as deleted absorbs the retry without a second // record. rpc ReleaseFence(personhog.types.v1.ReleaseFenceRequest) returns (personhog.types.v1.ReleaseFenceResponse); + + // Internal, saga-runner only. The merge saga's document write: fold the + // sealed source snapshots into the target person's document under the + // owner's per-person lock, produce the folded document to the + // changelog, and return it. Properties fold target-wins: the target + // keeps every key it has, snapshots fill still-absent keys in request + // order, then the merge event's $set overrides and $set_once fills. + // Scalars fold as created_at = min(target, sources) and + // is_identified = true; the version is max(target, max sealed) + 1, a + // max-merge that stays above every sealed source's death document. + // Safe under at-least-once delivery: re-applying the same fold on + // unchanged target state changes no content and only bumps the version. + // (The standard re-appliable-merge residual applies — see the request's + // op_id comment.) A target fenced by a + // different lifecycle op rejects FAILED_PRECONDITION (the merge target + // is marked, never fenced, so this only fires on a ghost fence — the + // retry goes through once the healer clears it); a destroyed target + // answers NOT_FOUND. + rpc FoldPersonDocument(personhog.types.v1.FoldPersonDocumentRequest) returns (personhog.types.v1.FoldPersonDocumentResponse); } diff --git a/proto/personhog/service/v1/service.proto b/proto/personhog/service/v1/service.proto index 802eeddafc60..79cb2ca6dd87 100644 --- a/proto/personhog/service/v1/service.proto +++ b/proto/personhog/service/v1/service.proto @@ -69,6 +69,7 @@ service PersonHogService { // semantics. rpc FencePerson(personhog.types.v1.FencePersonRequest) returns (personhog.types.v1.FencePersonResponse); rpc ReleaseFence(personhog.types.v1.ReleaseFenceRequest) returns (personhog.types.v1.ReleaseFenceResponse); + rpc FoldPersonDocument(personhog.types.v1.FoldPersonDocumentRequest) returns (personhog.types.v1.FoldPersonDocumentResponse); // Person deletes // WARNING: This is a write operation on person data. It should route to the leader diff --git a/proto/personhog/types/v1/person.proto b/proto/personhog/types/v1/person.proto index 4ac027679574..4b5c85a7a686 100644 --- a/proto/personhog/types/v1/person.proto +++ b/proto/personhog/types/v1/person.proto @@ -355,3 +355,39 @@ enum ReleaseOutcome { } message ReleaseFenceResponse {} + +// FoldPersonDocumentRequest folds sealed source snapshots into the merge +// target's document (see PersonHogLeader.FoldPersonDocument). The +// snapshots are the sources' final word — each was returned by +// FencePerson and no write can follow it while the merge op lives. +message FoldPersonDocumentRequest { + int64 team_id = 1; + // The merge target. The fold is computed by its owning leader from its + // authoritative cache plus the snapshots below. + int64 person_id = 2; + // Sealed source snapshots in property-precedence order: earlier + // snapshots beat later ones, and the target beats all. Must be + // non-empty — a merge with no sealed sources has nothing to fold. + repeated Person sealed_snapshots = 3; + // JSON-encoded maps from the merge event. event_set overrides on + // conflict (matching ingestion's $identify behavior); event_set_once + // fills only still-absent keys. + bytes event_set = 4; + bytes event_set_once = 5; + // The merge saga's op id. Re-applying a fold on unchanged target state + // changes no content and only bumps the version; the id exists for + // fence tolerance and observability, not dedup. The accepted + // at-least-once residual applies: the target is never fenced, so a + // re-driven fold recomputes over writes that landed in between and can + // clobber them where they overlap the event's $set or a snapshot's + // keys — the same residual every re-appliable leader merge carries + // today, closed later by operation identity in the write path. + string op_id = 6; +} + +message FoldPersonDocumentResponse { + // The folded document exactly as produced to the changelog: + // target-wins properties, created_at = min(target, sources), + // is_identified = true, version = max(target, max sealed) + 1. + Person person = 1; +} diff --git a/rust/personhog-common/src/client.rs b/rust/personhog-common/src/client.rs index 869639a3ca26..09637ef0ec2b 100644 --- a/rust/personhog-common/src/client.rs +++ b/rust/personhog-common/src/client.rs @@ -12,9 +12,9 @@ use tonic::{Request, Status}; use personhog_proto::personhog::service::v1::person_hog_service_client::PersonHogServiceClient; use personhog_proto::personhog::types::v1::{ - ConsistencyLevel, FencePersonRequest, FencePersonResponse, GetPersonRequest, Person, - ReadOptions, ReleaseFenceRequest, ReleaseFenceResponse, UpdatePersonPropertiesRequest, - UpdatePersonPropertiesResponse, + ConsistencyLevel, FencePersonRequest, FencePersonResponse, FoldPersonDocumentRequest, + FoldPersonDocumentResponse, GetPersonRequest, Person, ReadOptions, ReleaseFenceRequest, + ReleaseFenceResponse, UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, }; /// Routing headers for leader-bound calls through the router. @@ -112,6 +112,23 @@ impl RouterClient { .map(|response| response.into_inner()) } + /// Leader-routed merge fold (saga runner only): fold sealed source + /// snapshots into the target's document and return the folded result. + pub async fn fold_person_document( + &self, + request: FoldPersonDocumentRequest, + ) -> Result { + let (team_id, person_id) = (request.team_id, request.person_id); + let mut request = Request::new(request); + request.set_timeout(self.request_timeout); + stamp_person_routing_headers(&mut request, team_id, person_id); + self.inner + .clone() + .fold_person_document(request) + .await + .map(|response| response.into_inner()) + } + fn build_update_request( &self, request: UpdatePersonPropertiesRequest, diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index 83580f9b8df2..d196ffb4d7f0 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -6,9 +6,10 @@ use dashmap::DashMap; use metrics::{counter, histogram}; use personhog_proto::personhog::leader::v1::person_hog_leader_server::PersonHogLeader; use personhog_proto::personhog::types::v1::{ - FencePersonRequest, FencePersonResponse, GetPersonRequest, GetPersonResponse, LifecycleOpType, - Person, ReleaseFenceRequest, ReleaseFenceResponse, ReleaseOutcome, - UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, + FencePersonRequest, FencePersonResponse, FoldPersonDocumentRequest, FoldPersonDocumentResponse, + GetPersonRequest, GetPersonResponse, LifecycleOpType, Person, ReleaseFenceRequest, + ReleaseFenceResponse, ReleaseOutcome, UpdatePersonPropertiesRequest, + UpdatePersonPropertiesResponse, }; use rdkafka::producer::FutureProducer; use tokio::sync::Mutex; @@ -758,6 +759,24 @@ fn cached_person_to_proto(p: &CachedPerson) -> Person { } } +/// Parse a JSON-map wire field (empty bytes mean an empty map), refusing +/// anything that is not a JSON object. +// See `partition_from_metadata` for why `result_large_err` is allowed. +#[allow(clippy::result_large_err)] +fn parse_json_object_field(bytes: &[u8], field: &str) -> Result { + if bytes.is_empty() { + return Ok(serde_json::Value::Object(serde_json::Map::new())); + } + let value: serde_json::Value = serde_json::from_slice(bytes) + .map_err(|e| Status::invalid_argument(format!("invalid {field} JSON: {e}")))?; + if !value.is_object() { + return Err(Status::invalid_argument(format!( + "{field} must be a JSON object" + ))); + } + Ok(value) +} + /// Extract the routing partition from the `x-partition` request-metadata /// header. The router stamps this on every leader call after hashing /// `(team_id, person_id)`; its absence means a misrouted or malformed @@ -1143,6 +1162,211 @@ impl PersonHogLeader for PersonHogLeaderService { })) } + async fn fold_person_document( + &self, + request: Request, + ) -> Result, Status> { + let partition = partition_from_metadata(&request)?; + let req = request.into_inner(); + self.validate_partition(partition, req.team_id, req.person_id)?; + self.check_authority(partition)?; + let op_id = Uuid::parse_str(&req.op_id) + .map_err(|_| Status::invalid_argument("op_id must be a valid UUID"))?; + if req.sealed_snapshots.is_empty() { + return Err(Status::invalid_argument( + "sealed_snapshots must be non-empty: a merge with no sealed sources has nothing \ + to fold", + )); + } + + let Some(_inflight_guard) = self.inflight.try_begin(partition) else { + return Err(Status::failed_precondition(format!( + "partition {partition} is fenced for handoff; writes are rejected" + ))); + }; + + // Parse and sanitize every JSON input before taking the per-key + // lock. Snapshot properties were sanitized when the source's + // leader cached them, but they crossed the wire since; sanitizing + // again keeps the fold's admission guarantee self-contained. + let mut event_set = parse_json_object_field(&req.event_set, "event_set")?; + let mut event_set_once = parse_json_object_field(&req.event_set_once, "event_set_once")?; + sanitize_for_jsonb(&mut event_set); + sanitize_for_jsonb(&mut event_set_once); + let mut snapshots: Vec<(serde_json::Value, i64, i64)> = + Vec::with_capacity(req.sealed_snapshots.len()); + for snapshot in &req.sealed_snapshots { + let mut properties = + parse_json_object_field(&snapshot.properties, "sealed snapshot properties")?; + sanitize_for_jsonb(&mut properties); + snapshots.push((properties, snapshot.version, snapshot.created_at)); + } + + let cache_key = PersonCacheKey { + team_id: req.team_id, + person_id: req.person_id, + }; + let mutex = self + .locks + .entry(cache_key.clone()) + .or_default() + .value() + .clone(); + let lock_wait = std::time::Instant::now(); + let _guard = mutex.lock().await; + histogram!("personhog_leader_person_lock_wait_ms") + .record(lock_wait.elapsed().as_secs_f64() * 1000.0); + + if !self.dirty_index.can_admit(&cache_key) { + counter!("personhog_leader_writes_shed_total", "reason" => "dirty_index_full") + .increment(1); + return Err(Status::resource_exhausted( + "dirty index at capacity: the writer is behind and this fold cannot be tracked; \ + retry later", + )); + } + + // The merge target is marked, never fenced, so a fence here is + // either a ghost from a settled op (the healer clears it and the + // saga's retry goes through) or a bug. Same-op tolerance mirrors + // FencePerson's re-seal semantics. + if let Some(fence) = self.check_fence(&cache_key) { + if fence.op_id != op_id { + counter!("personhog_leader_writes_fenced_total").increment(1); + if let Some(healer) = &self.fence_healer { + healer.maybe_heal(cache_key.clone(), fence); + } + return Err(fenced_status(&fence)); + } + } + + let person = self.lookup_or_load_locked(partition, &cache_key).await?; + if person.is_deleted { + return Err(Status::not_found("person is destroyed")); + } + + // The fold: the target wins every key it has; snapshots fill + // still-absent keys in request order; then the merge event's $set + // overrides and $set_once fills. All inputs are sanitized, so the + // merged document is measured in stored form. + let mut folded = person.properties.clone(); + if !folded.is_object() { + folded = serde_json::Value::Object(serde_json::Map::new()); + } + let folded_map = folded + .as_object_mut() + .expect("folded was just normalized to an object"); + for (snapshot_properties, _, _) in &snapshots { + if let Some(map) = snapshot_properties.as_object() { + for (key, value) in map { + if !folded_map.contains_key(key) { + folded_map.insert(key.clone(), value.clone()); + } + } + } + } + if let Some(map) = event_set.as_object() { + for (key, value) in map { + folded_map.insert(key.clone(), value.clone()); + } + } + if let Some(map) = event_set_once.as_object() { + for (key, value) in map { + if !folded_map.contains_key(key) { + folded_map.insert(key.clone(), value.clone()); + } + } + } + + // Unlike a property update, a fold cannot be rejected for size: + // the saga would re-drive it forever. Deterministic trimming is + // the only completing behavior, so an oversized fold trims (same + // remediation the update path applies to already-oversized rows), + // and if protected keys alone exceed the target the fold keeps + // the target's stored properties — state admission already + // accepted — discarding the fold's property contribution. + let jsonb_size = jsonb_column_size(&folded); + if jsonb_size > self.size_limits.threshold { + match trim_properties_to_fit_size(&folded, self.size_limits.trim_target) { + TrimResult::Trimmed(trimmed) => { + counter!("personhog_leader_properties_trimmed_total").increment(1); + self.warnings.emit(&SizeViolationWarning { + team_id: cache_key.team_id, + person_uuid: person.uuid.clone(), + message: "Merged person properties exceeded the size limit and were \ + trimmed to fit" + .to_string(), + }); + folded = trimmed; + } + TrimResult::Fits => {} + TrimResult::CannotFit => { + counter!("personhog_leader_folds_total", "outcome" => "unremediable") + .increment(1); + self.warnings.emit(&SizeViolationWarning { + team_id: cache_key.team_id, + person_uuid: person.uuid.clone(), + message: "Merged person properties exceed the size limit and could not \ + be trimmed; the merged-in properties were discarded" + .to_string(), + }); + folded = person.properties.clone(); + } + } + } + + // Scalars: created_at is the min over the target and every + // snapshot (ignoring non-positive values a malformed snapshot + // could carry); is_identified is unconditionally true — a merge + // is an identify. + let created_at = snapshots + .iter() + .map(|(_, _, snapshot_created_at)| *snapshot_created_at) + .filter(|ts| *ts > 0) + .chain(std::iter::once(person.created_at)) + .min() + .unwrap_or(person.created_at); + + // The version is a max-merge over the target's floor and every + // sealed version, plus one: it stays above every source's death + // document (produced at sealed + 1 alongside a fold that clears + // max(sealed) + 1), and re-applying the fold only bumps it again + // — convergent under at-least-once delivery. + let base_version = self + .emitted_versions + .floor_for(partition, &cache_key, person.version); + let max_sealed = snapshots + .iter() + .map(|(_, snapshot_version, _)| *snapshot_version) + .max() + .unwrap_or(0); + let version = base_version.max(max_sealed).checked_add(1).ok_or_else(|| { + Status::invalid_argument("sealed versions leave no room for the folded version") + })?; + + let approx_bytes = approx_person_bytes(jsonb_column_size(&folded)); + let folded_person = CachedPerson { + id: person.id, + uuid: person.uuid.clone(), + team_id: person.team_id, + properties: folded, + created_at, + version, + is_identified: true, + is_deleted: false, + approx_bytes, + }; + + let proto = self + .commit_document(partition, &cache_key, folded_person) + .await?; + counter!("personhog_leader_folds_total", "outcome" => "folded").increment(1); + + Ok(Response::new(FoldPersonDocumentResponse { + person: Some(proto), + })) + } + async fn fence_person( &self, request: Request, diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index 550076f9d2b8..25c6f74f71f2 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -24,8 +24,8 @@ use personhog_leader::warnings::WarningsProducer; use personhog_proto::personhog::leader::v1::person_hog_leader_client::PersonHogLeaderClient; use personhog_proto::personhog::leader::v1::person_hog_leader_server::PersonHogLeaderServer; use personhog_proto::personhog::types::v1::{ - FencePersonRequest, GetPersonRequest, LifecycleOpType, Person, ReleaseFenceRequest, - ReleaseOutcome, UpdatePersonPropertiesRequest, + FencePersonRequest, FoldPersonDocumentRequest, GetPersonRequest, LifecycleOpType, Person, + ReleaseFenceRequest, ReleaseOutcome, UpdatePersonPropertiesRequest, }; use prost::Message; use rdkafka::consumer::{BaseConsumer, Consumer}; @@ -1162,3 +1162,285 @@ async fn at_capacity_new_fences_shed_but_reseals_succeed() { .await .expect("a re-seal of an already-fenced person is exempt from the cap"); } + +// ============================================================ +// FoldPersonDocument: the merge saga's document write +// ============================================================ + +fn snapshot(properties: serde_json::Value, version: i64, created_at: i64) -> Person { + Person { + properties: serde_json::to_vec(&properties).unwrap(), + version, + created_at, + ..Default::default() + } +} + +fn fold_request( + team_id: i64, + person_id: i64, + op_id: &Uuid, + snapshots: Vec, + event_set: serde_json::Value, + event_set_once: serde_json::Value, +) -> FoldPersonDocumentRequest { + FoldPersonDocumentRequest { + team_id, + person_id, + sealed_snapshots: snapshots, + event_set: serde_json::to_vec(&event_set).unwrap(), + event_set_once: serde_json::to_vec(&event_set_once).unwrap(), + op_id: op_id.to_string(), + } +} + +fn person_properties(person: &Person) -> serde_json::Value { + serde_json::from_slice(&person.properties).expect("changelog properties parse") +} + +#[tokio::test] +async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { + let mut harness = start_fence_harness( + CachedPerson { + properties: serde_json::json!({"a": "target", "b": "target"}), + created_at: 1_700_000_000, + version: 3, + is_identified: false, + ..test_cached_person() + }, + None, + ) + .await; + let op = Uuid::now_v7(); + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![ + // The first snapshot beats the second; both lose to the + // target; sealed versions and created_at feed the fold. + snapshot( + serde_json::json!({"b": "s1", "c": "s1", "d": "s1"}), + 7, + 1_600_000_000, + ), + snapshot(serde_json::json!({"c": "s2", "e": "s2"}), 5, 1_650_000_000), + ], + serde_json::json!({"a": "event"}), + serde_json::json!({"b": "event-ignored", "f": "event"}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .expect("fold returns the document"); + + assert_eq!( + person_properties(&folded), + serde_json::json!({ + "a": "event", // event $set overrides the target + "b": "target", // target beats snapshots; $set_once cannot override + "c": "s1", // earlier snapshot beats later + "d": "s1", + "e": "s2", + "f": "event", // $set_once fills the absent key + }) + ); + assert_eq!( + folded.created_at, 1_600_000_000, + "min over target and snapshots" + ); + assert!(folded.is_identified, "a merge is an identify"); + assert_eq!(folded.version, 8, "max(target 3, sealed 7) + 1"); + + // The response is the changelog record, not just a view of the cache. + let records = changelog_records(&harness); + let produced = records.last().expect("the fold produced a record"); + assert_eq!(person_properties(produced), person_properties(&folded)); + assert_eq!(produced.version, folded.version); + assert!(produced.is_identified); +} + +#[tokio::test] +async fn refolding_changes_no_content_and_only_bumps_the_version() { + let mut harness = start_fence_harness(test_cached_person(), None).await; + let op = Uuid::now_v7(); + let request = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"plan": "free"}), + 4, + 1_650_000_000, + )], + serde_json::json!({"source": "identify"}), + serde_json::json!({}), + ); + + let first = harness + .client + .fold_person_document(with_partition(request.clone(), harness.partition)) + .await + .expect("first fold succeeds") + .into_inner() + .person + .unwrap(); + let second = harness + .client + .fold_person_document(with_partition(request, harness.partition)) + .await + .expect("a re-driven fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!(person_properties(&second), person_properties(&first)); + assert_eq!(second.created_at, first.created_at); + assert_eq!(second.is_identified, first.is_identified); + assert_eq!(second.version, first.version + 1); +} + +#[tokio::test] +async fn a_fold_through_a_foreign_fence_is_rejected_until_released() { + let mut harness = start_fence_harness(test_cached_person(), None).await; + let delete_op = Uuid::now_v7(); + let merge_op = Uuid::now_v7(); + + harness + .client + .fence_person(with_partition( + fence_request(harness.team_id, harness.person_id, &delete_op), + harness.partition, + )) + .await + .expect("the delete op fences the person"); + + let request = fold_request( + harness.team_id, + harness.person_id, + &merge_op, + vec![snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000)], + serde_json::json!({}), + serde_json::json!({}), + ); + let status = harness + .client + .fold_person_document(with_partition(request.clone(), harness.partition)) + .await + .expect_err("a foreign fence blocks the fold"); + assert_eq!(status.code(), Code::FailedPrecondition); + assert_eq!( + status.metadata().get(FENCED_METADATA_KEY).unwrap(), + "delete" + ); + + harness + .client + .release_fence(with_partition( + ReleaseFenceRequest { + team_id: harness.team_id, + person_id: harness.person_id, + person_uuid: String::new(), + op_id: delete_op.to_string(), + outcome: ReleaseOutcome::Aborted.into(), + sealed_version: None, + created_at: 0, + }, + harness.partition, + )) + .await + .expect("the delete op releases its fence"); + + harness + .client + .fold_person_document(with_partition(request, harness.partition)) + .await + .expect("the fold goes through once the fence is gone"); +} + +#[tokio::test] +async fn a_destroyed_target_refuses_the_fold() { + let mut harness = start_fence_harness( + CachedPerson { + is_deleted: true, + ..test_cached_person() + }, + None, + ) + .await; + + let status = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &Uuid::now_v7(), + vec![snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000)], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect_err("a death document cannot be folded into"); + assert_eq!(status.code(), Code::NotFound); +} + +#[tokio::test] +async fn invalid_fold_requests_are_rejected_before_any_work() { + let mut harness = start_fence_harness(test_cached_person(), None).await; + let op = Uuid::now_v7(); + let valid_snapshot = || vec![snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000)]; + + let no_snapshots = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![], + serde_json::json!({}), + serde_json::json!({}), + ); + let bad_op_id = FoldPersonDocumentRequest { + op_id: "not-a-uuid".to_string(), + ..fold_request( + harness.team_id, + harness.person_id, + &op, + valid_snapshot(), + serde_json::json!({}), + serde_json::json!({}), + ) + }; + let non_object_event_set = FoldPersonDocumentRequest { + event_set: serde_json::to_vec(&serde_json::json!(["not", "a", "map"])).unwrap(), + ..fold_request( + harness.team_id, + harness.person_id, + &op, + valid_snapshot(), + serde_json::json!({}), + serde_json::json!({}), + ) + }; + + for (label, request) in [ + ("empty snapshots", no_snapshots), + ("malformed op_id", bad_op_id), + ("non-object event_set", non_object_event_set), + ] { + let status = harness + .client + .fold_person_document(with_partition(request, harness.partition)) + .await + .expect_err(label); + assert_eq!(status.code(), Code::InvalidArgument, "{label}"); + } +} diff --git a/rust/personhog-router/src/proxy.rs b/rust/personhog-router/src/proxy.rs index 91ea94c96c3a..79046f7227e5 100644 --- a/rust/personhog-router/src/proxy.rs +++ b/rust/personhog-router/src/proxy.rs @@ -39,6 +39,7 @@ pub const KNOWN_METHODS: &[&str] = &[ "DeletePersons", "DeletePersonsBatchForTeam", "FencePerson", + "FoldPersonDocument", "GetDistinctIdsForPerson", "GetDistinctIdsForPersons", "GetGroup", @@ -173,6 +174,12 @@ impl RawProxyInner { let (resp, call_ms) = self.raw_proxy_to_leader(req, "ReleaseFence").await; (resp, "leader", call_ms) } + // The merge saga's document write: leader-routed like every + // person write. + "FoldPersonDocument" => { + let (resp, call_ms) = self.raw_proxy_to_leader(req, "FoldPersonDocument").await; + (resp, "leader", call_ms) + } "GetPerson" => { let is_strong = req .headers() diff --git a/rust/personhog-router/tests/common/mod.rs b/rust/personhog-router/tests/common/mod.rs index 2dca30a133d4..cdb2ecde5f9b 100644 --- a/rust/personhog-router/tests/common/mod.rs +++ b/rust/personhog-router/tests/common/mod.rs @@ -712,6 +712,15 @@ impl PersonHogLeader for TestLeaderService { Err(Status::unimplemented("not exercised by router tests")) } + async fn fold_person_document( + &self, + request: Request, + ) -> Result, Status> + { + require_partition_metadata(&request)?; + Err(Status::unimplemented("not exercised by router tests")) + } + async fn get_person( &self, request: Request, diff --git a/rust/property-defs-rs/tests/group_type_resolver.rs b/rust/property-defs-rs/tests/group_type_resolver.rs index efd1e66bc6d4..1e975d9aaa34 100644 --- a/rust/property-defs-rs/tests/group_type_resolver.rs +++ b/rust/property-defs-rs/tests/group_type_resolver.rs @@ -130,6 +130,14 @@ impl PersonHogService for MockPersonHogService { Err(Status::unimplemented("not exercised by this mock")) } + async fn fold_person_document( + &self, + _req: Request, + ) -> Result, Status> + { + Err(Status::unimplemented("not exercised by this mock")) + } + async fn get_group_type_mappings_by_team_ids( &self, _req: Request, From d7fef802533a21038e9d98b0e6f9f56778fb652b Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Thu, 6 Aug 2026 15:44:02 -0700 Subject: [PATCH 02/10] chore(personhog): add fold precedence edge-case tests Cover three fold interactions the main test doesn't exercise: snapshot-contributed keys vs event_set/event_set_once, empty target properties, and created_at filtering of non-positive timestamps. --- .../tests/fence_integration.rs | 129 ++++++++++++++++++ 1 file changed, 129 insertions(+) diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index 25c6f74f71f2..390eb20cb189 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -1307,6 +1307,135 @@ async fn refolding_changes_no_content_and_only_bumps_the_version() { assert_eq!(second.version, first.version + 1); } +#[tokio::test] +async fn event_set_overrides_and_set_once_respects_snapshot_contributed_keys() { + let mut harness = start_fence_harness( + CachedPerson { + properties: serde_json::json!({"target_only": "t"}), + version: 2, + ..test_cached_person() + }, + None, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &Uuid::now_v7(), + vec![snapshot( + serde_json::json!({"from_snapshot": "snap", "contested": "snap"}), + 5, + 1_650_000_000, + )], + serde_json::json!({"contested": "set-wins"}), + serde_json::json!({"from_snapshot": "ignored", "fresh": "once"}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({ + "target_only": "t", + "from_snapshot": "snap", // event_set_once cannot override snapshot-contributed key + "contested": "set-wins", // event_set overrides snapshot-contributed key + "fresh": "once", // event_set_once fills truly absent key + }) + ); +} + +#[tokio::test] +async fn fold_with_empty_target_properties_fills_from_snapshots_and_event() { + let mut harness = start_fence_harness( + CachedPerson { + properties: serde_json::json!({}), + created_at: 1_700_000_000, + version: 10, + ..test_cached_person() + }, + None, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &Uuid::now_v7(), + vec![snapshot(serde_json::json!({"a": "snap"}), 3, 1_650_000_000)], + serde_json::json!({"b": "set"}), + serde_json::json!({"a": "ignored", "c": "once"}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({"a": "snap", "b": "set", "c": "once"}) + ); + assert_eq!( + folded.version, 11, + "target version 10 > sealed 3, so max(10, 3) + 1" + ); +} + +#[tokio::test] +async fn created_at_ignores_non_positive_snapshot_timestamps_and_target_can_be_earliest() { + let mut harness = start_fence_harness( + CachedPerson { + properties: serde_json::json!({}), + created_at: 1_500_000_000, + version: 1, + ..test_cached_person() + }, + None, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &Uuid::now_v7(), + vec![ + snapshot(serde_json::json!({}), 2, 0), + snapshot(serde_json::json!({}), 3, 1_600_000_000), + ], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!( + folded.created_at, 1_500_000_000, + "target is earliest; snapshot with created_at=0 is filtered out" + ); +} + #[tokio::test] async fn a_fold_through_a_foreign_fence_is_rejected_until_released() { let mut harness = start_fence_harness(test_cached_person(), None).await; From a91531e9f595966db60a1255baf68c67d08f6f5d Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Thu, 6 Aug 2026 20:25:06 -0700 Subject: [PATCH 03/10] fix(personhog): deliver semantic leader refusals through the router The router classified every leader FAILED_PRECONDITION as a routing-race bounce, so a fail-closed verification refusal (ReleaseFence's mark check, and the fold's new one) exhausted into a retriable UNAVAILABLE the saga would loop on forever. Refusals now carry an x-semantic-refusal metadata key and the router delivers them unchanged; bare FAILED_PRECONDITION (handoff fences, ownership races, person fences) still bounces. --- rust/personhog-common/src/grpc.rs | 11 +++++ rust/personhog-router/src/backend/leader.rs | 25 +++++++--- rust/personhog-router/tests/common/mod.rs | 11 ++++- .../tests/stash_integration.rs | 46 +++++++++++++++++-- 4 files changed, 82 insertions(+), 11 deletions(-) diff --git a/rust/personhog-common/src/grpc.rs b/rust/personhog-common/src/grpc.rs index 4b944a777dac..7dbeb40e3ffe 100644 --- a/rust/personhog-common/src/grpc.rs +++ b/rust/personhog-common/src/grpc.rs @@ -23,6 +23,17 @@ use tower::{Layer, Service}; /// Header name for client identification in gRPC metadata. const CLIENT_NAME_HEADER: &str = "x-client-name"; +/// Metadata key marking a FAILED_PRECONDITION as a definitive semantic +/// refusal rather than a routing-race rejection. The router bounces and +/// retries bare FAILED_PRECONDITION (handoff fences, ownership races, +/// person fences — conditions that clear in watch or healer time) and +/// surfaces exhaustion as retriable UNAVAILABLE; a response carrying this +/// key is a final answer about the request itself and must pass through +/// to the caller unchanged, or a fail-closed refusal degrades into an +/// infinite retry loop. The value is a short reason slug for +/// observability. +pub const SEMANTIC_REFUSAL_METADATA_KEY: &str = "x-semantic-refusal"; + /// Header name for caller-tag attribution in gRPC metadata. /// Identifies the code path / feature area within a service that /// triggered the request (e.g., "api/feature-flags", "celery/cohort-calculation"). diff --git a/rust/personhog-router/src/backend/leader.rs b/rust/personhog-router/src/backend/leader.rs index 1baaa290725c..a0940b669da3 100644 --- a/rust/personhog-router/src/backend/leader.rs +++ b/rust/personhog-router/src/backend/leader.rs @@ -13,7 +13,7 @@ use tonic::transport::Channel; use tonic::{Code, Status}; use tower::{Service, ServiceExt}; -use personhog_common::grpc::current_client_name; +use personhog_common::grpc::{current_client_name, SEMANTIC_REFUSAL_METADATA_KEY}; use personhog_common::partitioning::partition_for_person; use super::stash::{StashDecision, StashTable}; @@ -317,20 +317,31 @@ impl LeaderBackend { .send_frame(channel, forward_path, method, partition, headers, frame) .await { - // Every leader use of FailedPrecondition is a routing-race - // rejection ("fenced for handoff", "partition not owned") — - // never a semantic client error — so it classifies as a + // A bare FailedPrecondition is a routing-race rejection + // ("fenced for handoff", "partition not owned", a person + // fence that clears in healer time) — it classifies as a // bounce rather than an outcome. Almost all are refusals at // admission, where nothing was attempted; the exception is a // window fenced with its own commit outcome unknown, whose // record may already be in the changelog. That case is not // counted as a possible replay: admission refusals are the // ordinary traffic of every handoff, so counting the class - // would swamp the signal it exists to carry. - Ok((response, _call_ms)) + // would swamp the signal it exists to carry. The one carve-out + // is a semantic refusal (the leader's fail-closed verification + // rejections, marked by metadata): that is a final answer + // about the request, and bouncing it would exhaust into a + // retriable UNAVAILABLE the caller loops on forever. + Ok((response, call_ms)) if grpc_status_code(&response) == Some(Code::FailedPrecondition as i32) => { - ForwardDecision::Bounced(BounceReason::Fenced) + if response + .headers() + .contains_key(SEMANTIC_REFUSAL_METADATA_KEY) + { + ForwardDecision::Delivered { response, call_ms } + } else { + ForwardDecision::Bounced(BounceReason::Fenced) + } } Ok((response, call_ms)) => ForwardDecision::Delivered { response, call_ms }, Err(_status) => ForwardDecision::Bounced(BounceReason::Transport), diff --git a/rust/personhog-router/tests/common/mod.rs b/rust/personhog-router/tests/common/mod.rs index cdb2ecde5f9b..2c72cc001534 100644 --- a/rust/personhog-router/tests/common/mod.rs +++ b/rust/personhog-router/tests/common/mod.rs @@ -718,7 +718,16 @@ impl PersonHogLeader for TestLeaderService { ) -> Result, Status> { require_partition_metadata(&request)?; - Err(Status::unimplemented("not exercised by router tests")) + // Mimics the real leader's fail-closed mark refusal: a definitive + // FAILED_PRECONDITION marked as semantic, which the router must + // deliver rather than bounce. + let mut status = + Status::failed_precondition("op holds no live target mark for this person"); + status.metadata_mut().insert( + personhog_common::grpc::SEMANTIC_REFUSAL_METADATA_KEY, + "fold-unverified".parse().expect("static slug parses"), + ); + Err(status) } async fn get_person( diff --git a/rust/personhog-router/tests/stash_integration.rs b/rust/personhog-router/tests/stash_integration.rs index d9ec353a6723..9771dbfc8ff8 100644 --- a/rust/personhog-router/tests/stash_integration.rs +++ b/rust/personhog-router/tests/stash_integration.rs @@ -17,8 +17,8 @@ use http::HeaderMap; use http_body_util::BodyExt; use personhog_coordination::routing_table::StashHandler; use personhog_proto::personhog::types::v1::{ - GetPersonRequest, GetPersonResponse, UpdatePersonPropertiesRequest, - UpdatePersonPropertiesResponse, + FoldPersonDocumentRequest, FoldPersonDocumentResponse, GetPersonRequest, GetPersonResponse, + Person, UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, }; use personhog_router::backend::{LeaderBackend, LeaderBackendConfig, StashTable}; use personhog_router::stash_handler::RouterStashHandler; @@ -85,7 +85,7 @@ fn mk_request(team_id: i64, person_id: i64, set_email: &str) -> UpdatePersonProp /// Encode a typed request into a gRPC length-prefixed frame, as a client /// would send it over the wire. -fn encode_frame(req: &UpdatePersonPropertiesRequest) -> Bytes { +fn encode_frame(req: &T) -> Bytes { let encoded = req.encode_to_vec(); let mut buf = Vec::with_capacity(5 + encoded.len()); buf.push(0); // not compressed @@ -519,6 +519,46 @@ async fn stash_wait_exceeded_returns_unavailable() { ); } +/// A semantic refusal — the leader's fail-closed verification rejection, +/// FAILED_PRECONDITION marked by metadata — is a final answer, not a +/// routing race: the router must deliver it to the caller unchanged +/// instead of bouncing it into a retriable UNAVAILABLE the saga would +/// loop on forever. +#[tokio::test] +async fn a_semantic_refusal_passes_through_instead_of_bouncing() { + let person = create_test_person(); + let leader_addr = start_test_leader(TestLeaderService::new().with_person(person.clone())).await; + let stash = StashTable::with_bounds(usize::MAX, usize::MAX); + let backend = make_backend(leader_addr, stash).await; + + let partition = backend.partition_for_person(person.team_id, person.id); + let req = FoldPersonDocumentRequest { + team_id: person.team_id, + person_id: person.id, + sealed_snapshots: vec![Person::default()], + event_set: b"{}".to_vec(), + event_set_once: b"{}".to_vec(), + op_id: "0192b4a0-0000-7000-8000-000000000000".to_string(), + }; + let (response, _call_ms) = backend + .forward_or_stash( + "FoldPersonDocument", + partition, + (person.team_id, person.id), + HeaderMap::new(), + encode_frame(&req), + ) + .await; + let code = decode_response::(response) + .await + .expect_err("the refusal must surface as an error"); + assert_eq!( + code, + Code::FailedPrecondition, + "a marked refusal is delivered, not bounced into UNAVAILABLE" + ); +} + /// A drain that races the target leader's fence (a reaffirm's drain-back /// arriving before the owner's resume, or a completion's drain hitting a /// pod mid-cutover) gets FailedPrecondition from the leader — a From 44727673607aa01a3d6febd4fcf24f3cd6253ab4 Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Thu, 6 Aug 2026 20:25:17 -0700 Subject: [PATCH 04/10] fix(personhog): verify the fold's target mark and scope its trim Review fixes for FoldPersonDocument: - The fold now fails closed like ReleaseFence: it refuses unless the op holds the person as a live 'marked' merge target (new target_mark_status; mark_status excludes targets by design), stamped as a semantic refusal so the router delivers it. - An oversized fold trims only what the fold contributed; the target's own keys join the candidates only when its stored document already exceeded the limit, matching the update path's remediation. A trim that cannot reach the hysteresis target retries against the hard ceiling, since that band is still applyable by the writer. - When no applyable document exists (protected keys alone exceed the ceiling), the fold completes without producing instead of committing a record the writer would fail-stop on: outcome=unapplyable metric, error log, warning, and a README residual note. - folds_total increments once per request; the discard arm restores the normalized-object properties, not the raw stored value. - Doc comments state the mark check's read-time residual instead of denying it. Also deflakes at_capacity_new_fences_shed_but_reseals_succeed: both seeded persons can share a partition, and re-creating it wiped the first seed. --- proto/personhog/leader/v1/leader.proto | 6 +- proto/personhog/types/v1/person.proto | 27 +- rust/personhog-common/src/properties.rs | 32 +- rust/personhog-leader/README.md | 15 + rust/personhog-leader/src/fence.rs | 43 ++ rust/personhog-leader/src/service.rs | 145 +++++- .../tests/fence_integration.rs | 435 +++++++++++++++++- 7 files changed, 652 insertions(+), 51 deletions(-) diff --git a/proto/personhog/leader/v1/leader.proto b/proto/personhog/leader/v1/leader.proto index 188754000903..8db2a67b65c6 100644 --- a/proto/personhog/leader/v1/leader.proto +++ b/proto/personhog/leader/v1/leader.proto @@ -62,7 +62,11 @@ service PersonHogLeader { // Safe under at-least-once delivery: re-applying the same fold on // unchanged target state changes no content and only bumps the version. // (The standard re-appliable-merge residual applies — see the request's - // op_id comment.) A target fenced by a + // op_id comment.) The op must hold the target as its live mark in the + // lifecycle database or the fold is refused FAILED_PRECONDITION — + // fail closed, mirroring ReleaseFence; the check is read-time, so it + // closes late re-drives from a settled op, not a fold already in + // flight when the op settles. A target fenced by a // different lifecycle op rejects FAILED_PRECONDITION (the merge target // is marked, never fenced, so this only fires on a ghost fence — the // retry goes through once the healer clears it); a destroyed target diff --git a/proto/personhog/types/v1/person.proto b/proto/personhog/types/v1/person.proto index 4b5c85a7a686..4c46e760eef5 100644 --- a/proto/personhog/types/v1/person.proto +++ b/proto/personhog/types/v1/person.proto @@ -374,14 +374,20 @@ message FoldPersonDocumentRequest { // fills only still-absent keys. bytes event_set = 4; bytes event_set_once = 5; - // The merge saga's op id. Re-applying a fold on unchanged target state - // changes no content and only bumps the version; the id exists for - // fence tolerance and observability, not dedup. The accepted - // at-least-once residual applies: the target is never fenced, so a - // re-driven fold recomputes over writes that landed in between and can - // clobber them where they overlap the event's $set or a snapshot's - // keys — the same residual every re-appliable leader merge carries - // today, closed later by operation identity in the write path. + // The merge saga's op id, verified against the lifecycle database: + // the fold is refused unless this op holds the person as its live + // ('marked') merge target, closing late re-drives from a settled or + // superseded op. The check is read-time only — a fold already past it + // when the op settles can still land — so it narrows the late-fold + // residual rather than eliminating it. Re-applying a fold on + // unchanged target state changes no content and only bumps the + // version; the id is not a dedup key. The accepted at-least-once + // residual applies while the op lives: the target is never fenced, so + // a re-driven fold recomputes over writes that landed in between and + // can clobber them where they overlap the event's $set or a + // snapshot's keys — the same residual every re-appliable leader merge + // carries today, closed later by operation identity in the write + // path. string op_id = 6; } @@ -389,5 +395,10 @@ message FoldPersonDocumentResponse { // The folded document exactly as produced to the changelog: // target-wins properties, created_at = min(target, sources), // is_identified = true, version = max(target, max sealed) + 1. + // One exception: when the target's stored properties violate the size + // constraint beyond remediation (protected keys alone exceed the + // ceiling), no applyable document exists — the fold produces nothing + // and returns the person unchanged, skipping its property and scalar + // effects for this person. Person person = 1; } diff --git a/rust/personhog-common/src/properties.rs b/rust/personhog-common/src/properties.rs index 83debe11f248..d31c1be0d388 100644 --- a/rust/personhog-common/src/properties.rs +++ b/rust/personhog-common/src/properties.rs @@ -456,6 +456,31 @@ pub enum TrimResult { } pub fn trim_properties_to_fit_size(properties: &Value, target_bytes: usize) -> TrimResult { + let candidates: Vec = match properties.as_object() { + Some(map) => { + let mut keys: Vec = map + .keys() + .filter(|k| can_trim_property(k)) + .cloned() + .collect(); + keys.sort(); + keys + } + None => Vec::new(), + }; + trim_properties_with_candidates(properties, target_bytes, candidates) +} + +/// Same contract as [`trim_properties_to_fit_size`], but the caller names +/// which keys may be removed and in what order — removal walks `candidates` +/// front to back. The caller is responsible for excluding protected +/// properties from the list; `CannotFit` when the candidates run out before +/// the document fits. +pub fn trim_properties_with_candidates( + properties: &Value, + target_bytes: usize, + candidates: Vec, +) -> TrimResult { let mut current = jsonb_column_size(properties); if current <= target_bytes { return TrimResult::Fits; @@ -472,14 +497,7 @@ pub fn trim_properties_to_fit_size(properties: &Value, target_bytes: usize) -> T // exact and the deficit shrinks geometrically, bounding the total cost // at O(document) per pass for a handful of passes. This can remove a // key or two more than the strict minimum in padding-heavy edge cases. - // Removal order stays alphabetical, matching the pipeline's trim. let mut trimmed = map.clone(); - let mut candidates: Vec = map - .keys() - .filter(|k| can_trim_property(k)) - .cloned() - .collect(); - candidates.sort(); let mut candidates = candidates.into_iter(); while current > target_bytes { diff --git a/rust/personhog-leader/README.md b/rust/personhog-leader/README.md index 74d76dea48e1..01dd80b73d7a 100644 --- a/rust/personhog-leader/README.md +++ b/rust/personhog-leader/README.md @@ -114,6 +114,21 @@ update: created_at within sane bounds) — corrupt state must never reach the changelog. +The merge saga's fold (`FoldPersonDocument`) cannot reject for size — the +saga would re-drive it forever — so its oversized path always completes: +trim candidates are the fold's own contribution (a within-limit target +never loses a key it held), the target's own keys join only when its +stored document already exceeded the limit, and a trim that cannot reach +the hysteresis target retries against the hard ceiling, since any document +at or under the threshold is still applyable. The residual — a stored +document whose protected keys alone exceed the ceiling, so no applyable +fold document exists — completes without producing: the person keeps its +pre-fold state, the fold's property and scalar effects are skipped, and +the case is surfaced via `personhog_leader_folds_total{outcome= +"unapplyable"}`, an error log, and a size-violation warning. This is an +accepted gap: the alternatives are wedging the saga (reject) or halting +the writer (produce an unapplyable record). + Trims and rejections emit `person_properties_size_violation` ingestion warnings (`src/warnings.rs`), throttled per (team, type) to match the Node pipeline's limiter. The writer-side weld diff --git a/rust/personhog-leader/src/fence.rs b/rust/personhog-leader/src/fence.rs index d2a79e9f5816..041a36f45e60 100644 --- a/rust/personhog-leader/src/fence.rs +++ b/rust/personhog-leader/src/fence.rs @@ -67,6 +67,23 @@ pub const FENCED_METADATA_KEY: &str = "x-person-fenced"; /// Metadata key carrying the fencing operation's id on rejections. pub const FENCED_OP_ID_METADATA_KEY: &str = "x-person-fenced-op-id"; +/// A definitive FAILED_PRECONDITION the router passes through to the +/// caller instead of bouncing. Bare FAILED_PRECONDITION classifies as a +/// routing-race bounce and exhausts into retriable UNAVAILABLE, which +/// would turn a fail-closed verification refusal into an infinite saga +/// retry loop; the marker (see +/// `personhog_common::grpc::SEMANTIC_REFUSAL_METADATA_KEY`) makes the +/// refusal survive the trip. +pub fn semantic_refusal(message: impl Into, reason: &'static str) -> Status { + let mut status = Status::failed_precondition(message.into()); + if let Ok(value) = reason.parse() { + status + .metadata_mut() + .insert(personhog_common::grpc::SEMANTIC_REFUSAL_METADATA_KEY, value); + } + status +} + /// The typed rejection for a write to a fenced person: PERSON_DELETING / /// PERSON_MERGING per the RFC, encoded as FAILED_PRECONDITION plus /// metadata (gRPC has no custom codes). @@ -321,3 +338,29 @@ pub async fn mark_status( .fetch_optional(pool) .await } + +/// The fold's check: the status of the op's mark row claiming this person +/// as its merge target. `None` when the op never claimed the person as +/// target — including when it holds the person under another role, which +/// would make a fold into it a saga bug. A fold must find a live mark here +/// before it may write. The check is read-time only: a fold already past +/// it when the op settles can still land (the window spans the fold +/// computation and the produce), so it closes late re-drives, not the +/// full race — the same at-least-once residual the op_id proto comment +/// states. +pub async fn target_mark_status( + pool: &PgPool, + op_id: Uuid, + team_id: i64, + person_id: i64, +) -> Result, sqlx::Error> { + sqlx::query_scalar( + "SELECT status FROM lifecycle_op_person \ + WHERE op_id = $1 AND team_id = $2 AND person_id = $3 AND role = 'target'", + ) + .bind(op_id) + .bind(team_id as i32) + .bind(person_id) + .fetch_optional(pool) + .await +} diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index d196ffb4d7f0..cf7ab8efe782 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -25,7 +25,10 @@ use crate::cache::{ PersonCacheKey, }; use crate::emitted::{EmittedVersionGuard, EmittedVersions}; -use crate::fence::{fenced_status, mark_status, FenceHealer, FenceMap, FenceState}; +use crate::fence::{ + fenced_status, mark_status, semantic_refusal, target_mark_status, FenceHealer, FenceMap, + FenceState, +}; use crate::fencing::{FencedChangelogProducers, FencedProduceError}; use crate::inflight::InflightTracker; use crate::kafka::produce_person_changelog; @@ -34,7 +37,8 @@ use crate::pg::{load_person_from_pg, PgFallback}; use crate::recovery::ChangelogRecovery; use crate::warnings::{SizeViolationWarning, WarningsProducer}; use personhog_common::properties::{ - jsonb_column_size, sanitize_for_jsonb, trim_properties_to_fit_size, TrimResult, + can_trim_property, jsonb_column_size, sanitize_for_jsonb, trim_properties_to_fit_size, + trim_properties_with_candidates, TrimResult, }; /// Mirrors the config's `fence_map_max_entries` default; production @@ -1245,17 +1249,52 @@ impl PersonHogLeader for PersonHogLeaderService { return Err(Status::not_found("person is destroyed")); } + // The mark row — the fence's source of truth — must vouch for the + // op holding this person as its live merge target before the fold + // may write. The fence check above proves nothing here (the target + // is marked, never fenced), and without this a superseded or + // settled saga runner's late fold would still land. Mirrors + // ReleaseFence: unverifiable requests are refused — fail closed. + let Some(fallback) = &self.fallback else { + return Err(semantic_refusal( + "no lifecycle database configured; refusing to fold", + "no-lifecycle-db", + )); + }; + match target_mark_status(&fallback.pool, op_id, req.team_id, req.person_id).await { + Ok(Some(status)) if status == "marked" => {} + Ok(_) => { + counter!("personhog_leader_fences_total", "action" => "fold_unverified") + .increment(1); + return Err(semantic_refusal( + "op holds no live target mark for this person; refusing to fold", + "fold-unverified", + )); + } + Err(e) => { + tracing::warn!(error = %e, "target mark verification failed; rejecting (fail closed)"); + return Err(Status::unavailable( + "could not verify the lifecycle op against its target mark; retry", + )); + } + } + // The fold: the target wins every key it has; snapshots fill // still-absent keys in request order; then the merge event's $set // overrides and $set_once fills. All inputs are sanitized, so the // merged document is measured in stored form. - let mut folded = person.properties.clone(); - if !folded.is_object() { - folded = serde_json::Value::Object(serde_json::Map::new()); - } + let target_properties = if person.properties.is_object() { + person.properties.clone() + } else { + serde_json::Value::Object(serde_json::Map::new()) + }; + let target_map = target_properties + .as_object() + .expect("target_properties was just normalized to an object"); + let mut folded = target_properties.clone(); let folded_map = folded .as_object_mut() - .expect("folded was just normalized to an object"); + .expect("folded clones the normalized target"); for (snapshot_properties, _, _) in &snapshots { if let Some(map) = snapshot_properties.as_object() { for (key, value) in map { @@ -1279,15 +1318,51 @@ impl PersonHogLeader for PersonHogLeaderService { } // Unlike a property update, a fold cannot be rejected for size: - // the saga would re-drive it forever. Deterministic trimming is - // the only completing behavior, so an oversized fold trims (same - // remediation the update path applies to already-oversized rows), - // and if protected keys alone exceed the target the fold keeps - // the target's stored properties — state admission already - // accepted — discarding the fold's property contribution. + // the saga would re-drive it forever, so trimming is the only + // completing behavior. Trim candidates are the fold's own + // contribution — keys the target did not already hold — so a + // within-limit target never loses a key it had to a fold. The + // target's own keys join the candidates only when its stored + // document already exceeded the limit (the remediation the update + // path applies to already-oversized rows). The trim aims for the + // hysteresis target first and, when that is unreachable, retries + // against the hard ceiling: any document at or under the + // threshold is still applyable by the writer. When neither bound + // is reachable, the fold keeps the target's properties, + // discarding its contribution. let jsonb_size = jsonb_column_size(&folded); + let mut fold_outcome = "folded"; if jsonb_size > self.size_limits.threshold { - match trim_properties_to_fit_size(&folded, self.size_limits.trim_target) { + let target_size = jsonb_column_size(&target_properties); + let target_oversized = target_size >= self.size_limits.threshold; + let mut candidates: Vec = folded + .as_object() + .expect("folded is an object") + .keys() + .filter(|k| !target_map.contains_key(*k) && can_trim_property(k)) + .cloned() + .collect(); + candidates.sort(); + if target_oversized { + let mut own: Vec = target_map + .keys() + .filter(|k| can_trim_property(k)) + .cloned() + .collect(); + own.sort(); + candidates.extend(own); + } + let trim_result = match trim_properties_with_candidates( + &folded, + self.size_limits.trim_target, + candidates.clone(), + ) { + TrimResult::CannotFit => { + trim_properties_with_candidates(&folded, self.size_limits.threshold, candidates) + } + result => result, + }; + match trim_result { TrimResult::Trimmed(trimmed) => { counter!("personhog_leader_properties_trimmed_total").increment(1); self.warnings.emit(&SizeViolationWarning { @@ -1301,8 +1376,6 @@ impl PersonHogLeader for PersonHogLeaderService { } TrimResult::Fits => {} TrimResult::CannotFit => { - counter!("personhog_leader_folds_total", "outcome" => "unremediable") - .increment(1); self.warnings.emit(&SizeViolationWarning { team_id: cache_key.team_id, person_uuid: person.uuid.clone(), @@ -1310,7 +1383,34 @@ impl PersonHogLeader for PersonHogLeaderService { be trimmed; the merged-in properties were discarded" .to_string(), }); - folded = person.properties.clone(); + if target_oversized { + // No applyable document exists: the stored one + // itself violates the size constraint (protected + // keys alone exceed the ceiling). Producing it + // anyway would halt the writer — admission + // promises every acked record applies verbatim, + // and the writer fail-stops on a violation rather + // than skip — and rejecting would wedge the + // saga's re-drive loop. The fold completes + // without producing: this person's fold effects + // (properties and scalars) are skipped. Accepted + // residual — README, "Admission". + counter!("personhog_leader_folds_total", "outcome" => "unapplyable") + .increment(1); + tracing::error!( + team_id = cache_key.team_id, + person_uuid = %person.uuid, + stored_size = target_size, + threshold = self.size_limits.threshold, + "fold target's stored properties exceed the size constraint; \ + skipping the fold's document write" + ); + return Ok(Response::new(FoldPersonDocumentResponse { + person: Some(cached_person_to_proto(&person)), + })); + } + fold_outcome = "unremediable"; + folded = target_properties.clone(); } } } @@ -1360,7 +1460,7 @@ impl PersonHogLeader for PersonHogLeaderService { let proto = self .commit_document(partition, &cache_key, folded_person) .await?; - counter!("personhog_leader_folds_total", "outcome" => "folded").increment(1); + counter!("personhog_leader_folds_total", "outcome" => fold_outcome).increment(1); Ok(Response::new(FoldPersonDocumentResponse { person: Some(proto), @@ -1550,8 +1650,9 @@ impl PersonHogLeader for PersonHogLeaderService { // would rewrite the row's identity on its way out. if let Some(person) = ¤t { if person.uuid != req.person_uuid { - return Err(Status::failed_precondition( + return Err(semantic_refusal( "person_uuid does not match the person being released", + "uuid-mismatch", )); } } @@ -1564,8 +1665,9 @@ impl PersonHogLeader for PersonHogLeaderService { // document. Unverifiable requests are refused — fail // closed. let Some(fallback) = &self.fallback else { - return Err(Status::failed_precondition( + return Err(semantic_refusal( "no lifecycle database configured; refusing to produce a death document", + "no-lifecycle-db", )); }; match mark_status(&fallback.pool, op_id, req.team_id, req.person_id).await { @@ -1581,9 +1683,10 @@ impl PersonHogLeader for PersonHogLeaderService { Ok(_) => { counter!("personhog_leader_fences_total", "action" => "release_unverified") .increment(1); - return Err(Status::failed_precondition( + return Err(semantic_refusal( "op holds no live mark for this person; \ refusing to produce a death document", + "release-unverified", )); } Err(e) => { diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index 390eb20cb189..76ef30377f7e 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -824,6 +824,13 @@ async fn a_committed_release_without_a_live_mark_is_refused() { .await .expect_err("a release with no live mark is refused"); assert_eq!(status.code(), Code::FailedPrecondition); + assert!( + status + .metadata() + .get(personhog_common::grpc::SEMANTIC_REFUSAL_METADATA_KEY) + .is_some(), + "the refusal must be marked semantic for router pass-through" + ); // The person is untouched: had a death document been produced, the // entry would have been evicted and this read would answer not-found. @@ -1126,6 +1133,8 @@ async fn at_capacity_new_fences_shed_but_reseals_succeed() { let (first_id, second_id) = (5000, 5001); for id in [first_id, second_id] { let partition = partition_for_person(team_id, id, NUM_PARTITIONS); + // Both persons can share a partition; re-creating it would wipe + // the first seed. if !cache.has_partition(partition) { cache.create_partition(partition); } @@ -1198,9 +1207,58 @@ fn person_properties(person: &Person) -> serde_json::Value { serde_json::from_slice(&person.properties).expect("changelog properties parse") } +/// The mark rows the fold verifies against — committed by the merge saga +/// before it fences the sources in the real flow. +async fn seed_target_mark( + pool: &sqlx::PgPool, + op: &Uuid, + team_id: i64, + person_id: i64, + status: &str, +) { + sqlx::query( + "INSERT INTO lifecycle_op (op_id, op_type, team_id, step, request) \ + VALUES ($1, 'merge', $2, 'folding', '{}'::jsonb)", + ) + .bind(op) + .bind(team_id as i32) + .execute(pool) + .await + .expect("insert op"); + sqlx::query( + "INSERT INTO lifecycle_op_person (op_id, team_id, person_id, person_uuid, role, status) \ + VALUES ($1, $2, $3, gen_random_uuid(), 'target', $4)", + ) + .bind(op) + .bind(team_id as i32) + .bind(person_id) + .bind(status) + .execute(pool) + .await + .expect("insert target mark"); +} + +/// A fold-ready harness: lifecycle database attached and a live 'marked' +/// target row for `op` — the state the merge saga guarantees before it +/// calls the fold. +async fn start_marked_fold_harness(seed: CachedPerson, op: &Uuid) -> FenceHarness { + let pool = common::create_persons_pool().await; + let harness = start_fence_harness( + seed, + Some(PgFallback { + pool: pool.clone(), + table: "posthog_person".to_string(), + }), + ) + .await; + seed_target_mark(&pool, op, harness.team_id, harness.person_id, "marked").await; + harness +} + #[tokio::test] async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { - let mut harness = start_fence_harness( + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( CachedPerson { properties: serde_json::json!({"a": "target", "b": "target"}), created_at: 1_700_000_000, @@ -1208,10 +1266,9 @@ async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { is_identified: false, ..test_cached_person() }, - None, + &op, ) .await; - let op = Uuid::now_v7(); let folded = harness .client @@ -1269,8 +1326,8 @@ async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { #[tokio::test] async fn refolding_changes_no_content_and_only_bumps_the_version() { - let mut harness = start_fence_harness(test_cached_person(), None).await; let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness(test_cached_person(), &op).await; let request = fold_request( harness.team_id, harness.person_id, @@ -1309,13 +1366,14 @@ async fn refolding_changes_no_content_and_only_bumps_the_version() { #[tokio::test] async fn event_set_overrides_and_set_once_respects_snapshot_contributed_keys() { - let mut harness = start_fence_harness( + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( CachedPerson { properties: serde_json::json!({"target_only": "t"}), version: 2, ..test_cached_person() }, - None, + &op, ) .await; @@ -1325,7 +1383,7 @@ async fn event_set_overrides_and_set_once_respects_snapshot_contributed_keys() { fold_request( harness.team_id, harness.person_id, - &Uuid::now_v7(), + &op, vec![snapshot( serde_json::json!({"from_snapshot": "snap", "contested": "snap"}), 5, @@ -1355,14 +1413,15 @@ async fn event_set_overrides_and_set_once_respects_snapshot_contributed_keys() { #[tokio::test] async fn fold_with_empty_target_properties_fills_from_snapshots_and_event() { - let mut harness = start_fence_harness( + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( CachedPerson { properties: serde_json::json!({}), created_at: 1_700_000_000, version: 10, ..test_cached_person() }, - None, + &op, ) .await; @@ -1372,7 +1431,7 @@ async fn fold_with_empty_target_properties_fills_from_snapshots_and_event() { fold_request( harness.team_id, harness.person_id, - &Uuid::now_v7(), + &op, vec![snapshot(serde_json::json!({"a": "snap"}), 3, 1_650_000_000)], serde_json::json!({"b": "set"}), serde_json::json!({"a": "ignored", "c": "once"}), @@ -1397,14 +1456,15 @@ async fn fold_with_empty_target_properties_fills_from_snapshots_and_event() { #[tokio::test] async fn created_at_ignores_non_positive_snapshot_timestamps_and_target_can_be_earliest() { - let mut harness = start_fence_harness( + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( CachedPerson { properties: serde_json::json!({}), created_at: 1_500_000_000, version: 1, ..test_cached_person() }, - None, + &op, ) .await; @@ -1414,7 +1474,7 @@ async fn created_at_ignores_non_positive_snapshot_timestamps_and_target_can_be_e fold_request( harness.team_id, harness.person_id, - &Uuid::now_v7(), + &op, vec![ snapshot(serde_json::json!({}), 2, 0), snapshot(serde_json::json!({}), 3, 1_600_000_000), @@ -1438,9 +1498,9 @@ async fn created_at_ignores_non_positive_snapshot_timestamps_and_target_can_be_e #[tokio::test] async fn a_fold_through_a_foreign_fence_is_rejected_until_released() { - let mut harness = start_fence_harness(test_cached_person(), None).await; let delete_op = Uuid::now_v7(); let merge_op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness(test_cached_person(), &merge_op).await; harness .client @@ -1573,3 +1633,350 @@ async fn invalid_fold_requests_are_rejected_before_any_work() { assert_eq!(status.code(), Code::InvalidArgument, "{label}"); } } + +/// The fail-closed half of the fold: the request — even with a well-formed +/// op id — is never enough to write to the target. Without a live 'marked' +/// target row vouching for the op, the fold is refused and the target is +/// untouched: a superseded or settled saga runner's late fold must land +/// nowhere. +#[tokio::test] +async fn a_fold_whose_op_holds_no_live_target_mark_is_refused() { + let pool = common::create_persons_pool().await; + let mut harness = start_fence_harness( + test_cached_person(), + Some(PgFallback { + pool: pool.clone(), + table: "posthog_person".to_string(), + }), + ) + .await; + let op = Uuid::now_v7(); + let request = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000)], + serde_json::json!({}), + serde_json::json!({}), + ); + + // No mark row at all: the op never claimed the person. The refusal + // carries the semantic-refusal marker so the router delivers it + // instead of bouncing it into a retriable UNAVAILABLE. + let status = harness + .client + .fold_person_document(with_partition(request.clone(), harness.partition)) + .await + .expect_err("a fold with no mark is refused"); + assert_eq!(status.code(), Code::FailedPrecondition); + assert!( + status + .metadata() + .get(personhog_common::grpc::SEMANTIC_REFUSAL_METADATA_KEY) + .is_some(), + "the refusal must be marked semantic for router pass-through" + ); + + // A settled op: the target row was cleared when the saga completed. + seed_target_mark(&pool, &op, harness.team_id, harness.person_id, "cleared").await; + let status = harness + .client + .fold_person_document(with_partition(request.clone(), harness.partition)) + .await + .expect_err("a settled op's re-driven fold is refused"); + assert_eq!(status.code(), Code::FailedPrecondition); + + // The wrong role: the op holds the person, but not as its target — + // folding into it would be a saga bug. + sqlx::query( + "UPDATE lifecycle_op_person SET role = 'victim', status = 'marked' WHERE op_id = $1", + ) + .bind(op) + .execute(&pool) + .await + .expect("update mark"); + let status = harness + .client + .fold_person_document(with_partition(request.clone(), harness.partition)) + .await + .expect_err("a non-target mark does not authorize the fold"); + assert_eq!(status.code(), Code::FailedPrecondition); + + // The target was untouched through all three refusals. + let read = harness + .client + .get_person(with_partition( + GetPersonRequest { + team_id: harness.team_id, + person_id: harness.person_id, + read_options: None, + }, + harness.partition, + )) + .await + .expect("the person is still readable"); + assert_eq!(read.into_inner().person.unwrap().version, 1); + + // With the live target mark restored, the same request folds — the + // mark was the only thing missing. + sqlx::query("UPDATE lifecycle_op_person SET role = 'target' WHERE op_id = $1") + .bind(op) + .execute(&pool) + .await + .expect("restore mark"); + harness + .client + .fold_person_document(with_partition(request, harness.partition)) + .await + .expect("the fold goes through with a live target mark"); +} + +/// An oversized fold trims only what the fold contributed: the target's +/// own keys — state admission already accepted — survive even when they +/// sort first alphabetically, and the contributed keys absorb the trim. +#[tokio::test] +async fn an_oversized_fold_trims_the_contribution_not_the_targets_keys() { + let op = Uuid::now_v7(); + let target_value = "x".repeat(500_000); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!({"aa_target": target_value.clone()}), + version: 3, + ..test_cached_person() + }, + &op, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"zz_snapshot": "y".repeat(200_000)}), + 7, + 1_650_000_000, + )], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("an oversized fold still completes") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({"aa_target": target_value}), + "the target's key survives; the contributed key absorbed the trim" + ); +} + +/// When trimming the fold's contribution cannot get the document under the +/// limit, the fold discards the contribution entirely and keeps the +/// target's document byte for byte — it never dips into keys a +/// within-limit target already owned. The scalar fold still applies. +#[tokio::test] +async fn an_unremediable_fold_keeps_the_targets_document_untouched() { + let op = Uuid::now_v7(); + // Over the trim target but under the threshold: admitted state that + // leaves no room for any contribution. + let target_value = "x".repeat(600_000); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!({"tt_target": target_value.clone()}), + version: 3, + is_identified: false, + ..test_cached_person() + }, + &op, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"ss_snapshot": "y".repeat(100_000)}), + 7, + 1_650_000_000, + )], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("an unremediable fold still completes") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({"tt_target": target_value}) + ); + assert_eq!( + folded.version, 8, + "the version still folds: max(target 3, sealed 7) + 1" + ); + assert!(folded.is_identified, "the scalar fold still applies"); +} + +/// When the hysteresis trim target is unreachable, the trim retries +/// against the hard ceiling: a document in the band between them is +/// still applyable by the writer, so an already-oversized target folds +/// to an applyable document instead of discarding to its oversized +/// stored state — which the writer would refuse to commit past. +#[tokio::test] +async fn an_oversized_targets_fold_trims_to_the_hard_ceiling() { + let op = Uuid::now_v7(); + let email_value = "e".repeat(550_000); + let mut harness = start_marked_fold_harness( + CachedPerson { + // email is protected and alone overshoots the trim target; + // tt_target is trimmable overflow above the ceiling. + properties: serde_json::json!({"email": email_value.clone(), "tt_target": "x".repeat(200_000)}), + version: 3, + ..test_cached_person() + }, + &op, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"zz_snapshot": "y".repeat(10_000)}), + 7, + 1_650_000_000, + )], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("the fold completes") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({"email": email_value}), + "trimmed to the ceiling: protected key kept, overflow dropped" + ); + assert_eq!(folded.version, 8, "max(target 3, sealed 7) + 1"); +} + +/// When no applyable document exists at all — the target's stored +/// protected keys alone exceed the hard ceiling — the fold completes +/// without producing: committing the oversized document would halt the +/// writer, and rejecting would wedge the saga's re-drive loop. The +/// person comes back unchanged, fold effects skipped. +#[tokio::test] +async fn an_unapplyable_fold_completes_without_producing() { + let op = Uuid::now_v7(); + let email_value = "e".repeat(700_000); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!({"email": email_value.clone()}), + version: 3, + is_identified: false, + ..test_cached_person() + }, + &op, + ) + .await; + + let person = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"zz_snapshot": "y".repeat(10_000)}), + 7, + 1_650_000_000, + )], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("an unapplyable fold still completes") + .into_inner() + .person + .unwrap(); + + // An unchanged version proves nothing was produced: every commit + // bumps it. + assert_eq!(person.version, 3, "no document was produced"); + assert!(!person.is_identified, "scalar effects are skipped too"); + assert_eq!( + person_properties(&person), + serde_json::json!({"email": email_value}) + ); +} + +/// A target whose stored properties are not an object folds as an empty +/// document on every path: the unremediable arm must restore the +/// normalized form, not the raw stored value. +#[tokio::test] +async fn a_non_object_target_stays_an_object_through_the_unremediable_path() { + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!("legacy-scalar"), + ..test_cached_person() + }, + &op, + ) + .await; + + // The only contribution is protected, so nothing is trimmable and the + // contribution is discarded wholesale. + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot( + serde_json::json!({"$initial_utm_source": "y".repeat(700_000)}), + 2, + 1_650_000_000, + )], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("the fold completes") + .into_inner() + .person + .unwrap(); + + assert_eq!(person_properties(&folded), serde_json::json!({})); +} From 6181e9cf4628e4071a86499fe3ce4121fd0b903b Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 09:25:41 -0700 Subject: [PATCH 05/10] fix(personhog): carry last_seen_at through the fold MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase onto the identity/last-seen Person fields left the fold's CachedPerson literal without last_seen_at, breaking the build. The fold now max-merges it across the target and every sealed snapshot — the merged person was last seen whenever any constituent was — matching the update path's only-ever-advances semantics. --- proto/personhog/leader/v1/leader.proto | 5 +-- proto/personhog/types/v1/person.proto | 3 +- rust/personhog-leader/src/service.rs | 34 ++++++++++++++----- .../tests/fence_integration.rs | 22 ++++++++---- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/proto/personhog/leader/v1/leader.proto b/proto/personhog/leader/v1/leader.proto index 8db2a67b65c6..8467339b65a7 100644 --- a/proto/personhog/leader/v1/leader.proto +++ b/proto/personhog/leader/v1/leader.proto @@ -56,8 +56,9 @@ service PersonHogLeader { // changelog, and return it. Properties fold target-wins: the target // keeps every key it has, snapshots fill still-absent keys in request // order, then the merge event's $set overrides and $set_once fills. - // Scalars fold as created_at = min(target, sources) and - // is_identified = true; the version is max(target, max sealed) + 1, a + // Scalars fold as created_at = min(target, sources), + // is_identified = true, and last_seen_at = max(target, sources); the + // version is max(target, max sealed) + 1, a // max-merge that stays above every sealed source's death document. // Safe under at-least-once delivery: re-applying the same fold on // unchanged target state changes no content and only bumps the version. diff --git a/proto/personhog/types/v1/person.proto b/proto/personhog/types/v1/person.proto index 4c46e760eef5..2103a8365030 100644 --- a/proto/personhog/types/v1/person.proto +++ b/proto/personhog/types/v1/person.proto @@ -394,7 +394,8 @@ message FoldPersonDocumentRequest { message FoldPersonDocumentResponse { // The folded document exactly as produced to the changelog: // target-wins properties, created_at = min(target, sources), - // is_identified = true, version = max(target, max sealed) + 1. + // is_identified = true, last_seen_at = max(target, sources), + // version = max(target, max sealed) + 1. // One exception: when the target's stored properties violate the size // constraint beyond remediation (protected keys alone exceed the // ceiling), no applyable document exists — the fold produces nothing diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index cf7ab8efe782..d3520bbb1e7b 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -1197,13 +1197,23 @@ impl PersonHogLeader for PersonHogLeaderService { let mut event_set_once = parse_json_object_field(&req.event_set_once, "event_set_once")?; sanitize_for_jsonb(&mut event_set); sanitize_for_jsonb(&mut event_set_once); - let mut snapshots: Vec<(serde_json::Value, i64, i64)> = - Vec::with_capacity(req.sealed_snapshots.len()); + struct SealedSnapshot { + properties: serde_json::Value, + version: i64, + created_at: i64, + last_seen_at: Option, + } + let mut snapshots: Vec = Vec::with_capacity(req.sealed_snapshots.len()); for snapshot in &req.sealed_snapshots { let mut properties = parse_json_object_field(&snapshot.properties, "sealed snapshot properties")?; sanitize_for_jsonb(&mut properties); - snapshots.push((properties, snapshot.version, snapshot.created_at)); + snapshots.push(SealedSnapshot { + properties, + version: snapshot.version, + created_at: snapshot.created_at, + last_seen_at: snapshot.last_seen_at, + }); } let cache_key = PersonCacheKey { @@ -1295,8 +1305,8 @@ impl PersonHogLeader for PersonHogLeaderService { let folded_map = folded .as_object_mut() .expect("folded clones the normalized target"); - for (snapshot_properties, _, _) in &snapshots { - if let Some(map) = snapshot_properties.as_object() { + for snapshot in &snapshots { + if let Some(map) = snapshot.properties.as_object() { for (key, value) in map { if !folded_map.contains_key(key) { folded_map.insert(key.clone(), value.clone()); @@ -1418,14 +1428,21 @@ impl PersonHogLeader for PersonHogLeaderService { // Scalars: created_at is the min over the target and every // snapshot (ignoring non-positive values a malformed snapshot // could carry); is_identified is unconditionally true — a merge - // is an identify. + // is an identify; last_seen_at max-merges like the update path — + // the merged person was last seen whenever any constituent was + // (snapshot values were already hour-floored when stored). let created_at = snapshots .iter() - .map(|(_, _, snapshot_created_at)| *snapshot_created_at) + .map(|snapshot| snapshot.created_at) .filter(|ts| *ts > 0) .chain(std::iter::once(person.created_at)) .min() .unwrap_or(person.created_at); + let last_seen_at = snapshots + .iter() + .filter_map(|snapshot| snapshot.last_seen_at) + .chain(person.last_seen_at) + .max(); // The version is a max-merge over the target's floor and every // sealed version, plus one: it stays above every source's death @@ -1437,7 +1454,7 @@ impl PersonHogLeader for PersonHogLeaderService { .floor_for(partition, &cache_key, person.version); let max_sealed = snapshots .iter() - .map(|(_, snapshot_version, _)| *snapshot_version) + .map(|snapshot| snapshot.version) .max() .unwrap_or(0); let version = base_version.max(max_sealed).checked_add(1).ok_or_else(|| { @@ -1454,6 +1471,7 @@ impl PersonHogLeader for PersonHogLeaderService { version, is_identified: true, is_deleted: false, + last_seen_at, approx_bytes, }; diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index 76ef30377f7e..c5cda9e62206 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -1264,6 +1264,7 @@ async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { created_at: 1_700_000_000, version: 3, is_identified: false, + last_seen_at: Some(1_700_000_000_000), ..test_cached_person() }, &op, @@ -1279,12 +1280,16 @@ async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { &op, vec![ // The first snapshot beats the second; both lose to the - // target; sealed versions and created_at feed the fold. - snapshot( - serde_json::json!({"b": "s1", "c": "s1", "d": "s1"}), - 7, - 1_600_000_000, - ), + // target; sealed versions, created_at, and last_seen_at + // feed the fold. + Person { + last_seen_at: Some(1_750_000_000_000), + ..snapshot( + serde_json::json!({"b": "s1", "c": "s1", "d": "s1"}), + 7, + 1_600_000_000, + ) + }, snapshot(serde_json::json!({"c": "s2", "e": "s2"}), 5, 1_650_000_000), ], serde_json::json!({"a": "event"}), @@ -1314,6 +1319,11 @@ async fn a_fold_applies_precedence_and_scalars_and_lands_in_the_changelog() { "min over target and snapshots" ); assert!(folded.is_identified, "a merge is an identify"); + assert_eq!( + folded.last_seen_at, + Some(1_750_000_000_000), + "max over target and snapshots" + ); assert_eq!(folded.version, 8, "max(target 3, sealed 7) + 1"); // The response is the changelog record, not just a view of the cache. From 4815479072f0ffb54a6529b78cc420d5d9f32a4c Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 11:05:11 -0700 Subject: [PATCH 06/10] fix(personhog): validate snapshot identity and sanitize the fold target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remaining review fixes on the fold: - Snapshots must carry the target's team, a different person id, and a living document — a mismatch can only be a saga bug, and folding it silently would launder it into the target. - The target's cached properties are sanitized like every other fold input: rows loaded from Postgres or warmed from legacy changelog records can carry NULs and extreme floats the wire inputs cannot. - Sanitization counts now feed the same counters the update path uses. - The version comments state the invariant as computed: the folded version lands at or above each source's death document, not strictly above it. --- proto/personhog/leader/v1/leader.proto | 6 +- rust/personhog-leader/src/service.rs | 52 ++++++++-- .../tests/fence_integration.rs | 94 ++++++++++++++++++- 3 files changed, 141 insertions(+), 11 deletions(-) diff --git a/proto/personhog/leader/v1/leader.proto b/proto/personhog/leader/v1/leader.proto index 8467339b65a7..9af823456b78 100644 --- a/proto/personhog/leader/v1/leader.proto +++ b/proto/personhog/leader/v1/leader.proto @@ -58,8 +58,10 @@ service PersonHogLeader { // order, then the merge event's $set overrides and $set_once fills. // Scalars fold as created_at = min(target, sources), // is_identified = true, and last_seen_at = max(target, sources); the - // version is max(target, max sealed) + 1, a - // max-merge that stays above every sealed source's death document. + // version is max(target, max sealed) + 1 — at or above every sealed + // source's death document, which derives from the same sealed + 1 + // (equal when the highest sealed version dominates; the streams are + // per-person, so the relation is a convention, not a collision guard). // Safe under at-least-once delivery: re-applying the same fold on // unchanged target state changes no content and only bumps the version. // (The standard re-appliable-merge residual applies — see the request's diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index d3520bbb1e7b..af22c42fe0c6 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -38,7 +38,7 @@ use crate::recovery::ChangelogRecovery; use crate::warnings::{SizeViolationWarning, WarningsProducer}; use personhog_common::properties::{ can_trim_property, jsonb_column_size, sanitize_for_jsonb, trim_properties_to_fit_size, - trim_properties_with_candidates, TrimResult, + trim_properties_with_candidates, SanitizeStats, TrimResult, }; /// Mirrors the config's `fence_map_max_entries` default; production @@ -1193,10 +1193,15 @@ impl PersonHogLeader for PersonHogLeaderService { // lock. Snapshot properties were sanitized when the source's // leader cached them, but they crossed the wire since; sanitizing // again keeps the fold's admission guarantee self-contained. + let mut sanitize_totals = SanitizeStats::default(); + let mut track = |stats: SanitizeStats| { + sanitize_totals.nul_strings += stats.nul_strings; + sanitize_totals.clamped_numbers += stats.clamped_numbers; + }; let mut event_set = parse_json_object_field(&req.event_set, "event_set")?; let mut event_set_once = parse_json_object_field(&req.event_set_once, "event_set_once")?; - sanitize_for_jsonb(&mut event_set); - sanitize_for_jsonb(&mut event_set_once); + track(sanitize_for_jsonb(&mut event_set)); + track(sanitize_for_jsonb(&mut event_set_once)); struct SealedSnapshot { properties: serde_json::Value, version: i64, @@ -1205,9 +1210,27 @@ impl PersonHogLeader for PersonHogLeaderService { } let mut snapshots: Vec = Vec::with_capacity(req.sealed_snapshots.len()); for snapshot in &req.sealed_snapshots { + // A snapshot for the wrong person, or a death document, can + // only be a saga bug — folding it silently would launder it + // into the target. + if snapshot.team_id != req.team_id { + return Err(Status::invalid_argument( + "sealed snapshot belongs to a different team than the target", + )); + } + if snapshot.id == req.person_id { + return Err(Status::invalid_argument( + "sealed snapshot is the merge target itself", + )); + } + if snapshot.is_deleted { + return Err(Status::invalid_argument( + "sealed snapshot is a death document; only living sealed state folds", + )); + } let mut properties = parse_json_object_field(&snapshot.properties, "sealed snapshot properties")?; - sanitize_for_jsonb(&mut properties); + track(sanitize_for_jsonb(&mut properties)); snapshots.push(SealedSnapshot { properties, version: snapshot.version, @@ -1293,11 +1316,23 @@ impl PersonHogLeader for PersonHogLeaderService { // still-absent keys in request order; then the merge event's $set // overrides and $set_once fills. All inputs are sanitized, so the // merged document is measured in stored form. - let target_properties = if person.properties.is_object() { + let mut target_properties = if person.properties.is_object() { person.properties.clone() } else { serde_json::Value::Object(serde_json::Map::new()) }; + // The cached state is an input like any other: rows loaded from + // Postgres or warmed from records that predate sanitization can + // carry dirt the wire inputs cannot. + track(sanitize_for_jsonb(&mut target_properties)); + if sanitize_totals.nul_strings > 0 { + counter!("personhog_leader_properties_nul_sanitized_total") + .increment(sanitize_totals.nul_strings); + } + if sanitize_totals.clamped_numbers > 0 { + counter!("personhog_leader_properties_numbers_clamped_total") + .increment(sanitize_totals.clamped_numbers); + } let target_map = target_properties .as_object() .expect("target_properties was just normalized to an object"); @@ -1445,9 +1480,10 @@ impl PersonHogLeader for PersonHogLeaderService { .max(); // The version is a max-merge over the target's floor and every - // sealed version, plus one: it stays above every source's death - // document (produced at sealed + 1 alongside a fold that clears - // max(sealed) + 1), and re-applying the fold only bumps it again + // sealed version, plus one: at or above every source's death + // document, which derives from the same sealed + 1 (equal when + // the highest sealed version dominates — harmless, the streams + // are per-person), and re-applying the fold only bumps it again // — convergent under at-least-once delivery. let base_version = self .emitted_versions diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index c5cda9e62206..b18c4de37721 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -1193,10 +1193,25 @@ fn fold_request( event_set: serde_json::Value, event_set_once: serde_json::Value, ) -> FoldPersonDocumentRequest { + // Unset identity fields get the target's team and a distinct source + // id, mirroring what FencePerson seals; tests probing the identity + // checks set them explicitly. + let sealed_snapshots = snapshots + .into_iter() + .map(|mut snapshot| { + if snapshot.team_id == 0 { + snapshot.team_id = team_id; + } + if snapshot.id == 0 { + snapshot.id = person_id + 1_000; + } + snapshot + }) + .collect(); FoldPersonDocumentRequest { team_id, person_id, - sealed_snapshots: snapshots, + sealed_snapshots, event_set: serde_json::to_vec(&event_set).unwrap(), event_set_once: serde_json::to_vec(&event_set_once).unwrap(), op_id: op_id.to_string(), @@ -1630,10 +1645,47 @@ async fn invalid_fold_requests_are_rejected_before_any_work() { ) }; + let wrong_team = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![Person { + team_id: harness.team_id + 1, + ..snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000) + }], + serde_json::json!({}), + serde_json::json!({}), + ); + let snapshot_is_target = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![Person { + id: harness.person_id, + ..snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000) + }], + serde_json::json!({}), + serde_json::json!({}), + ); + let death_document_snapshot = fold_request( + harness.team_id, + harness.person_id, + &op, + vec![Person { + is_deleted: true, + ..snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000) + }], + serde_json::json!({}), + serde_json::json!({}), + ); + for (label, request) in [ ("empty snapshots", no_snapshots), ("malformed op_id", bad_op_id), ("non-object event_set", non_object_event_set), + ("wrong-team snapshot", wrong_team), + ("snapshot is the target", snapshot_is_target), + ("death-document snapshot", death_document_snapshot), ] { let status = harness .client @@ -1990,3 +2042,43 @@ async fn a_non_object_target_stays_an_object_through_the_unremediable_path() { assert_eq!(person_properties(&folded), serde_json::json!({})); } + +/// Cache dirt on the target — state loaded from Postgres or warmed from +/// records that predate sanitization — is rewritten like every other +/// fold input, so the committed document is measured in stored form. +#[tokio::test] +async fn a_folds_target_cache_dirt_is_sanitized() { + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!({"dirty": "a\u{0000}b"}), + ..test_cached_person() + }, + &op, + ) + .await; + + let folded = harness + .client + .fold_person_document(with_partition( + fold_request( + harness.team_id, + harness.person_id, + &op, + vec![snapshot(serde_json::json!({}), 2, 1_650_000_000)], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({"dirty": "a\u{FFFD}b"}) + ); +} From fdad122426b9112b55ddbbdb7513e3a4a603bb3b Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 14:31:13 -0700 Subject: [PATCH 07/10] feat(personhog): fold precedence follows snapshot ordinals Property precedence rode on request order alone, so a saga re-drive that listed sources differently folded a different document, breaking the fold's convergence claim. sealed_snapshots now pairs each Person with its lifecycle_op_person ordinal; the fold sorts by ordinal itself and rejects duplicates, so precedence is deterministic regardless of how the caller lists the sources. --- .../personhog/service/v1/service_pb.ts | 36 +- .../personhog/personhog/types/v1/person_pb.ts | 352 +++++++++++++++++- .../personhog/types/v1/person_pb2.py | 20 +- .../personhog/types/v1/person_pb2.pyi | 13 +- proto/personhog/leader/v1/leader.proto | 6 +- proto/personhog/types/v1/person.proto | 19 +- rust/personhog-leader/src/service.rs | 32 +- .../tests/fence_integration.rs | 127 ++++++- .../tests/stash_integration.rs | 7 +- 9 files changed, 575 insertions(+), 37 deletions(-) diff --git a/nodejs/src/common/generated/personhog/personhog/service/v1/service_pb.ts b/nodejs/src/common/generated/personhog/personhog/service/v1/service_pb.ts index 1cd58f4cc456..189878db4c7a 100644 --- a/nodejs/src/common/generated/personhog/personhog/service/v1/service_pb.ts +++ b/nodejs/src/common/generated/personhog/personhog/service/v1/service_pb.ts @@ -67,6 +67,10 @@ import type { DeletePersonsBatchForTeamResponseSchema, DeletePersonsRequestSchema, DeletePersonsResponseSchema, + FencePersonRequestSchema, + FencePersonResponseSchema, + FoldPersonDocumentRequestSchema, + FoldPersonDocumentResponseSchema, GetDistinctIdsForPersonRequestSchema, GetDistinctIdsForPersonResponseSchema, GetDistinctIdsForPersonsRequestSchema, @@ -82,6 +86,8 @@ import type { PersonsByDistinctIdsInTeamResponseSchema, PersonsByDistinctIdsResponseSchema, PersonsResponseSchema, + ReleaseFenceRequestSchema, + ReleaseFenceResponseSchema, SetPersonDistinctIdVersionFloorRequestSchema, SetPersonDistinctIdVersionFloorResponseSchema, SetPersonVersionFloorRequestSchema, @@ -99,7 +105,7 @@ import { file_personhog_types_v1_person } from '../../types/v1/person_pb' export const file_personhog_service_v1_service: GenFile = /*@__PURE__*/ fileDesc( - 'CiJwZXJzb25ob2cvc2VydmljZS92MS9zZXJ2aWNlLnByb3RvEhRwZXJzb25ob2cuc2VydmljZS52MTKLKAoQUGVyc29uSG9nU2VydmljZRJYCglHZXRQZXJzb24SJC5wZXJzb25ob2cudHlwZXMudjEuR2V0UGVyc29uUmVxdWVzdBolLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25SZXNwb25zZRJYCgpHZXRQZXJzb25zEiUucGVyc29uaG9nLnR5cGVzLnYxLkdldFBlcnNvbnNSZXF1ZXN0GiMucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbnNSZXNwb25zZRJkCg9HZXRQZXJzb25CeVV1aWQSKi5wZXJzb25ob2cudHlwZXMudjEuR2V0UGVyc29uQnlVdWlkUmVxdWVzdBolLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25SZXNwb25zZRJmChFHZXRQZXJzb25zQnlVdWlkcxIsLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlVdWlkc1JlcXVlc3QaIy5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc1Jlc3BvbnNlEnAKFUdldFBlcnNvbkJ5RGlzdGluY3RJZBIwLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25CeURpc3RpbmN0SWRSZXF1ZXN0GiUucGVyc29uaG9nLnR5cGVzLnYxLkdldFBlcnNvblJlc3BvbnNlEpEBCh1HZXRQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbRI4LnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbVJlcXVlc3QaNi5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc0J5RGlzdGluY3RJZHNJblRlYW1SZXNwb25zZRJ/ChdHZXRQZXJzb25zQnlEaXN0aW5jdElkcxIyLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlEaXN0aW5jdElkc1JlcXVlc3QaMC5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc0J5RGlzdGluY3RJZHNSZXNwb25zZRKCAQoXR2V0RGlzdGluY3RJZHNGb3JQZXJzb24SMi5wZXJzb25ob2cudHlwZXMudjEuR2V0RGlzdGluY3RJZHNGb3JQZXJzb25SZXF1ZXN0GjMucGVyc29uaG9nLnR5cGVzLnYxLkdldERpc3RpbmN0SWRzRm9yUGVyc29uUmVzcG9uc2UShQEKGEdldERpc3RpbmN0SWRzRm9yUGVyc29ucxIzLnBlcnNvbmhvZy50eXBlcy52MS5HZXREaXN0aW5jdElkc0ZvclBlcnNvbnNSZXF1ZXN0GjQucGVyc29uaG9nLnR5cGVzLnYxLkdldERpc3RpbmN0SWRzRm9yUGVyc29uc1Jlc3BvbnNlEogBChlHZXRIYXNoS2V5T3ZlcnJpZGVDb250ZXh0EjQucGVyc29uaG9nLnR5cGVzLnYxLkdldEhhc2hLZXlPdmVycmlkZUNvbnRleHRSZXF1ZXN0GjUucGVyc29uaG9nLnR5cGVzLnYxLkdldEhhc2hLZXlPdmVycmlkZUNvbnRleHRSZXNwb25zZRJ/ChZVcHNlcnRIYXNoS2V5T3ZlcnJpZGVzEjEucGVyc29uaG9nLnR5cGVzLnYxLlVwc2VydEhhc2hLZXlPdmVycmlkZXNSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLlVwc2VydEhhc2hLZXlPdmVycmlkZXNSZXNwb25zZRKUAQodRGVsZXRlSGFzaEtleU92ZXJyaWRlc0J5VGVhbXMSOC5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlSGFzaEtleU92ZXJyaWRlc0J5VGVhbXNSZXF1ZXN0GjkucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUhhc2hLZXlPdmVycmlkZXNCeVRlYW1zUmVzcG9uc2USdwoVQ2hlY2tDb2hvcnRNZW1iZXJzaGlwEjAucGVyc29uaG9nLnR5cGVzLnYxLkNoZWNrQ29ob3J0TWVtYmVyc2hpcFJlcXVlc3QaLC5wZXJzb25ob2cudHlwZXMudjEuQ29ob3J0TWVtYmVyc2hpcFJlc3BvbnNlEnMKEkNvdW50Q29ob3J0TWVtYmVycxItLnBlcnNvbmhvZy50eXBlcy52MS5Db3VudENvaG9ydE1lbWJlcnNSZXF1ZXN0Gi4ucGVyc29uaG9nLnR5cGVzLnYxLkNvdW50Q29ob3J0TWVtYmVyc1Jlc3BvbnNlEnMKEkRlbGV0ZUNvaG9ydE1lbWJlchItLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVDb2hvcnRNZW1iZXJSZXF1ZXN0Gi4ucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUNvaG9ydE1lbWJlclJlc3BvbnNlEoIBChdEZWxldGVDb2hvcnRNZW1iZXJzQnVsaxIyLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVDb2hvcnRNZW1iZXJzQnVsa1JlcXVlc3QaMy5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlQ29ob3J0TWVtYmVyc0J1bGtSZXNwb25zZRJ2ChNJbnNlcnRDb2hvcnRNZW1iZXJzEi4ucGVyc29uaG9nLnR5cGVzLnYxLkluc2VydENvaG9ydE1lbWJlcnNSZXF1ZXN0Gi8ucGVyc29uaG9nLnR5cGVzLnYxLkluc2VydENvaG9ydE1lbWJlcnNSZXNwb25zZRJ2ChNMaXN0Q29ob3J0TWVtYmVySWRzEi4ucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RDb2hvcnRNZW1iZXJJZHNSZXF1ZXN0Gi8ucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RDb2hvcnRNZW1iZXJJZHNSZXNwb25zZRJVCghHZXRHcm91cBIjLnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFJlcXVlc3QaJC5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBSZXNwb25zZRJVCglHZXRHcm91cHMSJC5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBzUmVxdWVzdBoiLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cHNSZXNwb25zZRJnCg5HZXRHcm91cHNCYXRjaBIpLnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cHNCYXRjaFJlcXVlc3QaKi5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBzQmF0Y2hSZXNwb25zZRJbCgpMaXN0R3JvdXBzEiUucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RHcm91cHNSZXF1ZXN0GiYucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RHcm91cHNSZXNwb25zZRKGAQocR2V0R3JvdXBUeXBlTWFwcGluZ3NCeVRlYW1JZBI3LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkUmVxdWVzdBotLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cFR5cGVNYXBwaW5nc1Jlc3BvbnNlEo0BCh1HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkcxI4LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkc1JlcXVlc3QaMi5wZXJzb25ob2cudHlwZXMudjEuR3JvdXBUeXBlTWFwcGluZ3NCYXRjaFJlc3BvbnNlEowBCh9HZXRHcm91cFR5cGVNYXBwaW5nc0J5UHJvamVjdElkEjoucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRSZXF1ZXN0Gi0ucGVyc29uaG9nLnR5cGVzLnYxLkdyb3VwVHlwZU1hcHBpbmdzUmVzcG9uc2USkwEKIEdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRzEjsucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRzUmVxdWVzdBoyLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cFR5cGVNYXBwaW5nc0JhdGNoUmVzcG9uc2USnQEKIEdldEdyb3VwVHlwZU1hcHBpbmdCeURhc2hib2FyZElkEjsucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdCeURhc2hib2FyZElkUmVxdWVzdBo8LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nQnlEYXNoYm9hcmRJZFJlc3BvbnNlEn8KFkNvdW50R3JvdXBUeXBlTWFwcGluZ3MSMS5wZXJzb25ob2cudHlwZXMudjEuQ291bnRHcm91cFR5cGVNYXBwaW5nc1JlcXVlc3QaMi5wZXJzb25ob2cudHlwZXMudjEuQ291bnRHcm91cFR5cGVNYXBwaW5nc1Jlc3BvbnNlEl4KC0NyZWF0ZUdyb3VwEiYucGVyc29uaG9nLnR5cGVzLnYxLkNyZWF0ZUdyb3VwUmVxdWVzdBonLnBlcnNvbmhvZy50eXBlcy52MS5DcmVhdGVHcm91cFJlc3BvbnNlEl4KC1VwZGF0ZUdyb3VwEiYucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwUmVxdWVzdBonLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVHcm91cFJlc3BvbnNlEoUBChhEZWxldGVHcm91cHNCYXRjaEZvclRlYW0SMy5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlR3JvdXBzQmF0Y2hGb3JUZWFtUmVxdWVzdBo0LnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVHcm91cHNCYXRjaEZvclRlYW1SZXNwb25zZRJ/ChZVcGRhdGVHcm91cFR5cGVNYXBwaW5nEjEucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwVHlwZU1hcHBpbmdSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwVHlwZU1hcHBpbmdSZXNwb25zZRJ/ChZEZWxldGVHcm91cFR5cGVNYXBwaW5nEjEucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdSZXNwb25zZRKmAQojRGVsZXRlR3JvdXBUeXBlTWFwcGluZ3NCYXRjaEZvclRlYW0SPi5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlR3JvdXBUeXBlTWFwcGluZ3NCYXRjaEZvclRlYW1SZXF1ZXN0Gj8ucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdzQmF0Y2hGb3JUZWFtUmVzcG9uc2USfwoWVXBkYXRlUGVyc29uUHJvcGVydGllcxIxLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVQZXJzb25Qcm9wZXJ0aWVzUmVxdWVzdBoyLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVQZXJzb25Qcm9wZXJ0aWVzUmVzcG9uc2USZAoNRGVsZXRlUGVyc29ucxIoLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25zUmVxdWVzdBopLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25zUmVzcG9uc2USiAEKGURlbGV0ZVBlcnNvbnNCYXRjaEZvclRlYW0SNC5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29uc0JhdGNoRm9yVGVhbVJlcXVlc3QaNS5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29uc0JhdGNoRm9yVGVhbVJlc3BvbnNlErIBCidEZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW0SQi5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29ubGVzc0Rpc3RpbmN0SWRzQmF0Y2hGb3JUZWFtUmVxdWVzdBpDLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW1SZXNwb25zZRJeCgtTcGxpdFBlcnNvbhImLnBlcnNvbmhvZy50eXBlcy52MS5TcGxpdFBlcnNvblJlcXVlc3QaJy5wZXJzb25ob2cudHlwZXMudjEuU3BsaXRQZXJzb25SZXNwb25zZRKaAQofU2V0UGVyc29uRGlzdGluY3RJZFZlcnNpb25GbG9vchI6LnBlcnNvbmhvZy50eXBlcy52MS5TZXRQZXJzb25EaXN0aW5jdElkVmVyc2lvbkZsb29yUmVxdWVzdBo7LnBlcnNvbmhvZy50eXBlcy52MS5TZXRQZXJzb25EaXN0aW5jdElkVmVyc2lvbkZsb29yUmVzcG9uc2USfAoVU2V0UGVyc29uVmVyc2lvbkZsb29yEjAucGVyc29uaG9nLnR5cGVzLnYxLlNldFBlcnNvblZlcnNpb25GbG9vclJlcXVlc3QaMS5wZXJzb25ob2cudHlwZXMudjEuU2V0UGVyc29uVmVyc2lvbkZsb29yUmVzcG9uc2ViBnByb3RvMw', + 'CiJwZXJzb25ob2cvc2VydmljZS92MS9zZXJ2aWNlLnByb3RvEhRwZXJzb25ob2cuc2VydmljZS52MTLDKgoQUGVyc29uSG9nU2VydmljZRJYCglHZXRQZXJzb24SJC5wZXJzb25ob2cudHlwZXMudjEuR2V0UGVyc29uUmVxdWVzdBolLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25SZXNwb25zZRJYCgpHZXRQZXJzb25zEiUucGVyc29uaG9nLnR5cGVzLnYxLkdldFBlcnNvbnNSZXF1ZXN0GiMucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbnNSZXNwb25zZRJkCg9HZXRQZXJzb25CeVV1aWQSKi5wZXJzb25ob2cudHlwZXMudjEuR2V0UGVyc29uQnlVdWlkUmVxdWVzdBolLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25SZXNwb25zZRJmChFHZXRQZXJzb25zQnlVdWlkcxIsLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlVdWlkc1JlcXVlc3QaIy5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc1Jlc3BvbnNlEnAKFUdldFBlcnNvbkJ5RGlzdGluY3RJZBIwLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25CeURpc3RpbmN0SWRSZXF1ZXN0GiUucGVyc29uaG9nLnR5cGVzLnYxLkdldFBlcnNvblJlc3BvbnNlEpEBCh1HZXRQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbRI4LnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbVJlcXVlc3QaNi5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc0J5RGlzdGluY3RJZHNJblRlYW1SZXNwb25zZRJ/ChdHZXRQZXJzb25zQnlEaXN0aW5jdElkcxIyLnBlcnNvbmhvZy50eXBlcy52MS5HZXRQZXJzb25zQnlEaXN0aW5jdElkc1JlcXVlc3QaMC5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uc0J5RGlzdGluY3RJZHNSZXNwb25zZRKCAQoXR2V0RGlzdGluY3RJZHNGb3JQZXJzb24SMi5wZXJzb25ob2cudHlwZXMudjEuR2V0RGlzdGluY3RJZHNGb3JQZXJzb25SZXF1ZXN0GjMucGVyc29uaG9nLnR5cGVzLnYxLkdldERpc3RpbmN0SWRzRm9yUGVyc29uUmVzcG9uc2UShQEKGEdldERpc3RpbmN0SWRzRm9yUGVyc29ucxIzLnBlcnNvbmhvZy50eXBlcy52MS5HZXREaXN0aW5jdElkc0ZvclBlcnNvbnNSZXF1ZXN0GjQucGVyc29uaG9nLnR5cGVzLnYxLkdldERpc3RpbmN0SWRzRm9yUGVyc29uc1Jlc3BvbnNlEogBChlHZXRIYXNoS2V5T3ZlcnJpZGVDb250ZXh0EjQucGVyc29uaG9nLnR5cGVzLnYxLkdldEhhc2hLZXlPdmVycmlkZUNvbnRleHRSZXF1ZXN0GjUucGVyc29uaG9nLnR5cGVzLnYxLkdldEhhc2hLZXlPdmVycmlkZUNvbnRleHRSZXNwb25zZRJ/ChZVcHNlcnRIYXNoS2V5T3ZlcnJpZGVzEjEucGVyc29uaG9nLnR5cGVzLnYxLlVwc2VydEhhc2hLZXlPdmVycmlkZXNSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLlVwc2VydEhhc2hLZXlPdmVycmlkZXNSZXNwb25zZRKUAQodRGVsZXRlSGFzaEtleU92ZXJyaWRlc0J5VGVhbXMSOC5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlSGFzaEtleU92ZXJyaWRlc0J5VGVhbXNSZXF1ZXN0GjkucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUhhc2hLZXlPdmVycmlkZXNCeVRlYW1zUmVzcG9uc2USdwoVQ2hlY2tDb2hvcnRNZW1iZXJzaGlwEjAucGVyc29uaG9nLnR5cGVzLnYxLkNoZWNrQ29ob3J0TWVtYmVyc2hpcFJlcXVlc3QaLC5wZXJzb25ob2cudHlwZXMudjEuQ29ob3J0TWVtYmVyc2hpcFJlc3BvbnNlEnMKEkNvdW50Q29ob3J0TWVtYmVycxItLnBlcnNvbmhvZy50eXBlcy52MS5Db3VudENvaG9ydE1lbWJlcnNSZXF1ZXN0Gi4ucGVyc29uaG9nLnR5cGVzLnYxLkNvdW50Q29ob3J0TWVtYmVyc1Jlc3BvbnNlEnMKEkRlbGV0ZUNvaG9ydE1lbWJlchItLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVDb2hvcnRNZW1iZXJSZXF1ZXN0Gi4ucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUNvaG9ydE1lbWJlclJlc3BvbnNlEoIBChdEZWxldGVDb2hvcnRNZW1iZXJzQnVsaxIyLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVDb2hvcnRNZW1iZXJzQnVsa1JlcXVlc3QaMy5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlQ29ob3J0TWVtYmVyc0J1bGtSZXNwb25zZRJ2ChNJbnNlcnRDb2hvcnRNZW1iZXJzEi4ucGVyc29uaG9nLnR5cGVzLnYxLkluc2VydENvaG9ydE1lbWJlcnNSZXF1ZXN0Gi8ucGVyc29uaG9nLnR5cGVzLnYxLkluc2VydENvaG9ydE1lbWJlcnNSZXNwb25zZRJ2ChNMaXN0Q29ob3J0TWVtYmVySWRzEi4ucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RDb2hvcnRNZW1iZXJJZHNSZXF1ZXN0Gi8ucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RDb2hvcnRNZW1iZXJJZHNSZXNwb25zZRJVCghHZXRHcm91cBIjLnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFJlcXVlc3QaJC5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBSZXNwb25zZRJVCglHZXRHcm91cHMSJC5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBzUmVxdWVzdBoiLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cHNSZXNwb25zZRJnCg5HZXRHcm91cHNCYXRjaBIpLnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cHNCYXRjaFJlcXVlc3QaKi5wZXJzb25ob2cudHlwZXMudjEuR2V0R3JvdXBzQmF0Y2hSZXNwb25zZRJbCgpMaXN0R3JvdXBzEiUucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RHcm91cHNSZXF1ZXN0GiYucGVyc29uaG9nLnR5cGVzLnYxLkxpc3RHcm91cHNSZXNwb25zZRKGAQocR2V0R3JvdXBUeXBlTWFwcGluZ3NCeVRlYW1JZBI3LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkUmVxdWVzdBotLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cFR5cGVNYXBwaW5nc1Jlc3BvbnNlEo0BCh1HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkcxI4LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nc0J5VGVhbUlkc1JlcXVlc3QaMi5wZXJzb25ob2cudHlwZXMudjEuR3JvdXBUeXBlTWFwcGluZ3NCYXRjaFJlc3BvbnNlEowBCh9HZXRHcm91cFR5cGVNYXBwaW5nc0J5UHJvamVjdElkEjoucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRSZXF1ZXN0Gi0ucGVyc29uaG9nLnR5cGVzLnYxLkdyb3VwVHlwZU1hcHBpbmdzUmVzcG9uc2USkwEKIEdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRzEjsucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdzQnlQcm9qZWN0SWRzUmVxdWVzdBoyLnBlcnNvbmhvZy50eXBlcy52MS5Hcm91cFR5cGVNYXBwaW5nc0JhdGNoUmVzcG9uc2USnQEKIEdldEdyb3VwVHlwZU1hcHBpbmdCeURhc2hib2FyZElkEjsucGVyc29uaG9nLnR5cGVzLnYxLkdldEdyb3VwVHlwZU1hcHBpbmdCeURhc2hib2FyZElkUmVxdWVzdBo8LnBlcnNvbmhvZy50eXBlcy52MS5HZXRHcm91cFR5cGVNYXBwaW5nQnlEYXNoYm9hcmRJZFJlc3BvbnNlEn8KFkNvdW50R3JvdXBUeXBlTWFwcGluZ3MSMS5wZXJzb25ob2cudHlwZXMudjEuQ291bnRHcm91cFR5cGVNYXBwaW5nc1JlcXVlc3QaMi5wZXJzb25ob2cudHlwZXMudjEuQ291bnRHcm91cFR5cGVNYXBwaW5nc1Jlc3BvbnNlEl4KC0NyZWF0ZUdyb3VwEiYucGVyc29uaG9nLnR5cGVzLnYxLkNyZWF0ZUdyb3VwUmVxdWVzdBonLnBlcnNvbmhvZy50eXBlcy52MS5DcmVhdGVHcm91cFJlc3BvbnNlEl4KC1VwZGF0ZUdyb3VwEiYucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwUmVxdWVzdBonLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVHcm91cFJlc3BvbnNlEoUBChhEZWxldGVHcm91cHNCYXRjaEZvclRlYW0SMy5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlR3JvdXBzQmF0Y2hGb3JUZWFtUmVxdWVzdBo0LnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVHcm91cHNCYXRjaEZvclRlYW1SZXNwb25zZRJ/ChZVcGRhdGVHcm91cFR5cGVNYXBwaW5nEjEucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwVHlwZU1hcHBpbmdSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLlVwZGF0ZUdyb3VwVHlwZU1hcHBpbmdSZXNwb25zZRJ/ChZEZWxldGVHcm91cFR5cGVNYXBwaW5nEjEucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdSZXF1ZXN0GjIucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdSZXNwb25zZRKmAQojRGVsZXRlR3JvdXBUeXBlTWFwcGluZ3NCYXRjaEZvclRlYW0SPi5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlR3JvdXBUeXBlTWFwcGluZ3NCYXRjaEZvclRlYW1SZXF1ZXN0Gj8ucGVyc29uaG9nLnR5cGVzLnYxLkRlbGV0ZUdyb3VwVHlwZU1hcHBpbmdzQmF0Y2hGb3JUZWFtUmVzcG9uc2USfwoWVXBkYXRlUGVyc29uUHJvcGVydGllcxIxLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVQZXJzb25Qcm9wZXJ0aWVzUmVxdWVzdBoyLnBlcnNvbmhvZy50eXBlcy52MS5VcGRhdGVQZXJzb25Qcm9wZXJ0aWVzUmVzcG9uc2USXgoLRmVuY2VQZXJzb24SJi5wZXJzb25ob2cudHlwZXMudjEuRmVuY2VQZXJzb25SZXF1ZXN0GicucGVyc29uaG9nLnR5cGVzLnYxLkZlbmNlUGVyc29uUmVzcG9uc2USYQoMUmVsZWFzZUZlbmNlEicucGVyc29uaG9nLnR5cGVzLnYxLlJlbGVhc2VGZW5jZVJlcXVlc3QaKC5wZXJzb25ob2cudHlwZXMudjEuUmVsZWFzZUZlbmNlUmVzcG9uc2UScwoSRm9sZFBlcnNvbkRvY3VtZW50Ei0ucGVyc29uaG9nLnR5cGVzLnYxLkZvbGRQZXJzb25Eb2N1bWVudFJlcXVlc3QaLi5wZXJzb25ob2cudHlwZXMudjEuRm9sZFBlcnNvbkRvY3VtZW50UmVzcG9uc2USZAoNRGVsZXRlUGVyc29ucxIoLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25zUmVxdWVzdBopLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25zUmVzcG9uc2USiAEKGURlbGV0ZVBlcnNvbnNCYXRjaEZvclRlYW0SNC5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29uc0JhdGNoRm9yVGVhbVJlcXVlc3QaNS5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29uc0JhdGNoRm9yVGVhbVJlc3BvbnNlErIBCidEZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW0SQi5wZXJzb25ob2cudHlwZXMudjEuRGVsZXRlUGVyc29ubGVzc0Rpc3RpbmN0SWRzQmF0Y2hGb3JUZWFtUmVxdWVzdBpDLnBlcnNvbmhvZy50eXBlcy52MS5EZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW1SZXNwb25zZRJeCgtTcGxpdFBlcnNvbhImLnBlcnNvbmhvZy50eXBlcy52MS5TcGxpdFBlcnNvblJlcXVlc3QaJy5wZXJzb25ob2cudHlwZXMudjEuU3BsaXRQZXJzb25SZXNwb25zZRKaAQofU2V0UGVyc29uRGlzdGluY3RJZFZlcnNpb25GbG9vchI6LnBlcnNvbmhvZy50eXBlcy52MS5TZXRQZXJzb25EaXN0aW5jdElkVmVyc2lvbkZsb29yUmVxdWVzdBo7LnBlcnNvbmhvZy50eXBlcy52MS5TZXRQZXJzb25EaXN0aW5jdElkVmVyc2lvbkZsb29yUmVzcG9uc2USfAoVU2V0UGVyc29uVmVyc2lvbkZsb29yEjAucGVyc29uaG9nLnR5cGVzLnYxLlNldFBlcnNvblZlcnNpb25GbG9vclJlcXVlc3QaMS5wZXJzb25ob2cudHlwZXMudjEuU2V0UGVyc29uVmVyc2lvbkZsb29yUmVzcG9uc2ViBnByb3RvMw', [ file_personhog_types_v1_person, file_personhog_types_v1_group, @@ -415,6 +421,34 @@ export const PersonHogService: GenService<{ input: typeof UpdatePersonPropertiesRequestSchema output: typeof UpdatePersonPropertiesResponseSchema } + /** + * Lifecycle fence RPCs (routed to leader). Internal: called only by the + * lifecycle saga runner in personhog-identity — see leader.proto for + * semantics. + * + * @generated from rpc personhog.service.v1.PersonHogService.FencePerson + */ + fencePerson: { + methodKind: 'unary' + input: typeof FencePersonRequestSchema + output: typeof FencePersonResponseSchema + } + /** + * @generated from rpc personhog.service.v1.PersonHogService.ReleaseFence + */ + releaseFence: { + methodKind: 'unary' + input: typeof ReleaseFenceRequestSchema + output: typeof ReleaseFenceResponseSchema + } + /** + * @generated from rpc personhog.service.v1.PersonHogService.FoldPersonDocument + */ + foldPersonDocument: { + methodKind: 'unary' + input: typeof FoldPersonDocumentRequestSchema + output: typeof FoldPersonDocumentResponseSchema + } /** * Person deletes * WARNING: This is a write operation on person data. It should route to the leader diff --git a/nodejs/src/common/generated/personhog/personhog/types/v1/person_pb.ts b/nodejs/src/common/generated/personhog/personhog/types/v1/person_pb.ts index 76adaaa2205a..387a19890417 100644 --- a/nodejs/src/common/generated/personhog/personhog/types/v1/person_pb.ts +++ b/nodejs/src/common/generated/personhog/personhog/types/v1/person_pb.ts @@ -1,8 +1,8 @@ // @generated by protoc-gen-es v2.11.0 with parameter "target=ts" // @generated from file personhog/types/v1/person.proto (package personhog.types.v1, syntax proto3) import type { Message } from '@bufbuild/protobuf' -import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2' -import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2' +import type { GenEnum, GenFile, GenMessage } from '@bufbuild/protobuf/codegenv2' +import { enumDesc, fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv2' import type { ReadOptions, TeamDistinctId } from './common_pb' import { file_personhog_types_v1_common } from './common_pb' @@ -13,7 +13,7 @@ import { file_personhog_types_v1_common } from './common_pb' export const file_personhog_types_v1_person: GenFile = /*@__PURE__*/ fileDesc( - 'Ch9wZXJzb25ob2cvdHlwZXMvdjEvcGVyc29uLnByb3RvEhJwZXJzb25ob2cudHlwZXMudjEingIKBlBlcnNvbhIKCgJpZBgBIAEoAxIMCgR1dWlkGAIgASgJEg8KB3RlYW1faWQYAyABKAMSEgoKcHJvcGVydGllcxgEIAEoDBIiChpwcm9wZXJ0aWVzX2xhc3RfdXBkYXRlZF9hdBgFIAEoDBIhChlwcm9wZXJ0aWVzX2xhc3Rfb3BlcmF0aW9uGAYgASgMEhIKCmNyZWF0ZWRfYXQYByABKAMSDwoHdmVyc2lvbhgIIAEoAxIVCg1pc19pZGVudGlmaWVkGAkgASgIEhcKCmlzX3VzZXJfaWQYCiABKAhIAIgBARIZCgxsYXN0X3NlZW5fYXQYCyABKANIAYgBAUINCgtfaXNfdXNlcl9pZEIPCg1fbGFzdF9zZWVuX2F0Ik4KFURpc3RpbmN0SWRXaXRoVmVyc2lvbhITCgtkaXN0aW5jdF9pZBgBIAEoCRIUCgd2ZXJzaW9uGAIgASgDSACIAQFCCgoIX3ZlcnNpb24iaAoVUGVyc29uV2l0aERpc3RpbmN0SWRzEhMKC2Rpc3RpbmN0X2lkGAEgASgJEi8KBnBlcnNvbhgCIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25IAIgBAUIJCgdfcGVyc29uImcKEVBlcnNvbkRpc3RpbmN0SWRzEhEKCXBlcnNvbl9pZBgBIAEoAxI/CgxkaXN0aW5jdF9pZHMYAiADKAsyKS5wZXJzb25ob2cudHlwZXMudjEuRGlzdGluY3RJZFdpdGhWZXJzaW9uIocBChhQZXJzb25XaXRoVGVhbURpc3RpbmN0SWQSLwoDa2V5GAEgASgLMiIucGVyc29uaG9nLnR5cGVzLnYxLlRlYW1EaXN0aW5jdElkEi8KBnBlcnNvbhgCIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25IAIgBAUIJCgdfcGVyc29uIm0KEEdldFBlcnNvblJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIRCglwZXJzb25faWQYAiABKAMSNQoMcmVhZF9vcHRpb25zGAMgASgLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlJlYWRPcHRpb25zIk8KEUdldFBlcnNvblJlc3BvbnNlEi8KBnBlcnNvbhgBIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25IAIgBAUIJCgdfcGVyc29uIm8KEUdldFBlcnNvbnNSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEgoKcGVyc29uX2lkcxgCIAMoAxI1CgxyZWFkX29wdGlvbnMYAyABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMiUwoPUGVyc29uc1Jlc3BvbnNlEisKB3BlcnNvbnMYASADKAsyGi5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uEhMKC21pc3NpbmdfaWRzGAIgAygDIm4KFkdldFBlcnNvbkJ5VXVpZFJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIMCgR1dWlkGAIgASgJEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucyJxChhHZXRQZXJzb25zQnlVdWlkc1JlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxINCgV1dWlkcxgCIAMoCRI1CgxyZWFkX29wdGlvbnMYAyABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMiewocR2V0UGVyc29uQnlEaXN0aW5jdElkUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhMKC2Rpc3RpbmN0X2lkGAIgASgJEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucyKEAQokR2V0UGVyc29uc0J5RGlzdGluY3RJZHNJblRlYW1SZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSFAoMZGlzdGluY3RfaWRzGAIgAygJEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucyJgCiJQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbVJlc3BvbnNlEjoKB3Jlc3VsdHMYASADKAsyKS5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uV2l0aERpc3RpbmN0SWRzIpYBCh5HZXRQZXJzb25zQnlEaXN0aW5jdElkc1JlcXVlc3QSPQoRdGVhbV9kaXN0aW5jdF9pZHMYASADKAsyIi5wZXJzb25ob2cudHlwZXMudjEuVGVhbURpc3RpbmN0SWQSNQoMcmVhZF9vcHRpb25zGAIgASgLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlJlYWRPcHRpb25zIl0KHFBlcnNvbnNCeURpc3RpbmN0SWRzUmVzcG9uc2USPQoHcmVzdWx0cxgBIAMoCzIsLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25XaXRoVGVhbURpc3RpbmN0SWQimQEKHkdldERpc3RpbmN0SWRzRm9yUGVyc29uUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxI1CgxyZWFkX29wdGlvbnMYAyABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMSEgoFbGltaXQYBCABKANIAIgBAUIICgZfbGltaXQiYgofR2V0RGlzdGluY3RJZHNGb3JQZXJzb25SZXNwb25zZRI/CgxkaXN0aW5jdF9pZHMYASADKAsyKS5wZXJzb25ob2cudHlwZXMudjEuRGlzdGluY3RJZFdpdGhWZXJzaW9uIrEBCh9HZXREaXN0aW5jdElkc0ZvclBlcnNvbnNSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEgoKcGVyc29uX2lkcxgCIAMoAxI1CgxyZWFkX29wdGlvbnMYAyABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMSHQoQbGltaXRfcGVyX3BlcnNvbhgEIAEoA0gAiAEBQhMKEV9saW1pdF9wZXJfcGVyc29uImYKIEdldERpc3RpbmN0SWRzRm9yUGVyc29uc1Jlc3BvbnNlEkIKE3BlcnNvbl9kaXN0aW5jdF9pZHMYASADKAsyJS5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uRGlzdGluY3RJZHMigAIKHVVwZGF0ZVBlcnNvblByb3BlcnRpZXNSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEQoJcGVyc29uX2lkGAIgASgDEhIKCmV2ZW50X25hbWUYAyABKAkSFgoOc2V0X3Byb3BlcnRpZXMYBCABKAwSGwoTc2V0X29uY2VfcHJvcGVydGllcxgFIAEoDBIYChB1bnNldF9wcm9wZXJ0aWVzGAYgAygJEhoKDWlzX2lkZW50aWZpZWQYByABKAhIAIgBARIZCgxsYXN0X3NlZW5fYXQYCCABKANIAYgBAUIQCg5faXNfaWRlbnRpZmllZEIPCg1fbGFzdF9zZWVuX2F0Im0KHlVwZGF0ZVBlcnNvblByb3BlcnRpZXNSZXNwb25zZRIvCgZwZXJzb24YASABKAsyGi5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uSACIAQESDwoHdXBkYXRlZBgCIAEoCEIJCgdfcGVyc29uIj0KFERlbGV0ZVBlcnNvbnNSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSFAoMcGVyc29uX3V1aWRzGAIgAygJIi4KFURlbGV0ZVBlcnNvbnNSZXNwb25zZRIVCg1kZWxldGVkX2NvdW50GAEgASgDIkcKIERlbGV0ZVBlcnNvbnNCYXRjaEZvclRlYW1SZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEgoKYmF0Y2hfc2l6ZRgCIAEoAyI6CiFEZWxldGVQZXJzb25zQmF0Y2hGb3JUZWFtUmVzcG9uc2USFQoNZGVsZXRlZF9jb3VudBgBIAEoAyJVCi5EZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW1SZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEgoKYmF0Y2hfc2l6ZRgCIAEoAyJICi9EZWxldGVQZXJzb25sZXNzRGlzdGluY3RJZHNCYXRjaEZvclRlYW1SZXNwb25zZRIVCg1kZWxldGVkX2NvdW50GAEgASgDIlcKElNwbGl0UGVyc29uUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxIdChVkaXN0aW5jdF9pZHNfdG9fc3BsaXQYAyADKAkijgEKC1NwbGl0UmVzdWx0EhMKC2Rpc3RpbmN0X2lkGAEgASgJEhcKD25ld19wZXJzb25fdXVpZBgCIAEoCRIaChJuZXdfcGVyc29uX3ZlcnNpb24YAyABKAMSEwoLcGRpX3ZlcnNpb24YBCABKAMSIAoYbmV3X3BlcnNvbl9jcmVhdGVkX2F0X21zGAUgASgDIkYKE1NwbGl0UGVyc29uUmVzcG9uc2USLwoGc3BsaXRzGAEgAygLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlNwbGl0UmVzdWx0ImMKJlNldFBlcnNvbkRpc3RpbmN0SWRWZXJzaW9uRmxvb3JSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEwoLZGlzdGluY3RfaWQYAiABKAkSEwoLbWluX3ZlcnNpb24YAyABKAMiZQonU2V0UGVyc29uRGlzdGluY3RJZFZlcnNpb25GbG9vclJlc3BvbnNlEi8KBnBlcnNvbhgBIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25IAIgBAUIJCgdfcGVyc29uIlcKHFNldFBlcnNvblZlcnNpb25GbG9vclJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIRCglwZXJzb25faWQYAiABKAMSEwoLbWluX3ZlcnNpb24YAyABKAMiMAodU2V0UGVyc29uVmVyc2lvbkZsb29yUmVzcG9uc2USDwoHdXBkYXRlZBgBIAEoCGIGcHJvdG8z', + 'Ch9wZXJzb25ob2cvdHlwZXMvdjEvcGVyc29uLnByb3RvEhJwZXJzb25ob2cudHlwZXMudjEisgIKBlBlcnNvbhIKCgJpZBgBIAEoAxIMCgR1dWlkGAIgASgJEg8KB3RlYW1faWQYAyABKAMSEgoKcHJvcGVydGllcxgEIAEoDBIiChpwcm9wZXJ0aWVzX2xhc3RfdXBkYXRlZF9hdBgFIAEoDBIhChlwcm9wZXJ0aWVzX2xhc3Rfb3BlcmF0aW9uGAYgASgMEhIKCmNyZWF0ZWRfYXQYByABKAMSDwoHdmVyc2lvbhgIIAEoAxIVCg1pc19pZGVudGlmaWVkGAkgASgIEhcKCmlzX3VzZXJfaWQYCiABKAhIAIgBARIZCgxsYXN0X3NlZW5fYXQYCyABKANIAYgBARISCgppc19kZWxldGVkGAwgASgIQg0KC19pc191c2VyX2lkQg8KDV9sYXN0X3NlZW5fYXQiTgoVRGlzdGluY3RJZFdpdGhWZXJzaW9uEhMKC2Rpc3RpbmN0X2lkGAEgASgJEhQKB3ZlcnNpb24YAiABKANIAIgBAUIKCghfdmVyc2lvbiJoChVQZXJzb25XaXRoRGlzdGluY3RJZHMSEwoLZGlzdGluY3RfaWQYASABKAkSLwoGcGVyc29uGAIgASgLMhoucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbkgAiAEBQgkKB19wZXJzb24iZwoRUGVyc29uRGlzdGluY3RJZHMSEQoJcGVyc29uX2lkGAEgASgDEj8KDGRpc3RpbmN0X2lkcxgCIAMoCzIpLnBlcnNvbmhvZy50eXBlcy52MS5EaXN0aW5jdElkV2l0aFZlcnNpb24ihwEKGFBlcnNvbldpdGhUZWFtRGlzdGluY3RJZBIvCgNrZXkYASABKAsyIi5wZXJzb25ob2cudHlwZXMudjEuVGVhbURpc3RpbmN0SWQSLwoGcGVyc29uGAIgASgLMhoucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbkgAiAEBQgkKB19wZXJzb24ibQoQR2V0UGVyc29uUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxI1CgxyZWFkX29wdGlvbnMYAyABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMiTwoRR2V0UGVyc29uUmVzcG9uc2USLwoGcGVyc29uGAEgASgLMhoucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbkgAiAEBQgkKB19wZXJzb24ibwoRR2V0UGVyc29uc1JlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxISCgpwZXJzb25faWRzGAIgAygDEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucyJTCg9QZXJzb25zUmVzcG9uc2USKwoHcGVyc29ucxgBIAMoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb24SEwoLbWlzc2luZ19pZHMYAiADKAMibgoWR2V0UGVyc29uQnlVdWlkUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEgwKBHV1aWQYAiABKAkSNQoMcmVhZF9vcHRpb25zGAMgASgLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlJlYWRPcHRpb25zInEKGEdldFBlcnNvbnNCeVV1aWRzUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEg0KBXV1aWRzGAIgAygJEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucyJ7ChxHZXRQZXJzb25CeURpc3RpbmN0SWRSZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEwoLZGlzdGluY3RfaWQYAiABKAkSNQoMcmVhZF9vcHRpb25zGAMgASgLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlJlYWRPcHRpb25zIoQBCiRHZXRQZXJzb25zQnlEaXN0aW5jdElkc0luVGVhbVJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIUCgxkaXN0aW5jdF9pZHMYAiADKAkSNQoMcmVhZF9vcHRpb25zGAMgASgLMh8ucGVyc29uaG9nLnR5cGVzLnYxLlJlYWRPcHRpb25zImAKIlBlcnNvbnNCeURpc3RpbmN0SWRzSW5UZWFtUmVzcG9uc2USOgoHcmVzdWx0cxgBIAMoCzIpLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25XaXRoRGlzdGluY3RJZHMilgEKHkdldFBlcnNvbnNCeURpc3RpbmN0SWRzUmVxdWVzdBI9ChF0ZWFtX2Rpc3RpbmN0X2lkcxgBIAMoCzIiLnBlcnNvbmhvZy50eXBlcy52MS5UZWFtRGlzdGluY3RJZBI1CgxyZWFkX29wdGlvbnMYAiABKAsyHy5wZXJzb25ob2cudHlwZXMudjEuUmVhZE9wdGlvbnMiXQocUGVyc29uc0J5RGlzdGluY3RJZHNSZXNwb25zZRI9CgdyZXN1bHRzGAEgAygLMiwucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbldpdGhUZWFtRGlzdGluY3RJZCKZAQoeR2V0RGlzdGluY3RJZHNGb3JQZXJzb25SZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEQoJcGVyc29uX2lkGAIgASgDEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucxISCgVsaW1pdBgEIAEoA0gAiAEBQggKBl9saW1pdCJiCh9HZXREaXN0aW5jdElkc0ZvclBlcnNvblJlc3BvbnNlEj8KDGRpc3RpbmN0X2lkcxgBIAMoCzIpLnBlcnNvbmhvZy50eXBlcy52MS5EaXN0aW5jdElkV2l0aFZlcnNpb24isQEKH0dldERpc3RpbmN0SWRzRm9yUGVyc29uc1JlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxISCgpwZXJzb25faWRzGAIgAygDEjUKDHJlYWRfb3B0aW9ucxgDIAEoCzIfLnBlcnNvbmhvZy50eXBlcy52MS5SZWFkT3B0aW9ucxIdChBsaW1pdF9wZXJfcGVyc29uGAQgASgDSACIAQFCEwoRX2xpbWl0X3Blcl9wZXJzb24iZgogR2V0RGlzdGluY3RJZHNGb3JQZXJzb25zUmVzcG9uc2USQgoTcGVyc29uX2Rpc3RpbmN0X2lkcxgBIAMoCzIlLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25EaXN0aW5jdElkcyKAAgodVXBkYXRlUGVyc29uUHJvcGVydGllc1JlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIRCglwZXJzb25faWQYAiABKAMSEgoKZXZlbnRfbmFtZRgDIAEoCRIWCg5zZXRfcHJvcGVydGllcxgEIAEoDBIbChNzZXRfb25jZV9wcm9wZXJ0aWVzGAUgASgMEhgKEHVuc2V0X3Byb3BlcnRpZXMYBiADKAkSGgoNaXNfaWRlbnRpZmllZBgHIAEoCEgAiAEBEhkKDGxhc3Rfc2Vlbl9hdBgIIAEoA0gBiAEBQhAKDl9pc19pZGVudGlmaWVkQg8KDV9sYXN0X3NlZW5fYXQibQoeVXBkYXRlUGVyc29uUHJvcGVydGllc1Jlc3BvbnNlEi8KBnBlcnNvbhgBIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb25IAIgBARIPCgd1cGRhdGVkGAIgASgIQgkKB19wZXJzb24iPQoURGVsZXRlUGVyc29uc1JlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIUCgxwZXJzb25fdXVpZHMYAiADKAkiLgoVRGVsZXRlUGVyc29uc1Jlc3BvbnNlEhUKDWRlbGV0ZWRfY291bnQYASABKAMiRwogRGVsZXRlUGVyc29uc0JhdGNoRm9yVGVhbVJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxISCgpiYXRjaF9zaXplGAIgASgDIjoKIURlbGV0ZVBlcnNvbnNCYXRjaEZvclRlYW1SZXNwb25zZRIVCg1kZWxldGVkX2NvdW50GAEgASgDIlUKLkRlbGV0ZVBlcnNvbmxlc3NEaXN0aW5jdElkc0JhdGNoRm9yVGVhbVJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxISCgpiYXRjaF9zaXplGAIgASgDIkgKL0RlbGV0ZVBlcnNvbmxlc3NEaXN0aW5jdElkc0JhdGNoRm9yVGVhbVJlc3BvbnNlEhUKDWRlbGV0ZWRfY291bnQYASABKAMiVwoSU3BsaXRQZXJzb25SZXF1ZXN0Eg8KB3RlYW1faWQYASABKAMSEQoJcGVyc29uX2lkGAIgASgDEh0KFWRpc3RpbmN0X2lkc190b19zcGxpdBgDIAMoCSKOAQoLU3BsaXRSZXN1bHQSEwoLZGlzdGluY3RfaWQYASABKAkSFwoPbmV3X3BlcnNvbl91dWlkGAIgASgJEhoKEm5ld19wZXJzb25fdmVyc2lvbhgDIAEoAxITCgtwZGlfdmVyc2lvbhgEIAEoAxIgChhuZXdfcGVyc29uX2NyZWF0ZWRfYXRfbXMYBSABKAMiRgoTU3BsaXRQZXJzb25SZXNwb25zZRIvCgZzcGxpdHMYASADKAsyHy5wZXJzb25ob2cudHlwZXMudjEuU3BsaXRSZXN1bHQiYwomU2V0UGVyc29uRGlzdGluY3RJZFZlcnNpb25GbG9vclJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxITCgtkaXN0aW5jdF9pZBgCIAEoCRITCgttaW5fdmVyc2lvbhgDIAEoAyJlCidTZXRQZXJzb25EaXN0aW5jdElkVmVyc2lvbkZsb29yUmVzcG9uc2USLwoGcGVyc29uGAEgASgLMhoucGVyc29uaG9nLnR5cGVzLnYxLlBlcnNvbkgAiAEBQgkKB19wZXJzb24iVwocU2V0UGVyc29uVmVyc2lvbkZsb29yUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxITCgttaW5fdmVyc2lvbhgDIAEoAyIwCh1TZXRQZXJzb25WZXJzaW9uRmxvb3JSZXNwb25zZRIPCgd1cGRhdGVkGAEgASgIIn0KEkZlbmNlUGVyc29uUmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxINCgVvcF9pZBgDIAEoCRI0CgdvcF90eXBlGAQgASgOMiMucGVyc29uaG9nLnR5cGVzLnYxLkxpZmVjeWNsZU9wVHlwZSJBChNGZW5jZVBlcnNvblJlc3BvbnNlEioKBnNlYWxlZBgBIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb24i1gEKE1JlbGVhc2VGZW5jZVJlcXVlc3QSDwoHdGVhbV9pZBgBIAEoAxIRCglwZXJzb25faWQYAiABKAMSEwoLcGVyc29uX3V1aWQYAyABKAkSDQoFb3BfaWQYBCABKAkSMwoHb3V0Y29tZRgFIAEoDjIiLnBlcnNvbmhvZy50eXBlcy52MS5SZWxlYXNlT3V0Y29tZRIbCg5zZWFsZWRfdmVyc2lvbhgGIAEoA0gAiAEBEhIKCmNyZWF0ZWRfYXQYByABKANCEQoPX3NlYWxlZF92ZXJzaW9uIhYKFFJlbGVhc2VGZW5jZVJlc3BvbnNlIlMKFFNlYWxlZFNvdXJjZVNuYXBzaG90EioKBnBlcnNvbhgBIAEoCzIaLnBlcnNvbmhvZy50eXBlcy52MS5QZXJzb24SDwoHb3JkaW5hbBgCIAEoBSK9AQoZRm9sZFBlcnNvbkRvY3VtZW50UmVxdWVzdBIPCgd0ZWFtX2lkGAEgASgDEhEKCXBlcnNvbl9pZBgCIAEoAxJCChBzZWFsZWRfc25hcHNob3RzGAMgAygLMigucGVyc29uaG9nLnR5cGVzLnYxLlNlYWxlZFNvdXJjZVNuYXBzaG90EhEKCWV2ZW50X3NldBgEIAEoDBIWCg5ldmVudF9zZXRfb25jZRgFIAEoDBINCgVvcF9pZBgGIAEoCSJIChpGb2xkUGVyc29uRG9jdW1lbnRSZXNwb25zZRIqCgZwZXJzb24YASABKAsyGi5wZXJzb25ob2cudHlwZXMudjEuUGVyc29uKm8KD0xpZmVjeWNsZU9wVHlwZRIhCh1MSUZFQ1lDTEVfT1BfVFlQRV9VTlNQRUNJRklFRBAAEhwKGExJRkVDWUNMRV9PUF9UWVBFX0RFTEVURRABEhsKF0xJRkVDWUNMRV9PUF9UWVBFX01FUkdFEAIqbQoOUmVsZWFzZU91dGNvbWUSHwobUkVMRUFTRV9PVVRDT01FX1VOU1BFQ0lGSUVEEAASHQoZUkVMRUFTRV9PVVRDT01FX0NPTU1JVFRFRBABEhsKF1JFTEVBU0VfT1VUQ09NRV9BQk9SVEVEEAJiBnByb3RvMw', [file_personhog_types_v1_common] ) @@ -81,6 +81,16 @@ export type Person = Message<'personhog.types.v1.Person'> & { * @generated from field: optional int64 last_seen_at = 11; */ lastSeenAt?: bigint + + /** + * True on a death document: the changelog record the leader produces when + * a lifecycle saga destroys the person (version = sealed_version + 1). + * Consumers treat the person as gone; the writer projects it as the + * Postgres tombstone. proto3 default (false) keeps old records live. + * + * @generated from field: bool is_deleted = 12; + */ + isDeleted: boolean } /** @@ -1049,3 +1059,339 @@ export type SetPersonVersionFloorResponse = Message<'personhog.types.v1.SetPerso export const SetPersonVersionFloorResponseSchema: GenMessage = /*@__PURE__*/ messageDesc(file_personhog_types_v1_person, 34) + +/** + * FencePersonRequest freezes a person for a lifecycle operation (see + * PersonHogLeader.FencePerson). op_id is the saga's caller-supplied UUID; + * op_type selects the rejection surfaced to writers while the fence holds. + * + * @generated from message personhog.types.v1.FencePersonRequest + */ +export type FencePersonRequest = Message<'personhog.types.v1.FencePersonRequest'> & { + /** + * @generated from field: int64 team_id = 1; + */ + teamId: bigint + + /** + * @generated from field: int64 person_id = 2; + */ + personId: bigint + + /** + * @generated from field: string op_id = 3; + */ + opId: string + + /** + * @generated from field: personhog.types.v1.LifecycleOpType op_type = 4; + */ + opType: LifecycleOpType +} + +/** + * Describes the message personhog.types.v1.FencePersonRequest. + * Use `create(FencePersonRequestSchema)` to create a new message. + */ +export const FencePersonRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 35) + +/** + * @generated from message personhog.types.v1.FencePersonResponse + */ +export type FencePersonResponse = Message<'personhog.types.v1.FencePersonResponse'> & { + /** + * The sealed state: the person's final word — no write can follow it + * while the fence holds. Delete uses sealed.version only; merge seals + * the full document (properties included). + * + * Failure is reported through the gRPC status, never in-band: + * FAILED_PRECONDITION with x-person-fenced metadata when another op + * holds the person, NOT_FOUND when the person is destroyed, + * FAILED_PRECONDITION when this pod does not serve the partition (the + * retry re-routes), and RESOURCE_EXHAUSTED when the fence map is at + * capacity (retry later). + * + * @generated from field: personhog.types.v1.Person sealed = 1; + */ + sealed?: Person +} + +/** + * Describes the message personhog.types.v1.FencePersonResponse. + * Use `create(FencePersonResponseSchema)` to create a new message. + */ +export const FencePersonResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 36) + +/** + * ReleaseFenceRequest closes a fence (see PersonHogLeader.ReleaseFence). + * person_uuid, sealed_version, and created_at travel in the request so a + * leader with no cache entry — after a restart — re-derives the death + * document deterministically. All three come from the sealed state the + * fence returned (the saga persists it in the mark row for replay). + * + * @generated from message personhog.types.v1.ReleaseFenceRequest + */ +export type ReleaseFenceRequest = Message<'personhog.types.v1.ReleaseFenceRequest'> & { + /** + * @generated from field: int64 team_id = 1; + */ + teamId: bigint + + /** + * @generated from field: int64 person_id = 2; + */ + personId: bigint + + /** + * @generated from field: string person_uuid = 3; + */ + personUuid: string + + /** + * @generated from field: string op_id = 4; + */ + opId: string + + /** + * @generated from field: personhog.types.v1.ReleaseOutcome outcome = 5; + */ + outcome: ReleaseOutcome + + /** + * The version sealed at fence time. The death document is produced at + * sealed_version + 1. Required when outcome = COMMITTED. Explicitly + * optional because 0 is a legitimate sealed version — a fresh person + * stub is created at version 0 — so absence must stay distinguishable + * from it. + * + * @generated from field: optional int64 sealed_version = 6; + */ + sealedVersion?: bigint + + /** + * The person's creation time as sealed at fence time, in epoch + * seconds — the unit Person.created_at carries end to end (the leader + * loads `timestamp()`, the writer binds via `epoch_secs_to_datetime`). + * The death document carries it verbatim — a cold leader has nothing + * to re-derive it from, and the writer projects it onto the tombstone + * row. Required when outcome = COMMITTED. + * + * @generated from field: int64 created_at = 7; + */ + createdAt: bigint +} + +/** + * Describes the message personhog.types.v1.ReleaseFenceRequest. + * Use `create(ReleaseFenceRequestSchema)` to create a new message. + */ +export const ReleaseFenceRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 37) + +/** + * @generated from message personhog.types.v1.ReleaseFenceResponse + */ +export type ReleaseFenceResponse = Message<'personhog.types.v1.ReleaseFenceResponse'> & {} + +/** + * Describes the message personhog.types.v1.ReleaseFenceResponse. + * Use `create(ReleaseFenceResponseSchema)` to create a new message. + */ +export const ReleaseFenceResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 38) + +/** + * A sealed source snapshot paired with its precedence ordinal — the + * pair order the saga recorded in lifecycle_op_person. The fold sorts + * by ordinal itself rather than trusting request order, so a re-drive + * that lists sources differently still folds the same document. + * + * @generated from message personhog.types.v1.SealedSourceSnapshot + */ +export type SealedSourceSnapshot = Message<'personhog.types.v1.SealedSourceSnapshot'> & { + /** + * @generated from field: personhog.types.v1.Person person = 1; + */ + person?: Person + + /** + * @generated from field: int32 ordinal = 2; + */ + ordinal: number +} + +/** + * Describes the message personhog.types.v1.SealedSourceSnapshot. + * Use `create(SealedSourceSnapshotSchema)` to create a new message. + */ +export const SealedSourceSnapshotSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 39) + +/** + * FoldPersonDocumentRequest folds sealed source snapshots into the merge + * target's document (see PersonHogLeader.FoldPersonDocument). The + * snapshots are the sources' final word — each was returned by + * FencePerson and no write can follow it while the merge op lives. + * + * @generated from message personhog.types.v1.FoldPersonDocumentRequest + */ +export type FoldPersonDocumentRequest = Message<'personhog.types.v1.FoldPersonDocumentRequest'> & { + /** + * @generated from field: int64 team_id = 1; + */ + teamId: bigint + + /** + * The merge target. The fold is computed by its owning leader from its + * authoritative cache plus the snapshots below. + * + * @generated from field: int64 person_id = 2; + */ + personId: bigint + + /** + * Sealed source snapshots. Property precedence follows the ordinals — + * a lower ordinal beats a higher one on contested keys, and the + * target beats all — in whatever order the request lists them; + * duplicate ordinals are rejected. Must be non-empty — a merge with + * no sealed sources has nothing to fold. + * + * @generated from field: repeated personhog.types.v1.SealedSourceSnapshot sealed_snapshots = 3; + */ + sealedSnapshots: SealedSourceSnapshot[] + + /** + * JSON-encoded maps from the merge event. event_set overrides on + * conflict (matching ingestion's $identify behavior); event_set_once + * fills only still-absent keys. + * + * @generated from field: bytes event_set = 4; + */ + eventSet: Uint8Array + + /** + * @generated from field: bytes event_set_once = 5; + */ + eventSetOnce: Uint8Array + + /** + * The merge saga's op id, verified against the lifecycle database: + * the fold is refused unless this op holds the person as its live + * ('marked') merge target, closing late re-drives from a settled or + * superseded op. The check is read-time only — a fold already past it + * when the op settles can still land — so it narrows the late-fold + * residual rather than eliminating it. Re-applying a fold on + * unchanged target state changes no content and only bumps the + * version; the id is not a dedup key. The accepted at-least-once + * residual applies while the op lives: the target is never fenced, so + * a re-driven fold recomputes over writes that landed in between and + * can clobber them where they overlap the event's $set or a + * snapshot's keys — the same residual every re-appliable leader merge + * carries today, closed later by operation identity in the write + * path. + * + * @generated from field: string op_id = 6; + */ + opId: string +} + +/** + * Describes the message personhog.types.v1.FoldPersonDocumentRequest. + * Use `create(FoldPersonDocumentRequestSchema)` to create a new message. + */ +export const FoldPersonDocumentRequestSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 40) + +/** + * @generated from message personhog.types.v1.FoldPersonDocumentResponse + */ +export type FoldPersonDocumentResponse = Message<'personhog.types.v1.FoldPersonDocumentResponse'> & { + /** + * The folded document exactly as produced to the changelog: + * target-wins properties, created_at = min(target, sources), + * is_identified = true, last_seen_at = max(target, sources), + * version = max(target, max sealed) + 1. + * One exception: when the target's stored properties violate the size + * constraint beyond remediation (protected keys alone exceed the + * ceiling), no applyable document exists — the fold produces nothing + * and returns the person unchanged, skipping its property and scalar + * effects for this person. + * + * @generated from field: personhog.types.v1.Person person = 1; + */ + person?: Person +} + +/** + * Describes the message personhog.types.v1.FoldPersonDocumentResponse. + * Use `create(FoldPersonDocumentResponseSchema)` to create a new message. + */ +export const FoldPersonDocumentResponseSchema: GenMessage = + /*@__PURE__*/ + messageDesc(file_personhog_types_v1_person, 41) + +/** + * The kind of lifecycle operation — mirrors lifecycle_op.op_type. The + * canonical string mapping lives on this enum's impl in personhog-proto; + * derive from it everywhere instead of repeating the literals. + * + * @generated from enum personhog.types.v1.LifecycleOpType + */ +export enum LifecycleOpType { + /** + * @generated from enum value: LIFECYCLE_OP_TYPE_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * @generated from enum value: LIFECYCLE_OP_TYPE_DELETE = 1; + */ + DELETE = 1, + + /** + * @generated from enum value: LIFECYCLE_OP_TYPE_MERGE = 2; + */ + MERGE = 2, +} + +/** + * Describes the enum personhog.types.v1.LifecycleOpType. + */ +export const LifecycleOpTypeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_personhog_types_v1_person, 0) + +/** + * @generated from enum personhog.types.v1.ReleaseOutcome + */ +export enum ReleaseOutcome { + /** + * @generated from enum value: RELEASE_OUTCOME_UNSPECIFIED = 0; + */ + UNSPECIFIED = 0, + + /** + * The person was destroyed: produce the death document, evict, unfence. + * + * @generated from enum value: RELEASE_OUTCOME_COMMITTED = 1; + */ + COMMITTED = 1, + + /** + * The op backed out: unfence, keep the entry, produce nothing. + * + * @generated from enum value: RELEASE_OUTCOME_ABORTED = 2; + */ + ABORTED = 2, +} + +/** + * Describes the enum personhog.types.v1.ReleaseOutcome. + */ +export const ReleaseOutcomeSchema: GenEnum = /*@__PURE__*/ enumDesc(file_personhog_types_v1_person, 1) diff --git a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py index c59a3bf1c345..40c3e3f7b88b 100644 --- a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py +++ b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.py @@ -15,17 +15,17 @@ from ....personhog.types.v1 import common_pb2 as personhog_dot_types_dot_v1_dot_common__pb2 DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile( - b'\n\x1fpersonhog/types/v1/person.proto\x12\x12personhog.types.v1\x1a\x1fpersonhog/types/v1/common.proto"\xb2\x02\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x12\x0f\n\x07team_id\x18\x03 \x01(\x03\x12\x12\n\nproperties\x18\x04 \x01(\x0c\x12"\n\x1aproperties_last_updated_at\x18\x05 \x01(\x0c\x12!\n\x19properties_last_operation\x18\x06 \x01(\x0c\x12\x12\n\ncreated_at\x18\x07 \x01(\x03\x12\x0f\n\x07version\x18\x08 \x01(\x03\x12\x15\n\ris_identified\x18\t \x01(\x08\x12\x17\n\nis_user_id\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x0b \x01(\x03H\x01\x88\x01\x01\x12\x12\n\nis_deleted\x18\x0c \x01(\x08B\r\n\x0b_is_user_idB\x0f\n\r_last_seen_at"N\n\x15DistinctIdWithVersion\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x14\n\x07version\x18\x02 \x01(\x03H\x00\x88\x01\x01B\n\n\x08_version"h\n\x15PersonWithDistinctIds\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"g\n\x11PersonDistinctIds\x12\x11\n\tperson_id\x18\x01 \x01(\x03\x12?\n\x0cdistinct_ids\x18\x02 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\x87\x01\n\x18PersonWithTeamDistinctId\x12/\n\x03key\x18\x01 \x01(\x0b2".personhog.types.v1.TeamDistinctId\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"m\n\x10GetPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"O\n\x11GetPersonResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"o\n\x11GetPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"S\n\x0fPersonsResponse\x12+\n\x07persons\x18\x01 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x13\n\x0bmissing_ids\x18\x02 \x03(\x03"n\n\x16GetPersonByUuidRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"q\n\x18GetPersonsByUuidsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\r\n\x05uuids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"{\n\x1cGetPersonByDistinctIdRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"\x84\x01\n$GetPersonsByDistinctIdsInTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cdistinct_ids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"`\n"PersonsByDistinctIdsInTeamResponse\x12:\n\x07results\x18\x01 \x03(\x0b2).personhog.types.v1.PersonWithDistinctIds"\x96\x01\n\x1eGetPersonsByDistinctIdsRequest\x12=\n\x11team_distinct_ids\x18\x01 \x03(\x0b2".personhog.types.v1.TeamDistinctId\x125\n\x0cread_options\x18\x02 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"]\n\x1cPersonsByDistinctIdsResponse\x12=\n\x07results\x18\x01 \x03(\x0b2,.personhog.types.v1.PersonWithTeamDistinctId"\x99\x01\n\x1eGetDistinctIdsForPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x12\n\x05limit\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x08\n\x06_limit"b\n\x1fGetDistinctIdsForPersonResponse\x12?\n\x0cdistinct_ids\x18\x01 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\xb1\x01\n\x1fGetDistinctIdsForPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x1d\n\x10limit_per_person\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x13\n\x11_limit_per_person"f\n GetDistinctIdsForPersonsResponse\x12B\n\x13person_distinct_ids\x18\x01 \x03(\x0b2%.personhog.types.v1.PersonDistinctIds"\x80\x02\n\x1dUpdatePersonPropertiesRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x12\n\nevent_name\x18\x03 \x01(\t\x12\x16\n\x0eset_properties\x18\x04 \x01(\x0c\x12\x1b\n\x13set_once_properties\x18\x05 \x01(\x0c\x12\x18\n\x10unset_properties\x18\x06 \x03(\t\x12\x1a\n\ris_identified\x18\x07 \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x08 \x01(\x03H\x01\x88\x01\x01B\x10\n\x0e_is_identifiedB\x0f\n\r_last_seen_at"m\n\x1eUpdatePersonPropertiesResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01\x12\x0f\n\x07updated\x18\x02 \x01(\x08B\t\n\x07_person"=\n\x14DeletePersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cperson_uuids\x18\x02 \x03(\t".\n\x15DeletePersonsResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"G\n DeletePersonsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03":\n!DeletePersonsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"U\n.DeletePersonlessDistinctIdsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03"H\n/DeletePersonlessDistinctIdsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"W\n\x12SplitPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x1d\n\x15distinct_ids_to_split\x18\x03 \x03(\t"\x8e\x01\n\x0bSplitResult\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x17\n\x0fnew_person_uuid\x18\x02 \x01(\t\x12\x1a\n\x12new_person_version\x18\x03 \x01(\x03\x12\x13\n\x0bpdi_version\x18\x04 \x01(\x03\x12 \n\x18new_person_created_at_ms\x18\x05 \x01(\x03"F\n\x13SplitPersonResponse\x12/\n\x06splits\x18\x01 \x03(\x0b2\x1f.personhog.types.v1.SplitResult"c\n&SetPersonDistinctIdVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"e\n\'SetPersonDistinctIdVersionFloorResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"W\n\x1cSetPersonVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"0\n\x1dSetPersonVersionFloorResponse\x12\x0f\n\x07updated\x18\x01 \x01(\x08"}\n\x12FencePersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\r\n\x05op_id\x18\x03 \x01(\t\x124\n\x07op_type\x18\x04 \x01(\x0e2#.personhog.types.v1.LifecycleOpType"A\n\x13FencePersonResponse\x12*\n\x06sealed\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person"\xd6\x01\n\x13ReleaseFenceRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bperson_uuid\x18\x03 \x01(\t\x12\r\n\x05op_id\x18\x04 \x01(\t\x123\n\x07outcome\x18\x05 \x01(\x0e2".personhog.types.v1.ReleaseOutcome\x12\x1b\n\x0esealed_version\x18\x06 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\ncreated_at\x18\x07 \x01(\x03B\x11\n\x0f_sealed_version"\x16\n\x14ReleaseFenceResponse"\xaf\x01\n\x19FoldPersonDocumentRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x124\n\x10sealed_snapshots\x18\x03 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x11\n\tevent_set\x18\x04 \x01(\x0c\x12\x16\n\x0eevent_set_once\x18\x05 \x01(\x0c\x12\r\n\x05op_id\x18\x06 \x01(\t"H\n\x1aFoldPersonDocumentResponse\x12*\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person*o\n\x0fLifecycleOpType\x12!\n\x1dLIFECYCLE_OP_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18LIFECYCLE_OP_TYPE_DELETE\x10\x01\x12\x1b\n\x17LIFECYCLE_OP_TYPE_MERGE\x10\x02*m\n\x0eReleaseOutcome\x12\x1f\n\x1bRELEASE_OUTCOME_UNSPECIFIED\x10\x00\x12\x1d\n\x19RELEASE_OUTCOME_COMMITTED\x10\x01\x12\x1b\n\x17RELEASE_OUTCOME_ABORTED\x10\x02b\x06proto3' + b'\n\x1fpersonhog/types/v1/person.proto\x12\x12personhog.types.v1\x1a\x1fpersonhog/types/v1/common.proto"\xb2\x02\n\x06Person\x12\n\n\x02id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x12\x0f\n\x07team_id\x18\x03 \x01(\x03\x12\x12\n\nproperties\x18\x04 \x01(\x0c\x12"\n\x1aproperties_last_updated_at\x18\x05 \x01(\x0c\x12!\n\x19properties_last_operation\x18\x06 \x01(\x0c\x12\x12\n\ncreated_at\x18\x07 \x01(\x03\x12\x0f\n\x07version\x18\x08 \x01(\x03\x12\x15\n\ris_identified\x18\t \x01(\x08\x12\x17\n\nis_user_id\x18\n \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x0b \x01(\x03H\x01\x88\x01\x01\x12\x12\n\nis_deleted\x18\x0c \x01(\x08B\r\n\x0b_is_user_idB\x0f\n\r_last_seen_at"N\n\x15DistinctIdWithVersion\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x14\n\x07version\x18\x02 \x01(\x03H\x00\x88\x01\x01B\n\n\x08_version"h\n\x15PersonWithDistinctIds\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"g\n\x11PersonDistinctIds\x12\x11\n\tperson_id\x18\x01 \x01(\x03\x12?\n\x0cdistinct_ids\x18\x02 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\x87\x01\n\x18PersonWithTeamDistinctId\x12/\n\x03key\x18\x01 \x01(\x0b2".personhog.types.v1.TeamDistinctId\x12/\n\x06person\x18\x02 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"m\n\x10GetPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"O\n\x11GetPersonResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"o\n\x11GetPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"S\n\x0fPersonsResponse\x12+\n\x07persons\x18\x01 \x03(\x0b2\x1a.personhog.types.v1.Person\x12\x13\n\x0bmissing_ids\x18\x02 \x03(\x03"n\n\x16GetPersonByUuidRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x0c\n\x04uuid\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"q\n\x18GetPersonsByUuidsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\r\n\x05uuids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"{\n\x1cGetPersonByDistinctIdRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"\x84\x01\n$GetPersonsByDistinctIdsInTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cdistinct_ids\x18\x02 \x03(\t\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"`\n"PersonsByDistinctIdsInTeamResponse\x12:\n\x07results\x18\x01 \x03(\x0b2).personhog.types.v1.PersonWithDistinctIds"\x96\x01\n\x1eGetPersonsByDistinctIdsRequest\x12=\n\x11team_distinct_ids\x18\x01 \x03(\x0b2".personhog.types.v1.TeamDistinctId\x125\n\x0cread_options\x18\x02 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions"]\n\x1cPersonsByDistinctIdsResponse\x12=\n\x07results\x18\x01 \x03(\x0b2,.personhog.types.v1.PersonWithTeamDistinctId"\x99\x01\n\x1eGetDistinctIdsForPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x12\n\x05limit\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x08\n\x06_limit"b\n\x1fGetDistinctIdsForPersonResponse\x12?\n\x0cdistinct_ids\x18\x01 \x03(\x0b2).personhog.types.v1.DistinctIdWithVersion"\xb1\x01\n\x1fGetDistinctIdsForPersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nperson_ids\x18\x02 \x03(\x03\x125\n\x0cread_options\x18\x03 \x01(\x0b2\x1f.personhog.types.v1.ReadOptions\x12\x1d\n\x10limit_per_person\x18\x04 \x01(\x03H\x00\x88\x01\x01B\x13\n\x11_limit_per_person"f\n GetDistinctIdsForPersonsResponse\x12B\n\x13person_distinct_ids\x18\x01 \x03(\x0b2%.personhog.types.v1.PersonDistinctIds"\x80\x02\n\x1dUpdatePersonPropertiesRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x12\n\nevent_name\x18\x03 \x01(\t\x12\x16\n\x0eset_properties\x18\x04 \x01(\x0c\x12\x1b\n\x13set_once_properties\x18\x05 \x01(\x0c\x12\x18\n\x10unset_properties\x18\x06 \x03(\t\x12\x1a\n\ris_identified\x18\x07 \x01(\x08H\x00\x88\x01\x01\x12\x19\n\x0clast_seen_at\x18\x08 \x01(\x03H\x01\x88\x01\x01B\x10\n\x0e_is_identifiedB\x0f\n\r_last_seen_at"m\n\x1eUpdatePersonPropertiesResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01\x12\x0f\n\x07updated\x18\x02 \x01(\x08B\t\n\x07_person"=\n\x14DeletePersonsRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x14\n\x0cperson_uuids\x18\x02 \x03(\t".\n\x15DeletePersonsResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"G\n DeletePersonsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03":\n!DeletePersonsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"U\n.DeletePersonlessDistinctIdsBatchForTeamRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x12\n\nbatch_size\x18\x02 \x01(\x03"H\n/DeletePersonlessDistinctIdsBatchForTeamResponse\x12\x15\n\rdeleted_count\x18\x01 \x01(\x03"W\n\x12SplitPersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x1d\n\x15distinct_ids_to_split\x18\x03 \x03(\t"\x8e\x01\n\x0bSplitResult\x12\x13\n\x0bdistinct_id\x18\x01 \x01(\t\x12\x17\n\x0fnew_person_uuid\x18\x02 \x01(\t\x12\x1a\n\x12new_person_version\x18\x03 \x01(\x03\x12\x13\n\x0bpdi_version\x18\x04 \x01(\x03\x12 \n\x18new_person_created_at_ms\x18\x05 \x01(\x03"F\n\x13SplitPersonResponse\x12/\n\x06splits\x18\x01 \x03(\x0b2\x1f.personhog.types.v1.SplitResult"c\n&SetPersonDistinctIdVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x13\n\x0bdistinct_id\x18\x02 \x01(\t\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"e\n\'SetPersonDistinctIdVersionFloorResponse\x12/\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.PersonH\x00\x88\x01\x01B\t\n\x07_person"W\n\x1cSetPersonVersionFloorRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bmin_version\x18\x03 \x01(\x03"0\n\x1dSetPersonVersionFloorResponse\x12\x0f\n\x07updated\x18\x01 \x01(\x08"}\n\x12FencePersonRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\r\n\x05op_id\x18\x03 \x01(\t\x124\n\x07op_type\x18\x04 \x01(\x0e2#.personhog.types.v1.LifecycleOpType"A\n\x13FencePersonResponse\x12*\n\x06sealed\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person"\xd6\x01\n\x13ReleaseFenceRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12\x13\n\x0bperson_uuid\x18\x03 \x01(\t\x12\r\n\x05op_id\x18\x04 \x01(\t\x123\n\x07outcome\x18\x05 \x01(\x0e2".personhog.types.v1.ReleaseOutcome\x12\x1b\n\x0esealed_version\x18\x06 \x01(\x03H\x00\x88\x01\x01\x12\x12\n\ncreated_at\x18\x07 \x01(\x03B\x11\n\x0f_sealed_version"\x16\n\x14ReleaseFenceResponse"S\n\x14SealedSourceSnapshot\x12*\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person\x12\x0f\n\x07ordinal\x18\x02 \x01(\x05"\xbd\x01\n\x19FoldPersonDocumentRequest\x12\x0f\n\x07team_id\x18\x01 \x01(\x03\x12\x11\n\tperson_id\x18\x02 \x01(\x03\x12B\n\x10sealed_snapshots\x18\x03 \x03(\x0b2(.personhog.types.v1.SealedSourceSnapshot\x12\x11\n\tevent_set\x18\x04 \x01(\x0c\x12\x16\n\x0eevent_set_once\x18\x05 \x01(\x0c\x12\r\n\x05op_id\x18\x06 \x01(\t"H\n\x1aFoldPersonDocumentResponse\x12*\n\x06person\x18\x01 \x01(\x0b2\x1a.personhog.types.v1.Person*o\n\x0fLifecycleOpType\x12!\n\x1dLIFECYCLE_OP_TYPE_UNSPECIFIED\x10\x00\x12\x1c\n\x18LIFECYCLE_OP_TYPE_DELETE\x10\x01\x12\x1b\n\x17LIFECYCLE_OP_TYPE_MERGE\x10\x02*m\n\x0eReleaseOutcome\x12\x1f\n\x1bRELEASE_OUTCOME_UNSPECIFIED\x10\x00\x12\x1d\n\x19RELEASE_OUTCOME_COMMITTED\x10\x01\x12\x1b\n\x17RELEASE_OUTCOME_ABORTED\x10\x02b\x06proto3' ) _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, "personhog.types.v1.person_pb2", _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals["_LIFECYCLEOPTYPE"]._serialized_start = 4700 - _globals["_LIFECYCLEOPTYPE"]._serialized_end = 4811 - _globals["_RELEASEOUTCOME"]._serialized_start = 4813 - _globals["_RELEASEOUTCOME"]._serialized_end = 4922 + _globals["_LIFECYCLEOPTYPE"]._serialized_start = 4799 + _globals["_LIFECYCLEOPTYPE"]._serialized_end = 4910 + _globals["_RELEASEOUTCOME"]._serialized_start = 4912 + _globals["_RELEASEOUTCOME"]._serialized_end = 5021 _globals["_PERSON"]._serialized_start = 89 _globals["_PERSON"]._serialized_end = 395 _globals["_DISTINCTIDWITHVERSION"]._serialized_start = 397 @@ -104,7 +104,9 @@ _globals["_RELEASEFENCEREQUEST"]._serialized_end = 4422 _globals["_RELEASEFENCERESPONSE"]._serialized_start = 4424 _globals["_RELEASEFENCERESPONSE"]._serialized_end = 4446 - _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_start = 4449 - _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_end = 4624 - _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_start = 4626 - _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_end = 4698 + _globals["_SEALEDSOURCESNAPSHOT"]._serialized_start = 4448 + _globals["_SEALEDSOURCESNAPSHOT"]._serialized_end = 4531 + _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_start = 4534 + _globals["_FOLDPERSONDOCUMENTREQUEST"]._serialized_end = 4723 + _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_start = 4725 + _globals["_FOLDPERSONDOCUMENTRESPONSE"]._serialized_end = 4797 diff --git a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi index f1e941f5e89b..f1ff692bd781 100644 --- a/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi +++ b/posthog/personhog_client/proto/generated/personhog/types/v1/person_pb2.pyi @@ -579,6 +579,15 @@ class ReleaseFenceResponse(_message.Message): def __init__(self) -> None: ... +class SealedSourceSnapshot(_message.Message): + __slots__ = ("person", "ordinal") + PERSON_FIELD_NUMBER: _ClassVar[int] + ORDINAL_FIELD_NUMBER: _ClassVar[int] + person: Person + ordinal: int + + def __init__(self, person: _Optional[_Union[Person, _Mapping]] = ..., ordinal: _Optional[int] = ...) -> None: ... + class FoldPersonDocumentRequest(_message.Message): __slots__ = ("team_id", "person_id", "sealed_snapshots", "event_set", "event_set_once", "op_id") TEAM_ID_FIELD_NUMBER: _ClassVar[int] @@ -589,7 +598,7 @@ class FoldPersonDocumentRequest(_message.Message): OP_ID_FIELD_NUMBER: _ClassVar[int] team_id: int person_id: int - sealed_snapshots: _containers.RepeatedCompositeFieldContainer[Person] + sealed_snapshots: _containers.RepeatedCompositeFieldContainer[SealedSourceSnapshot] event_set: bytes event_set_once: bytes op_id: str @@ -598,7 +607,7 @@ class FoldPersonDocumentRequest(_message.Message): self, team_id: _Optional[int] = ..., person_id: _Optional[int] = ..., - sealed_snapshots: _Optional[_Iterable[_Union[Person, _Mapping]]] = ..., + sealed_snapshots: _Optional[_Iterable[_Union[SealedSourceSnapshot, _Mapping]]] = ..., event_set: _Optional[bytes] = ..., event_set_once: _Optional[bytes] = ..., op_id: _Optional[str] = ..., diff --git a/proto/personhog/leader/v1/leader.proto b/proto/personhog/leader/v1/leader.proto index 9af823456b78..7feb4afe5493 100644 --- a/proto/personhog/leader/v1/leader.proto +++ b/proto/personhog/leader/v1/leader.proto @@ -54,8 +54,10 @@ service PersonHogLeader { // sealed source snapshots into the target person's document under the // owner's per-person lock, produce the folded document to the // changelog, and return it. Properties fold target-wins: the target - // keeps every key it has, snapshots fill still-absent keys in request - // order, then the merge event's $set overrides and $set_once fills. + // keeps every key it has, snapshots fill still-absent keys in ordinal + // order (sorted by the fold itself, so precedence is deterministic + // across re-drives), then the merge event's $set overrides and + // $set_once fills. // Scalars fold as created_at = min(target, sources), // is_identified = true, and last_seen_at = max(target, sources); the // version is max(target, max sealed) + 1 — at or above every sealed diff --git a/proto/personhog/types/v1/person.proto b/proto/personhog/types/v1/person.proto index 2103a8365030..19fde29a7e9a 100644 --- a/proto/personhog/types/v1/person.proto +++ b/proto/personhog/types/v1/person.proto @@ -356,6 +356,15 @@ enum ReleaseOutcome { message ReleaseFenceResponse {} +// A sealed source snapshot paired with its precedence ordinal — the +// pair order the saga recorded in lifecycle_op_person. The fold sorts +// by ordinal itself rather than trusting request order, so a re-drive +// that lists sources differently still folds the same document. +message SealedSourceSnapshot { + Person person = 1; + int32 ordinal = 2; +} + // FoldPersonDocumentRequest folds sealed source snapshots into the merge // target's document (see PersonHogLeader.FoldPersonDocument). The // snapshots are the sources' final word — each was returned by @@ -365,10 +374,12 @@ message FoldPersonDocumentRequest { // The merge target. The fold is computed by its owning leader from its // authoritative cache plus the snapshots below. int64 person_id = 2; - // Sealed source snapshots in property-precedence order: earlier - // snapshots beat later ones, and the target beats all. Must be - // non-empty — a merge with no sealed sources has nothing to fold. - repeated Person sealed_snapshots = 3; + // Sealed source snapshots. Property precedence follows the ordinals — + // a lower ordinal beats a higher one on contested keys, and the + // target beats all — in whatever order the request lists them; + // duplicate ordinals are rejected. Must be non-empty — a merge with + // no sealed sources has nothing to fold. + repeated SealedSourceSnapshot sealed_snapshots = 3; // JSON-encoded maps from the merge event. event_set overrides on // conflict (matching ingestion's $identify behavior); event_set_once // fills only still-absent keys. diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index af22c42fe0c6..e50ad6d29a22 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -1203,41 +1203,59 @@ impl PersonHogLeader for PersonHogLeaderService { track(sanitize_for_jsonb(&mut event_set)); track(sanitize_for_jsonb(&mut event_set_once)); struct SealedSnapshot { + ordinal: i32, properties: serde_json::Value, version: i64, created_at: i64, last_seen_at: Option, } + let mut seen_ordinals = + std::collections::HashSet::with_capacity(req.sealed_snapshots.len()); let mut snapshots: Vec = Vec::with_capacity(req.sealed_snapshots.len()); for snapshot in &req.sealed_snapshots { + let Some(person) = &snapshot.person else { + return Err(Status::invalid_argument( + "sealed snapshot is missing its person", + )); + }; // A snapshot for the wrong person, or a death document, can // only be a saga bug — folding it silently would launder it // into the target. - if snapshot.team_id != req.team_id { + if person.team_id != req.team_id { return Err(Status::invalid_argument( "sealed snapshot belongs to a different team than the target", )); } - if snapshot.id == req.person_id { + if person.id == req.person_id { return Err(Status::invalid_argument( "sealed snapshot is the merge target itself", )); } - if snapshot.is_deleted { + if person.is_deleted { return Err(Status::invalid_argument( "sealed snapshot is a death document; only living sealed state folds", )); } + if !seen_ordinals.insert(snapshot.ordinal) { + return Err(Status::invalid_argument( + "sealed snapshots carry a duplicate ordinal; precedence would be ambiguous", + )); + } let mut properties = - parse_json_object_field(&snapshot.properties, "sealed snapshot properties")?; + parse_json_object_field(&person.properties, "sealed snapshot properties")?; track(sanitize_for_jsonb(&mut properties)); snapshots.push(SealedSnapshot { + ordinal: snapshot.ordinal, properties, - version: snapshot.version, - created_at: snapshot.created_at, - last_seen_at: snapshot.last_seen_at, + version: person.version, + created_at: person.created_at, + last_seen_at: person.last_seen_at, }); } + // Precedence comes from the recorded pair order, not from the + // request: a re-drive that lists sources differently must fold + // the same document. + snapshots.sort_by_key(|snapshot| snapshot.ordinal); let cache_key = PersonCacheKey { team_id: req.team_id, diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index b18c4de37721..7098522b5325 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -25,7 +25,7 @@ use personhog_proto::personhog::leader::v1::person_hog_leader_client::PersonHogL use personhog_proto::personhog::leader::v1::person_hog_leader_server::PersonHogLeaderServer; use personhog_proto::personhog::types::v1::{ FencePersonRequest, FoldPersonDocumentRequest, GetPersonRequest, LifecycleOpType, Person, - ReleaseFenceRequest, ReleaseOutcome, UpdatePersonPropertiesRequest, + ReleaseFenceRequest, ReleaseOutcome, SealedSourceSnapshot, UpdatePersonPropertiesRequest, }; use prost::Message; use rdkafka::consumer::{BaseConsumer, Consumer}; @@ -1192,20 +1192,48 @@ fn fold_request( snapshots: Vec, event_set: serde_json::Value, event_set_once: serde_json::Value, +) -> FoldPersonDocumentRequest { + // List position becomes the ordinal, so tests read naturally: + // earlier in the list = higher precedence. + let with_ordinals = snapshots + .into_iter() + .enumerate() + .map(|(index, person)| (person, index as i32)) + .collect(); + fold_request_with_ordinals( + team_id, + person_id, + op_id, + with_ordinals, + event_set, + event_set_once, + ) +} + +fn fold_request_with_ordinals( + team_id: i64, + person_id: i64, + op_id: &Uuid, + snapshots: Vec<(Person, i32)>, + event_set: serde_json::Value, + event_set_once: serde_json::Value, ) -> FoldPersonDocumentRequest { // Unset identity fields get the target's team and a distinct source // id, mirroring what FencePerson seals; tests probing the identity // checks set them explicitly. let sealed_snapshots = snapshots .into_iter() - .map(|mut snapshot| { - if snapshot.team_id == 0 { - snapshot.team_id = team_id; + .map(|(mut person, ordinal)| { + if person.team_id == 0 { + person.team_id = team_id; + } + if person.id == 0 { + person.id = person_id + 1_000 + i64::from(ordinal); } - if snapshot.id == 0 { - snapshot.id = person_id + 1_000; + SealedSourceSnapshot { + person: Some(person), + ordinal, } - snapshot }) .collect(); FoldPersonDocumentRequest { @@ -1645,6 +1673,31 @@ async fn invalid_fold_requests_are_rejected_before_any_work() { ) }; + let duplicate_ordinals = fold_request_with_ordinals( + harness.team_id, + harness.person_id, + &op, + vec![ + (snapshot(serde_json::json!({"x": "1"}), 2, 1_650_000_000), 0), + (snapshot(serde_json::json!({"y": "2"}), 3, 1_650_000_000), 0), + ], + serde_json::json!({}), + serde_json::json!({}), + ); + let missing_person = FoldPersonDocumentRequest { + sealed_snapshots: vec![SealedSourceSnapshot { + person: None, + ordinal: 0, + }], + ..fold_request( + harness.team_id, + harness.person_id, + &op, + valid_snapshot(), + serde_json::json!({}), + serde_json::json!({}), + ) + }; let wrong_team = fold_request( harness.team_id, harness.person_id, @@ -1683,6 +1736,8 @@ async fn invalid_fold_requests_are_rejected_before_any_work() { ("empty snapshots", no_snapshots), ("malformed op_id", bad_op_id), ("non-object event_set", non_object_event_set), + ("duplicate ordinals", duplicate_ordinals), + ("snapshot missing its person", missing_person), ("wrong-team snapshot", wrong_team), ("snapshot is the target", snapshot_is_target), ("death-document snapshot", death_document_snapshot), @@ -2043,6 +2098,64 @@ async fn a_non_object_target_stays_an_object_through_the_unremediable_path() { assert_eq!(person_properties(&folded), serde_json::json!({})); } +/// Precedence follows the recorded ordinals, not the request order: the +/// same sources listed differently fold to the same document — the +/// convergence a re-driven saga step depends on. +#[tokio::test] +async fn precedence_follows_ordinals_not_request_order() { + let op = Uuid::now_v7(); + let mut harness = start_marked_fold_harness( + CachedPerson { + properties: serde_json::json!({}), + version: 1, + ..test_cached_person() + }, + &op, + ) + .await; + + let winner = snapshot( + serde_json::json!({"contested": "winner", "a": "winner"}), + 4, + 1_600_000_000, + ); + let loser = snapshot( + serde_json::json!({"contested": "loser", "b": "loser"}), + 5, + 1_650_000_000, + ); + + // The request lists the lower-precedence source first; the ordinals, + // not the list order, decide the contested key. + let folded = harness + .client + .fold_person_document(with_partition( + fold_request_with_ordinals( + harness.team_id, + harness.person_id, + &op, + vec![(loser, 1), (winner, 0)], + serde_json::json!({}), + serde_json::json!({}), + ), + harness.partition, + )) + .await + .expect("fold succeeds") + .into_inner() + .person + .unwrap(); + + assert_eq!( + person_properties(&folded), + serde_json::json!({ + "contested": "winner", + "a": "winner", + "b": "loser", + }) + ); +} + /// Cache dirt on the target — state loaded from Postgres or warmed from /// records that predate sanitization — is rewritten like every other /// fold input, so the committed document is measured in stored form. diff --git a/rust/personhog-router/tests/stash_integration.rs b/rust/personhog-router/tests/stash_integration.rs index 9771dbfc8ff8..44f4e9811063 100644 --- a/rust/personhog-router/tests/stash_integration.rs +++ b/rust/personhog-router/tests/stash_integration.rs @@ -18,7 +18,7 @@ use http_body_util::BodyExt; use personhog_coordination::routing_table::StashHandler; use personhog_proto::personhog::types::v1::{ FoldPersonDocumentRequest, FoldPersonDocumentResponse, GetPersonRequest, GetPersonResponse, - Person, UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, + Person, SealedSourceSnapshot, UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, }; use personhog_router::backend::{LeaderBackend, LeaderBackendConfig, StashTable}; use personhog_router::stash_handler::RouterStashHandler; @@ -535,7 +535,10 @@ async fn a_semantic_refusal_passes_through_instead_of_bouncing() { let req = FoldPersonDocumentRequest { team_id: person.team_id, person_id: person.id, - sealed_snapshots: vec![Person::default()], + sealed_snapshots: vec![SealedSourceSnapshot { + person: Some(Person::default()), + ordinal: 0, + }], event_set: b"{}".to_vec(), event_set_once: b"{}".to_vec(), op_id: "0192b4a0-0000-7000-8000-000000000000".to_string(), From f24f4c4c6e34f67a57a5891f7dc24ddbbdc6553d Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 15:03:03 -0700 Subject: [PATCH 08/10] chore(personhog): extract sealed-snapshot admission into a helper parse_sealed_snapshots owns validation, parsing, sanitization, and ordinal ordering of the fold's snapshots, with unit tests for the rejection matrix, the ordinal sort, and the sanitize-stat totals the integration suite could not see. --- rust/personhog-leader/src/service.rs | 245 +++++++++++++++++++++------ 1 file changed, 190 insertions(+), 55 deletions(-) diff --git a/rust/personhog-leader/src/service.rs b/rust/personhog-leader/src/service.rs index e50ad6d29a22..aff08a819e20 100644 --- a/rust/personhog-leader/src/service.rs +++ b/rust/personhog-leader/src/service.rs @@ -8,7 +8,7 @@ use personhog_proto::personhog::leader::v1::person_hog_leader_server::PersonHogL use personhog_proto::personhog::types::v1::{ FencePersonRequest, FencePersonResponse, FoldPersonDocumentRequest, FoldPersonDocumentResponse, GetPersonRequest, GetPersonResponse, LifecycleOpType, Person, ReleaseFenceRequest, - ReleaseFenceResponse, ReleaseOutcome, UpdatePersonPropertiesRequest, + ReleaseFenceResponse, ReleaseOutcome, SealedSourceSnapshot, UpdatePersonPropertiesRequest, UpdatePersonPropertiesResponse, }; use rdkafka::producer::FutureProducer; @@ -781,6 +781,80 @@ fn parse_json_object_field(bytes: &[u8], field: &str) -> Result, +} + +/// Admit the fold request's sealed snapshots: validate, parse, and +/// sanitize each, returning them in ordinal order plus what sanitization +/// rewrote. Every refusal is a deterministic `InvalidArgument`: each +/// snapshot must be a plausible living source of the target — same team, +/// a different person, not a death document — and ordinals must be +/// unique, or precedence would be ambiguous. A mismatch can only be a +/// saga bug, and folding it silently would launder it into the target. +// See `partition_from_metadata` for why `result_large_err` is allowed. +#[allow(clippy::result_large_err)] +fn parse_sealed_snapshots( + sealed_snapshots: &[SealedSourceSnapshot], + team_id: i64, + person_id: i64, +) -> Result<(Vec, SanitizeStats), Status> { + let mut stats = SanitizeStats::default(); + let mut seen_ordinals = std::collections::HashSet::with_capacity(sealed_snapshots.len()); + let mut snapshots: Vec = Vec::with_capacity(sealed_snapshots.len()); + for snapshot in sealed_snapshots { + let Some(person) = &snapshot.person else { + return Err(Status::invalid_argument( + "sealed snapshot is missing its person", + )); + }; + if person.team_id != team_id { + return Err(Status::invalid_argument( + "sealed snapshot belongs to a different team than the target", + )); + } + if person.id == person_id { + return Err(Status::invalid_argument( + "sealed snapshot is the merge target itself", + )); + } + if person.is_deleted { + return Err(Status::invalid_argument( + "sealed snapshot is a death document; only living sealed state folds", + )); + } + if !seen_ordinals.insert(snapshot.ordinal) { + return Err(Status::invalid_argument( + "sealed snapshots carry a duplicate ordinal; precedence would be ambiguous", + )); + } + let mut properties = + parse_json_object_field(&person.properties, "sealed snapshot properties")?; + let snapshot_stats = sanitize_for_jsonb(&mut properties); + stats.nul_strings += snapshot_stats.nul_strings; + stats.clamped_numbers += snapshot_stats.clamped_numbers; + snapshots.push(SealedSnapshot { + ordinal: snapshot.ordinal, + properties, + version: person.version, + created_at: person.created_at, + last_seen_at: person.last_seen_at, + }); + } + // Precedence comes from the recorded pair order, not from the + // request: a re-drive that lists sources differently must fold the + // same document. + snapshots.sort_by_key(|snapshot| snapshot.ordinal); + Ok((snapshots, stats)) +} + /// Extract the routing partition from the `x-partition` request-metadata /// header. The router stamps this on every leader call after hashing /// `(team_id, person_id)`; its absence means a misrouted or malformed @@ -1202,60 +1276,9 @@ impl PersonHogLeader for PersonHogLeaderService { let mut event_set_once = parse_json_object_field(&req.event_set_once, "event_set_once")?; track(sanitize_for_jsonb(&mut event_set)); track(sanitize_for_jsonb(&mut event_set_once)); - struct SealedSnapshot { - ordinal: i32, - properties: serde_json::Value, - version: i64, - created_at: i64, - last_seen_at: Option, - } - let mut seen_ordinals = - std::collections::HashSet::with_capacity(req.sealed_snapshots.len()); - let mut snapshots: Vec = Vec::with_capacity(req.sealed_snapshots.len()); - for snapshot in &req.sealed_snapshots { - let Some(person) = &snapshot.person else { - return Err(Status::invalid_argument( - "sealed snapshot is missing its person", - )); - }; - // A snapshot for the wrong person, or a death document, can - // only be a saga bug — folding it silently would launder it - // into the target. - if person.team_id != req.team_id { - return Err(Status::invalid_argument( - "sealed snapshot belongs to a different team than the target", - )); - } - if person.id == req.person_id { - return Err(Status::invalid_argument( - "sealed snapshot is the merge target itself", - )); - } - if person.is_deleted { - return Err(Status::invalid_argument( - "sealed snapshot is a death document; only living sealed state folds", - )); - } - if !seen_ordinals.insert(snapshot.ordinal) { - return Err(Status::invalid_argument( - "sealed snapshots carry a duplicate ordinal; precedence would be ambiguous", - )); - } - let mut properties = - parse_json_object_field(&person.properties, "sealed snapshot properties")?; - track(sanitize_for_jsonb(&mut properties)); - snapshots.push(SealedSnapshot { - ordinal: snapshot.ordinal, - properties, - version: person.version, - created_at: person.created_at, - last_seen_at: person.last_seen_at, - }); - } - // Precedence comes from the recorded pair order, not from the - // request: a re-drive that lists sources differently must fold - // the same document. - snapshots.sort_by_key(|snapshot| snapshot.ordinal); + let (snapshots, snapshot_stats) = + parse_sealed_snapshots(&req.sealed_snapshots, req.team_id, req.person_id)?; + track(snapshot_stats); let cache_key = PersonCacheKey { team_id: req.team_id, @@ -1875,6 +1898,118 @@ mod tests { PersonCacheKey { team_id, person_id } } + fn wire_snapshot(person: Person, ordinal: i32) -> SealedSourceSnapshot { + SealedSourceSnapshot { + person: Some(person), + ordinal, + } + } + + fn source_person(id: i64, properties: &serde_json::Value) -> Person { + Person { + id, + team_id: 7, + properties: serde_json::to_vec(properties).unwrap(), + ..Default::default() + } + } + + #[test] + fn sealed_snapshot_admission_rejects_implausible_sources() { + let valid = serde_json::json!({}); + let cases: Vec<(&str, Vec)> = vec![ + ( + "missing person", + vec![SealedSourceSnapshot { + person: None, + ordinal: 0, + }], + ), + ( + "wrong team", + vec![wire_snapshot( + Person { + team_id: 8, + ..source_person(2, &valid) + }, + 0, + )], + ), + ( + "snapshot is the target", + vec![wire_snapshot(source_person(1, &valid), 0)], + ), + ( + "death document", + vec![wire_snapshot( + Person { + is_deleted: true, + ..source_person(2, &valid) + }, + 0, + )], + ), + ( + "duplicate ordinal", + vec![ + wire_snapshot(source_person(2, &valid), 0), + wire_snapshot(source_person(3, &valid), 0), + ], + ), + ( + "non-object properties", + vec![wire_snapshot( + Person { + properties: b"[1]".to_vec(), + ..source_person(2, &valid) + }, + 0, + )], + ), + ]; + for (label, sealed) in cases { + let status = parse_sealed_snapshots(&sealed, 7, 1).expect_err(label); + assert_eq!(status.code(), Code::InvalidArgument, "{label}"); + } + } + + #[test] + fn sealed_snapshot_admission_sorts_by_ordinal_and_counts_sanitization() { + let nul_dirty = serde_json::json!({"a": "x\u{0000}y"}); + let float_dirty = serde_json::json!({"b": 1e308}); + let (snapshots, stats) = parse_sealed_snapshots( + &[ + wire_snapshot( + Person { + version: 9, + ..source_person(2, &nul_dirty) + }, + 1, + ), + wire_snapshot( + Person { + version: 4, + ..source_person(3, &float_dirty) + }, + 0, + ), + ], + 7, + 1, + ) + .expect("plausible snapshots admit"); + assert_eq!( + snapshots + .iter() + .map(|snapshot| snapshot.version) + .collect::>(), + vec![4, 9], + "returned in ordinal order, not request order" + ); + assert_eq!(stats.nul_strings, 1); + assert_eq!(stats.clamped_numbers, 1); + } + /// A service with no PG pool and a producer that never connects — /// enough to exercise the miss path, where no test reaches Kafka or PG. async fn make_test_service() -> PersonHogLeaderService { From 2a93e5f93185817d2b49667a5983b0feae546e64 Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 15:16:03 -0700 Subject: [PATCH 09/10] fix(personhog): stub the fence RPCs in the node fake service The regenerated service stubs picked up fencePerson, releaseFence, and foldPersonDocument from the service proto, so the tests' full-service fakes no longer satisfied ServiceImpl and the Node build failed. --- nodejs/src/common/personhog/client.test.ts | 3 +++ nodejs/src/common/personhog/persons.test.ts | 3 +++ 2 files changed, 6 insertions(+) diff --git a/nodejs/src/common/personhog/client.test.ts b/nodejs/src/common/personhog/client.test.ts index 1c65f7c3ae25..114f1fde3767 100644 --- a/nodejs/src/common/personhog/client.test.ts +++ b/nodejs/src/common/personhog/client.test.ts @@ -136,6 +136,9 @@ const SERVICE_DEFAULTS: ServiceImpl = { splitPerson: () => ({ splits: [] }), setPersonDistinctIdVersionFloor: () => ({}), setPersonVersionFloor: () => ({ updated: false }), + fencePerson: () => ({}), + releaseFence: () => ({}), + foldPersonDocument: () => ({}), } function createMockClient(overrides: Partial> = {}): PersonHogClient { diff --git a/nodejs/src/common/personhog/persons.test.ts b/nodejs/src/common/personhog/persons.test.ts index 4df06e2d1b02..904eb6643f34 100644 --- a/nodejs/src/common/personhog/persons.test.ts +++ b/nodejs/src/common/personhog/persons.test.ts @@ -72,6 +72,9 @@ const SERVICE_DEFAULTS: ServiceImpl = { splitPerson: () => ({ splits: [] }), setPersonDistinctIdVersionFloor: () => ({}), setPersonVersionFloor: () => ({ updated: false }), + fencePerson: () => ({}), + releaseFence: () => ({}), + foldPersonDocument: () => ({}), } function createOperations(overrides: Partial> = {}): { From 99582088eaf2fc486ef7b52333f304851856677a Mon Sep 17 00:00:00 2001 From: nickbest-ph Date: Fri, 7 Aug 2026 15:32:11 -0700 Subject: [PATCH 10/10] chore(personhog): lease fence-test lifecycle ops out of the sweeper's scan The seeded ops carried a NULL lease and never complete, so the identity sweeper treats each as an abandoned op to resume; hundreds of leftovers in a shared database drown the engine tests' own assertions. A far-future lease keeps them invisible. --- rust/personhog-leader/tests/fence_integration.rs | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/rust/personhog-leader/tests/fence_integration.rs b/rust/personhog-leader/tests/fence_integration.rs index 7098522b5325..b2b26cb2f27f 100644 --- a/rust/personhog-leader/tests/fence_integration.rs +++ b/rust/personhog-leader/tests/fence_integration.rs @@ -1259,9 +1259,13 @@ async fn seed_target_mark( person_id: i64, status: &str, ) { + // A far-future lease keeps these never-completed ops out of the + // identity sweeper's abandoned-op scan (it resumes NULL-lease and + // expired-lease rows), so runs against a shared database cannot + // poison the engine tests. sqlx::query( - "INSERT INTO lifecycle_op (op_id, op_type, team_id, step, request) \ - VALUES ($1, 'merge', $2, 'folding', '{}'::jsonb)", + "INSERT INTO lifecycle_op (op_id, op_type, team_id, step, request, lease_expires_at) \ + VALUES ($1, 'merge', $2, 'folding', '{}'::jsonb, now() + interval '100 years')", ) .bind(op) .bind(team_id as i32)