Compare commits

...
139 Commits
Author SHA1 Message Date
JzoNg 5c8cdb4ca1 Merge branch 'main' into jzh 2026-07-27 17:56:29 +08:00
JzoNg dfa2859ea3 feat(contacts): confirm platform contact upgrades 2026-07-27 17:53:22 +08:00
JzoNg a68fca6f68 feat(members): confirm external contact upgrades 2026-07-27 17:39:44 +08:00
JzoNg f7a14cb99f chore: merge main into jzh 2026-07-27 16:41:26 +08:00
JzoNg 02f9e1b7ad merge main 2026-07-27 14:55:37 +08:00
JzoNg 5350700e47 feat(workflow): refresh Human Input migration UI 2026-07-27 14:22:59 +08:00
JzoNg 67a31db615 Merge branch 'main' into jzh 2026-07-27 13:39:32 +08:00
JzoNg ec3ff5cc01 chore(merge): merge main 2026-07-27 10:31:26 +08:00
QuantumGhost 7c2c319538 Add IM contact sync API change proposal 2026-07-27 02:44:47 +08:00
QuantumGhost 3815aac382 fix(api): keep IM delivery independent from email feature checks 2026-07-27 02:26:37 +08:00
QuantumGhost 4136705d97 fix(api): isolate human input delivery provider failures 2026-07-27 02:26:37 +08:00
QuantumGhost 14b740fc60 feat(api): dispatch human input forms through delivery providers 2026-07-27 02:26:37 +08:00
QuantumGhost 763d1695f2 fix(api): harden human input delivery payload handling 2026-07-27 02:25:57 +08:00
QuantumGhost bcefa8491d feat(api): add IM-ready human input delivery contract 2026-07-27 02:25:57 +08:00
QuantumGhost f89bcd0496 test(api): cover human input form delivery providers 2026-07-27 02:25:07 +08:00
QuantumGhost 08322dc8c2 feat(api): introduce human input form delivery providers 2026-07-27 02:24:17 +08:00
QuantumGhost fab7a0af7b Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-27 02:14:51 +08:00
QuantumGhost ce6a3787de test(api): strengthen contact directory branch coverage 2026-07-25 13:04:03 +08:00
QuantumGhost a277a0dc4c docs(openspec): archive human input v2 submission runtime 2026-07-25 12:41:51 +08:00
QuantumGhost f87b1f4693 fix(api): retry serialized submissions safely 2026-07-25 12:35:46 +08:00
QuantumGhost b3475ed624 fix(api): harden human input v2 submission invariants 2026-07-25 11:57:48 +08:00
QuantumGhost 92c3600fc4 feat: implement human input v2 submission runtime 2026-07-25 11:14:08 +08:00
QuantumGhost 363a7834aa docs(openspec): archive human input v2 OTP proof session 2026-07-25 10:08:56 +08:00
QuantumGhost be54c54a5d fix(api): tighten OTP proof session invariants 2026-07-25 10:05:00 +08:00
QuantumGhost 6f3d09009d feat(api): implement human input v2 OTP proof sessions 2026-07-25 09:36:52 +08:00
QuantumGhost d6583084ec docs(openspec): archive human input v2 form core 2026-07-25 08:35:00 +08:00
QuantumGhost 993ccfc7d8 fix(api): tighten human input v2 form invariants 2026-07-25 08:30:58 +08:00
QuantumGhost 8abbdec4fd feat(api): implement human input v2 form core 2026-07-25 08:07:08 +08:00
QuantumGhost 64dba42bf3 docs(openspec): archive human input v2 recipient resolution 2026-07-25 07:16:24 +08:00
QuantumGhost a3ded70600 fix(api): harden recipient resolution fallbacks 2026-07-25 07:13:43 +08:00
QuantumGhost fdde47bbc3 feat(api): implement human input v2 recipient resolution 2026-07-25 06:56:33 +08:00
QuantumGhost c16bbe786f docs(openspec): archive human input v2 IM control plane 2026-07-25 06:24:41 +08:00
QuantumGhost ab816a54aa fix(api): align IM sync contact semantics 2026-07-25 06:22:03 +08:00
QuantumGhost 2d3ba7fa5d fix(api): harden IM control plane invariants 2026-07-25 06:10:34 +08:00
QuantumGhost 7c5fc2a133 feat(api): implement human input v2 im control plane 2026-07-25 05:39:30 +08:00
QuantumGhost cb9ae0c0a6 docs(openspec): normalize contact directory spec 2026-07-25 04:48:35 +08:00
QuantumGhost ad31e11320 docs(openspec): archive human input v2 contact directory 2026-07-25 04:47:17 +08:00
QuantumGhost eedb4d29ea fix(api): scope contact directory deployment locking 2026-07-25 04:43:41 +08:00
QuantumGhost 4639e4b818 fix(api): serialize contact email claims 2026-07-25 04:24:04 +08:00
QuantumGhost 95e73dd4b1 fix(api): tighten contact directory invariants 2026-07-25 04:01:34 +08:00
QuantumGhost 05e2f1ddef fix(api): keep legacy human input version typing compatible 2026-07-25 03:28:29 +08:00
QuantumGhost 4ef0c50a97 feat(api): implement human input v2 contact directory 2026-07-25 03:26:43 +08:00
QuantumGhost 6c2ac9cd91 docs(openspec): split human input v2 domain core 2026-07-25 02:54:52 +08:00
QuantumGhost 318fbcef27 chore: update docs 2026-07-25 02:31:14 +08:00
QuantumGhost 4db897abc1 feat(api): add independent HITL v2 form models
Assisted-by: Codex
2026-07-25 02:03:08 +08:00
QuantumGhost 5f78012b7d docs(openspec): define human input v2 domain core
Assisted-by: Codex
2026-07-24 14:48:50 +08:00
JzoNg eed57c9abd Merge branch 'main' into jzh 2026-07-24 14:30:24 +08:00
QuantumGhost 179351d1fe Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 14:27:25 +08:00
JzoNg f1f20384eb Merge branch 'main' into jzh 2026-07-24 14:03:19 +08:00
QuantumGhost 4b2ceb1bf6 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 11:56:21 +08:00
QuantumGhost eda9315a7b Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-24 11:55:46 +08:00
JzoNg 3a848a7a9c chore(merge): merge main 2026-07-24 11:25:45 +08:00
QuantumGhost 11b0723a92 chore: remove domain model stub 2026-07-24 11:03:11 +08:00
QuantumGhost f6440f6af4 update api stub, design spec and db models 2026-07-24 10:43:51 +08:00
QuantumGhost 462af76989 test(models): cover strict StrEnum JSON restoration
Assisted-by: Codex:gpt-5.6-sol
2026-07-24 05:03:22 +08:00
QuantumGhost 4b17446ed6 docs(models): clarify JSONModelColumn migration risks
Explain why legacy callers and stored data require compatibility review before adopting FrozenPydanticModelColumn.

Assisted-by: Codex
2026-07-23 15:33:48 +08:00
QuantumGhost 252054f9b6 fix(models): persist frozen Pydantic columns as text
Rename the strict immutable column type, deprecate JSONModelColumn, and cover single-encoded JSON storage.

Assisted-by: Codex
2026-07-23 15:29:21 +08:00
JzoNg 4193deb220 chore(merge): merge main 2026-07-23 15:26:49 +08:00
QuantumGhost 075d44a7fa refactor(models): split Pydantic JSON constructors
Assisted-by: Codex
2026-07-23 15:07:43 +08:00
QuantumGhost 5e696c9131 feat(models): add Pydantic model JSON type
Assisted-by: Codex
2026-07-23 14:40:43 +08:00
JzoNg 5f1646c14b feat(human-input): add batch migration and contact lookup boundaries 2026-07-23 11:09:29 +08:00
JzoNg 30b04d4d14 Merge branch 'main' into jzh 2026-07-23 09:59:09 +08:00
QuantumGhost e592c9f3ac docs: remove python stub from api summary docs 2026-07-23 09:20:59 +08:00
QuantumGhost fa5a2ff899 chore(api): move shared enum definitions to core/human_input_v2 directory 2026-07-23 09:20:25 +08:00
JzoNg 45d6efd8ab Merge branch 'main' into jzh 2026-07-23 07:29:26 +08:00
JzoNg d6b8466bcd refactor(contacts): simplify repository boundary 2026-07-22 18:04:12 +08:00
JzoNg 7fc5e60007 test(contacts): cover backend-aligned list contract 2026-07-22 17:42:36 +08:00
JzoNg 4e4f34be1f refactor(contacts): align management with list contract 2026-07-22 17:42:21 +08:00
QuantumGhost 09076f6555 docs: update spec and api stub 2026-07-22 17:02:23 +08:00
JzoNg dfcf690d9a feat(contacts): refine platform contact management 2026-07-22 16:37:24 +08:00
JzoNg ad1fe871f4 docs(openspec): clarify platform contact management 2026-07-22 16:37:08 +08:00
JzoNg f587807123 feat(contacts): add configured channel actions 2026-07-22 15:59:04 +08:00
JzoNg 6154e58da7 feat(contacts): add channels and email configuration 2026-07-22 15:52:45 +08:00
JzoNg 85d3226175 docs(openspec): update channels management requirements 2026-07-22 15:37:00 +08:00
QuantumGhost 47f20c8f3d chore: update openspec docs 2026-07-22 15:20:57 +08:00
JzoNg 074c172f52 chore(merge): resolve main branch conflicts 2026-07-22 14:54:53 +08:00
JzoNg 9986ff92b6 Merge branch 'main' into jzh 2026-07-21 17:02:03 +08:00
QuantumGhost ce694a20b4 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-21 16:44:04 +08:00
QuantumGhost ef5933a90e chore(api): refine HITL v2 docs and API stubs 2026-07-21 16:12:35 +08:00
JzoNg ce66f7505c Merge branch 'main' into jzh 2026-07-20 17:35:46 +08:00
JzoNg 41024954b7 fix(human-input): rename recipients spec field 2026-07-20 17:29:38 +08:00
JzoNg 1af6a1803a Merge branch 'main' into jzh 2026-07-20 16:56:11 +08:00
JzoNg 446f0f993a chore(merge): merge main into jzh 2026-07-20 15:40:12 +08:00
JzoNg 3c88536e2a chore(openspec): complete Human Input v2 form tasks 2026-07-20 15:32:17 +08:00
JzoNg 34b71467e7 feat(file-uploader): add Human Input v2 upload routing 2026-07-20 15:32:03 +08:00
JzoNg 7f2e40225c feat(human-input-v2): add OTP-guarded public form route 2026-07-20 15:31:39 +08:00
JzoNg 33b0bc578f feat(human-input-v2): add mock-first transport contracts 2026-07-20 15:31:14 +08:00
JzoNg aa128184f8 refactor(human-input): extract shared public form presentation 2026-07-20 15:30:44 +08:00
JzoNg 47f6cac432 chore(merge): merge main into jzh 2026-07-20 14:43:52 +08:00
JzoNg 1c9d15116c chore(openspec): complete Human Input v2 migration tasks 2026-07-20 14:31:02 +08:00
JzoNg 679d011d17 feat(workflow): add Human Input v2 migration flow 2026-07-20 14:28:27 +08:00
JzoNg 1d4b5eba35 feat(workflow): gate Human Input v2 creation 2026-07-20 14:05:27 +08:00
JzoNg ad328d156a feat(workflow): add Human Input v2 migration planner 2026-07-20 13:57:15 +08:00
JzoNg 1e5c206d31 docs(openspec): propose Human Input v2 form page 2026-07-20 13:43:39 +08:00
JzoNg 8b66eedaba docs(openspec): propose Human Input v2 migration UI 2026-07-20 11:52:40 +08:00
JzoNg ea7465b853 merge main 2026-07-20 10:16:45 +08:00
QuantumGhost c5d19039c4 Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-19 11:33:39 +08:00
JzoNg 9cc072ed5d docs(openspec): complete Human Input v2 UI tasks 2026-07-18 14:07:15 +08:00
JzoNg 0c3e3f6f78 feat(workflow): integrate Human Input v2 workflow utilities 2026-07-18 14:06:53 +08:00
JzoNg 922565f5e3 feat(workflow): add Human Input v2 configuration UI 2026-07-18 14:06:38 +08:00
JzoNg 90487f57fc refactor(workflow): share Human Input editor infrastructure 2026-07-18 14:06:25 +08:00
JzoNg cee50acbc4 docs(openspec): document Human Input v2 UI acceptance 2026-07-18 14:04:49 +08:00
JzoNg 84fd4011ab Merge branch 'main' into jzh 2026-07-18 12:55:28 +08:00
JzoNg ef5f698746 Merge branch 'main' into jzh 2026-07-17 16:41:08 +08:00
JzoNg 2603ca1bfb feat(contacts): add mock-backed contacts management UI 2026-07-17 16:33:32 +08:00
QuantumGhost 9791c0891d chore: update frontend api stubs 2026-07-17 15:24:51 +08:00
JzoNg 93a745c9ca chore(contacts): complete IM platform frontend quality checks 2026-07-17 15:17:30 +08:00
JzoNg 78980e9f1f feat(contacts): implement IM contact sync and details 2026-07-17 15:17:29 +08:00
JzoNg b30c3578a7 feat(contacts): implement IM platform binding management UI 2026-07-17 15:17:29 +08:00
JzoNg ef5c3df88b feat(contacts): implement IM platform mock data boundary 2026-07-17 15:17:29 +08:00
JzoNg e35752ddbc fix(contacts): restrict IM platform entry to non-enterprise editions 2026-07-17 15:17:29 +08:00
JzoNg 5b5d62a2af feat(contacts): establish IM platform frontend baseline 2026-07-17 15:17:29 +08:00
JzoNg cdd111869b docs: human input node v2 2026-07-17 15:09:50 +08:00
JzoNg 4fd6c028af docs: contacts management ui 2026-07-17 15:09:50 +08:00
JzoNg abcc77204b docs: add IM platform binding 2026-07-17 15:09:50 +08:00
JzoNg dce8e971be Merge branch 'main' into tp 2026-07-17 15:09:02 +08:00
QuantumGhost 2249974daa feat: add API stub 2026-07-17 14:32:03 +08:00
QuantumGhost f49e3dd5b1 chore: clarify organization contact and platform contact 2026-07-17 14:32:03 +08:00
QuantumGhost 51c471a359 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 1f05bdd543 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 2250f83d0f chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost 30fdbed7f2 chore: separate lark and feishu provider 2026-07-17 14:32:03 +08:00
QuantumGhost 3ff1c91d84 chore: remove outdated docs 2026-07-17 14:32:03 +08:00
QuantumGhost b9561cef58 chore: update specs 2026-07-17 14:32:02 +08:00
QuantumGhost ee1b9cb95f chore: update spec 2026-07-17 14:32:02 +08:00
QuantumGhost c1154686d8 chore: update openspec docs 2026-07-17 14:32:02 +08:00
JzoNg 3ead32a4e8 Merge branch 'main' into tp 2026-07-17 13:06:50 +08:00
JzoNg 6f1ecc47e8 Merge branch 'main' into tp 2026-07-17 11:24:24 +08:00
JzoNg 3d80e13f4f Merge branch 'main' into tp 2026-07-16 20:30:35 +08:00
JzoNg 75e2bcd460 Merge branch 'main' into tp 2026-07-16 17:47:25 +08:00
JzoNg 691eca212b Merge branch 'main' into tp 2026-07-16 11:25:16 +08:00
JzoNg e21a0883c1 Merge branch 'main' into tp 2026-07-16 08:13:27 +08:00
JzoNg ba1c13e37c Merge branch 'main' into tp 2026-07-14 14:16:26 +08:00
QuantumGhost 1e2f6f234e feat: add HITL IM DSL schema 2026-07-14 13:59:24 +08:00
QuantumGhost 6813e8d34f chore: introduce openspec 2026-07-14 10:36:52 +08:00
QuantumGhost e348d8accf Merge remote-tracking branch 'upstream/feat/hitl-im-dev' into feat/hitl-im-dev 2026-07-14 10:36:12 +08:00
QuantumGhost b71bbc2d3b Merge remote-tracking branch 'upstream/main' into feat/hitl-im-dev 2026-07-14 01:59:51 +08:00
JzoNg 69e18efab6 Merge branch 'main' into tp 2026-07-13 16:40:13 +08:00
QuantumGhost 196287fc8a docs: add HITL IM docs 2026-07-11 17:02:34 +08:00
427 changed files with 58112 additions and 1681 deletions
+2
View File
@@ -25,6 +25,8 @@ HUMAN_INPUT_FORM_INPUT_EXAMPLE = {
class HumanInputFormSubmitPayload(BaseModel):
"""Legacy Human Input v1 submit payload shared by existing runtime surfaces."""
inputs: dict[str, JsonValue] = Field(
description=(
"Submitted human input values keyed by output variable name. "
@@ -0,0 +1,984 @@
"""Shared Human Input v2 transport contracts.
Request DTOs use normal Pydantic coercion and forbid unknown fields. Migration
input is the sole compatibility exception: it ignores unknown legacy fields,
defaults a missing version to ``"1"``, rejects any other explicit version, and
rejects duplicate node IDs. Its transport shape mirrors the frontend migration
adapter so the generated client can replace the temporary mock without changing
frontend orchestration.
Public v2, trusted Service API v2, and legacy v1 submit DTOs stay independent.
"""
from __future__ import annotations
from http import HTTPStatus
from typing import Annotated, Literal, Self, Union
from pydantic import BaseModel, ConfigDict, Discriminator, Field, JsonValue, field_validator, model_validator
from core.human_input_v2.entities import (
ContactId,
EmailProviderType,
HumanInputContactType,
IMBindingId,
IMBindingScope,
IMIdentityBindingStatus,
IMIdentityId,
IMIntegrationStatus,
IMProvider,
IMSyncRemovalReason,
IMSyncResultType,
IMSyncRunId,
IMSyncRunStatus,
OrganizationCandidateId,
)
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
from core.workflow.nodes.human_input.entities import HumanInputNodeDataFull as HITLv1NodeData
from core.workflow.nodes.human_input_v2.entities import Channel
from core.workflow.nodes.human_input_v2.entities import HumanInputNodeData as HITLv2NodeData
from fields.base import ResponseModel
from fields.pagination import PaginationParamsMixin, PaginationResultMixin
from fields.timestamp import Timestamp
from libs.helper import EmailStr
class _NoExtraModel(BaseModel):
model_config = ConfigDict(extra="forbid")
class _RequestModel(BaseModel):
"""Base request model that forbids unknown fields while accepting JSON-native values."""
model_config = ConfigDict(extra="forbid")
class _MigrationInputModel(BaseModel):
"""Forward-compatible migration input that ignores fields unknown to this backend version."""
model_config = ConfigDict(extra="ignore")
class ContactListQuery(PaginationParamsMixin, _NoExtraModel):
"""Query params for listing contacts in the workspace directory."""
group: HumanInputContactType | None = Field(
default=None,
description="Optional contact type filter. None means all contacts.",
)
keyword: str | None = Field(default=None, description="Free-text search against contact name or email.")
class ContactOptionsQuery(PaginationParamsMixin, _NoExtraModel):
"""Query params for selecting contacts in workflow editors."""
keyword: str | None = Field(default=None, description="Free-text search against selectable contact names.")
class OrganizationCandidatesQuery(PaginationParamsMixin, _NoExtraModel):
"""Query params for searching organization member candidates."""
keyword: str | None = Field(default=None, description="Free-text search against candidate name or email.")
ExternalContactName = Annotated[
str,
Field(
min_length=1,
max_length=255,
description="Display name shown in the contact directory.",
),
]
ExternalContactEmail = Annotated[
EmailStr,
Field(
description="Primary email used for delivery and identity verification.",
),
]
ExternalContactAvatar = Annotated[
str,
Field(
description=(
"Optional avatar file ID. Upload the avatar image first via "
"`POST /console/api/files/upload`, then use the returned file id here."
" Set to empty string for resetting to default avatar."
),
),
]
class ExternalContactCreateRequest(_RequestModel):
"""Request body for creating or updating one external contact."""
name: ExternalContactName
email: ExternalContactEmail
avatar: ExternalContactAvatar | None = None
class ExternalContactUpdateRequest(_RequestModel):
"""Request body for creating or updating one external contact."""
name: ExternalContactName | None = None
email: ExternalContactEmail | None = None
avatar: ExternalContactAvatar | None = None
class IMBinding(BaseModel):
id: IMBindingId = Field(description="Unique IM binding identifier.")
provider: IMProvider = Field(description="Provider of the IM binding.")
scope: IMBindingScope = Field(description="Scope of the IM binding.")
class HumanInputContactSummary(BaseModel):
"""A trimmed version of `HumanInputContact` that only includes the fields needed for workflow orchestration."""
id: ContactId = Field(description="Unique contact identifier.")
name: str = Field(description="Display name shown in the contact directory.")
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
class HumanInputContact(BaseModel):
"""One contact entity returned by contact-related APIs."""
id: ContactId = Field(description="Unique contact identifier.")
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
name: str = Field(description="Display name shown in the contact directory.")
email: str | None = Field(default=None, description="Primary contact email if one exists.")
avatar_url: str = Field(default="", description="URL of the contact's avatar.")
# the `im_bindings` field is always empty for EXTERNAL contacts
im_bindings: list[IMBinding] = Field(
default_factory=list[IMBinding],
description=(
"IM bindings that are bound to this contact. "
"Currently, only one IM binding is supported. "
"There is at most one IM binding per IM provider."
),
)
created_at: Timestamp = Field(description="Timestamp when the contact was created.")
class ContactOption(ResponseModel):
"""Least-privilege contact projection returned to workflow editors."""
id: ContactId = Field(description="Unique contact identifier persisted in workflow recipient configuration.")
type: HumanInputContactType = Field(description="Resolved contact type in the current workspace scope.")
name: str = Field(description="Display name shown in the contact picker.")
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
class ExternalContactCreateResponse(ResponseModel):
contact: HumanInputContact = Field(description="The created external contact.")
class ExternalContactUpdateResponse(ResponseModel):
contact: HumanInputContact = Field(description="The updated external contact. Fields are values after updating.")
class OrganizationCandidate(ResponseModel):
"""One organization member candidate that may become a platform contact."""
id: OrganizationCandidateId = Field(description="Organization candidate identifier.")
name: str = Field(description="Display name shown in the candidate list.")
email: str = Field(description="Primary organization email used for matching.")
avatar_url: str | None = Field(default=None, description="Signed avatar URL if one is available.")
class ListContactsResponse(PaginationResultMixin, ResponseModel):
"""Paginated response body for contact list APIs."""
data: list[HumanInputContact] = Field(description="Contacts returned for the current page.")
class ListContactOptionsResponse(PaginationResultMixin, ResponseModel):
"""Paginated editor-safe contact picker response."""
data: list[ContactOption] = Field(description="Selectable contacts returned for the current page.")
class GetContactResponse(ResponseModel):
"""Response body for one contact resolved in the current workspace scope."""
contact: HumanInputContact = Field(description="Contact resolved as workspace, platform, or external.")
class ListOrganizationCandidatesResponse(PaginationResultMixin, ResponseModel):
"""Paginated response body for organization candidate search."""
data: list[OrganizationCandidate] = Field(
description="Organization member candidates returned for the current page."
)
class AddPlatformContactsRequest(_RequestModel):
"""Request body for adding one or more organization members as platform contacts."""
candidate_ids: list[OrganizationCandidateId] = Field(
...,
min_length=1,
description="Organization candidate identifiers to project into the current workspace as platform contacts.",
)
class AddPlatformContactsResponse(ResponseModel):
"""Response body for adding platform contacts."""
data: list[HumanInputContact] = Field(description="Contacts created by the current add operation.")
class RemoveContactsRequest(_RequestModel):
"""Request body for batch-removing platform or external contacts."""
contact_ids: list[ContactId] = Field(
...,
min_length=1,
description="Contact identifiers selected for removal from the contact directory surface.",
)
class RemoveContactsResponse(ResponseModel):
"""Response body returned after batch-removing contacts."""
removed_contact_ids: list[ContactId] = Field(description="Contact identifiers removed by the current operation.")
class _FeishuLarkIMIntegrationCredentialsBase(_RequestModel):
"""Shared credential fields for Feishu and Lark integrations."""
app_id: str = Field(description="Feishu or Lark application identifier.")
app_secret: str | PreserveOriginalValue = Field(description="Feishu or Lark application secret.")
verification_token: str | PreserveOriginalValue | None = Field(
default=None, description="Optional callback verification token."
)
encrypt_key: str | PreserveOriginalValue | None = Field(default=None, description="Optional callback encrypt key.")
class FeishuIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
"""Feishu integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.FEISHU] = Field(description="Discriminator for Feishu integration credentials.")
class LarkIMIntegrationCredentials(_FeishuLarkIMIntegrationCredentialsBase):
"""Lark integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.LARK] = Field(description="Discriminator for Lark integration credentials.")
class SlackIMIntegrationCredentials(_RequestModel):
"""Slack integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.SLACK] = Field(description="Discriminator for Slack integration credentials.")
client_id: str = Field(description="Slack OAuth client identifier.")
client_secret: str | PreserveOriginalValue = Field(description="Slack OAuth client secret.")
signing_secret: str | PreserveOriginalValue = Field(description="Slack signing secret used to verify callbacks.")
bot_token: str | PreserveOriginalValue = Field(
description="Slack bot token used for API calls and message delivery."
)
class DingTalkIMIntegrationCredentials(_RequestModel):
"""DingTalk integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.DING_TALK] = Field(description="Discriminator for DingTalk integration credentials.")
client_id: str = Field(description="DingTalk application client identifier.")
client_secret: str | PreserveOriginalValue = Field(
description="DingTalk application client secret. This field will be masked in response."
)
class MSTeamsIMIntegrationCredentials(_RequestModel):
"""Microsoft Teams integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.MS_TEAMS] = Field(
description="Discriminator for Microsoft Teams integration credentials."
)
tenant_id: str = Field(description="Microsoft Entra tenant identifier.")
client_id: str = Field(description="Microsoft Teams application client identifier.")
client_secret: str | PreserveOriginalValue = Field(
description="Microsoft Teams application client secret. This field will be masked in response"
)
class WeComIMIntegrationCredentials(_RequestModel):
"""WeCom integration credentials used by organization-level IM setup."""
provider: Literal[IMProvider.WE_COM] = Field(description="Discriminator for WeCom integration credentials.")
corp_id: str = Field(description="WeCom corporation identifier.")
agent_id: str = Field(description="WeCom agent identifier.")
secret: str | PreserveOriginalValue = Field(
description="WeCom application secret. This field will be masked in response"
)
IMIntegrationCredentials = Annotated[
FeishuIMIntegrationCredentials
| LarkIMIntegrationCredentials
| SlackIMIntegrationCredentials
| DingTalkIMIntegrationCredentials
| MSTeamsIMIntegrationCredentials
| WeComIMIntegrationCredentials,
Field(discriminator="provider"),
]
class _IMIntegrationRequest(_RequestModel):
"""Internal shared body for IM integration write/test operations."""
credentials: IMIntegrationCredentials = Field(description="Provider-specific IM integration credentials.")
class UpdateIMIntegrationRequest(_IMIntegrationRequest):
"""Request body for creating or updating one IM integration."""
expected_integration_id: str | None = Field(
default=None,
min_length=1,
description="Current integration identifier used with expected_config_version for compare-and-swap.",
)
expected_config_version: int | None = Field(
default=None,
ge=1,
description="Current integration revision used with expected_integration_id for compare-and-swap.",
)
@model_validator(mode="after")
def validate_complete_cas_token(self) -> Self:
has_integration_id = self.expected_integration_id is not None
has_config_version = self.expected_config_version is not None
if has_integration_id != has_config_version:
raise ValueError("expected_integration_id and expected_config_version must be provided together")
return self
class DeleteIMIntegrationQuery(_NoExtraModel):
"""CAS token required when deleting the current IM integration."""
expected_integration_id: str = Field(min_length=1, description="Current integration identifier.")
expected_config_version: int = Field(ge=1, description="Current integration revision.")
class TestIMIntegrationRequest(_IMIntegrationRequest):
"""Request body for testing one IM integration."""
class IMIntegration(ResponseModel):
"""One organization-level IM integration snapshot."""
provider: IMProvider | None = Field(
default=None,
description="Configured IM provider. None is allowed when the integration is not configured.",
)
status: IMIntegrationStatus = Field(description="Current integration connectivity state.")
callback_url: str | None = Field(
default=None,
description=(
"Callback URL expected by the provider. "
"None if the current deployment uses persistence connections for receive events."
),
)
permission_hint: str | None = Field(default=None, description="Operator-facing hint about permission issues.")
configured_at: Timestamp | None = Field(
default=None, description="Unix timestamp in milliseconds when the integration was created."
)
updated_at: Timestamp | None = Field(
default=None, description="Unix timestamp in milliseconds when the integration was last updated."
)
integration_id: str | None = Field(
default=None,
description="Stable integration identifier. None when no integration is configured.",
)
config_version: int | None = Field(
default=None,
ge=1,
description="Monotonic configuration revision. None when no integration is configured.",
)
class GetIMIntegrationResponse(ResponseModel):
"""Response body carrying one IM integration snapshot."""
integration: IMIntegration = Field(description="Current organization-level IM integration snapshot.")
class UpdateIMIntegrationResponse(ResponseModel):
"""Response body returned after updating one IM integration."""
integration: IMIntegration = Field(description="Saved organization-level IM integration snapshot.")
class TestIMIntegrationResponse(ResponseModel):
"""Response body returned by IM integration test APIs."""
status: IMIntegrationStatus = Field(description="Integration status mapped from the test result.")
message: str = Field(description="Human-readable explanation of the test result.")
class IMSyncRunResultCounts(ResponseModel):
"""Aggregate result counts for one IM sync run."""
added: int = Field(description="Number of entries newly matched and bound.")
not_matched: int = Field(description="Number of entries that could not be matched.")
failed: int = Field(description="Number of entries that failed to reconcile.")
removed: int = Field(description="Number of entries whose prior binding was removed.")
skipped: int = Field(description="Number of entries intentionally skipped.")
class IMSyncRun(ResponseModel):
"""One IM sync run snapshot.
The latest-only UI displays ``finished_at`` as the explicit sync time. The
transport contract intentionally does not expose a ``started_by`` actor.
"""
id: IMSyncRunId = Field(description="Unique sync run identifier.")
status: IMSyncRunStatus = Field(description="Current lifecycle state of the sync run.")
started_at: Timestamp | None = Field(
default=None, description="Unix timestamp in milliseconds when the sync run started."
)
finished_at: Timestamp | None = Field(
default=None,
description=(
"Unix timestamp in milliseconds when the sync run finished. "
"This is the sync time displayed by the latest-only UI and is None while the run is unfinished."
),
)
error_message: str | None = Field(
default=None,
description="Terminal error message. Present only when the sync run status is `failed`.",
)
result_counts: IMSyncRunResultCounts = Field(
description="Aggregate reconciliation counts for the current run snapshot.",
)
provider: IMProvider = Field(description="IM provider associated with the sync run.")
integration_id: str = Field(description="Integration identifier captured when the sync run was created.")
integration_config_version: int = Field(
ge=1,
description="Integration configuration revision captured when the sync run was created.",
)
class CreateIMSyncRunResponse(ResponseModel):
"""Response body returned after creating one sync run."""
run: IMSyncRun = Field(description="Newly created sync run snapshot.")
class IMDirectoryEntry(_RequestModel):
"""Normalized provider-side account observed during an IM sync run.
The entry is run-scoped input to identity and binding reconciliation. It does
not represent a stable Dify identity and must not be referenced by bindings
or runtime authorization. Sync results may retain a snapshot for display,
diagnostics, and audit; durable references use IMIdentity or IMBinding IDs.
"""
provider_user_id: str
display_name: str | None = None
email: str | None = None
class IMIdentitySnapshot(_RequestModel):
"""Last known persistent IM identity state retained by a sync result."""
identity_id: IMIdentityId
provider_user_id: str
display_name: str | None = None
email: str | None = None
class IMSyncResultAdded(BaseModel):
type: Literal[IMSyncResultType.ADDED] = IMSyncResultType.ADDED
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
entry: IMDirectoryEntry = Field(description="Provider directory entry observed during the current sync run.")
class IMSyncResultRemoved(BaseModel):
type: Literal[IMSyncResultType.REMOVED] = IMSyncResultType.REMOVED
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
last_known_identity: IMIdentitySnapshot = Field(
description="Last known persistent IM identity state before its binding was removed."
)
reason: IMSyncRemovalReason = Field(description="Reason the existing IM binding was removed.")
class IMSyncResultFailed(BaseModel):
type: Literal[IMSyncResultType.FAILED] = IMSyncResultType.FAILED
entry: IMDirectoryEntry | None = Field(
None, description="Provider directory entry observed before this reconciliation failure, if available."
)
reason: str = Field(description="Reason the binding failed to sync.")
class IMSyncResultSkipped(BaseModel):
type: Literal[IMSyncResultType.SKIPPED] = IMSyncResultType.SKIPPED
entry: IMDirectoryEntry | None = Field(
None, description="Provider directory entry observed before reconciliation was skipped, if available."
)
contact: HumanInputContactSummary = Field(description="The contact that associated with this sync result.")
class IMSyncResultNotMatched(BaseModel):
type: Literal[IMSyncResultType.NOT_MATCHED] = IMSyncResultType.NOT_MATCHED
entry: IMDirectoryEntry | None = Field(
None, description="Provider directory entry that could not be matched, if available."
)
IMSyncResult = Annotated[
Union[
IMSyncResultAdded,
IMSyncResultRemoved,
IMSyncResultFailed,
IMSyncResultNotMatched,
IMSyncResultSkipped,
],
Discriminator("type"),
]
class IMSyncResultItem(ResponseModel):
"""One paginated reconciliation result entry for the latest sync run."""
# The current implementation does not return IM binding status for other IM providers.
# According to the design, we should return IM binding status for all configured IM providers.
# However, this version allows only one configured IM provider, so this model excludes
# the IM binding status for other IM providers.
id: str = Field(description="Unique synchronization result identifier.")
result: IMSyncResult = Field(description="Result bucket this entry belongs to.")
class GetLatestIMSyncRunResponse(ResponseModel):
"""Response body for reading the latest IM sync run summary."""
run: IMSyncRun = Field(description="Latest sync run summary.")
class ListLatestIMSyncRunResultsQuery(PaginationParamsMixin, _NoExtraModel):
"""Query params for reading paginated latest-run results."""
result: IMSyncResultType = Field(
...,
description=(
"Required result bucket to paginate from the latest sync run. "
"There is no `all` bucket or unfiltered results mode."
),
)
class ListLatestIMSyncRunResultsResponse(PaginationResultMixin, ResponseModel):
"""Page-based latest-run results without cursor state or a repeated run summary."""
data: list[IMSyncResultItem] = Field(
description="Result entries returned with page, limit, and total metadata for the selected bucket."
)
class ListIMIdentitiesQuery(PaginationParamsMixin, _NoExtraModel):
"""Query params for searching synced IM identities."""
keyword: str | None = Field(
default=None,
description="Free-text search against identity display name, email, or provider user ID.",
)
class IMIdentity(ResponseModel):
"""One synced IM identity that may be bound or overridden."""
id: IMIdentityId = Field(description="Internal IM identity record identifier.")
provider: IMProvider = Field(description="IM provider that owns this identity.")
provider_user_id: str = Field(description="Provider-side user identifier.")
display_name: str | None = Field(default=None, description="Display name returned by the provider.")
email: str | None = Field(default=None, description="Email returned by the provider, if any.")
binding_status: IMIdentityBindingStatus = Field(
description="Whether this IM identity is currently bound to a contact."
)
class ListIMIdentitiesResponse(PaginationResultMixin, ResponseModel):
"""Paginated response body for synced IM identity search."""
data: list[IMIdentity] = Field(description="IM identities returned for the current page.")
class SetContactIMOverrideRequest(_RequestModel):
"""Request body for setting one workspace-scoped IM override."""
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
class SetContactIMOverrideResponse(ResponseModel):
"""Response body returned after setting one contact IM override."""
contact: HumanInputContact = Field(description="Contact snapshot after the override is applied.")
class ResetContactIMOverrideResponse(ResponseModel):
"""Response body returned after resetting one contact IM override."""
contact: HumanInputContact = Field(description="Contact snapshot after the override is cleared.")
class CreateIMBindingRequest(_RequestModel):
"""Request body for setting one workspace-scoped IM override."""
identity_id: IMIdentityId = Field(description="Synced IM identity identifier selected as the workspace override.")
class CreateIMBindingResponse(ResponseModel):
"""Response body returned after binding one IM identity to the workspace."""
contact: HumanInputContact = Field(description="Contact snapshot after the IM identity is bound.")
class DeleteIMBindingQuery(_RequestModel):
binding_id: IMBindingId = Field(description="IM binding to unbind.")
class DeleteIMBindingResponse(ResponseModel):
pass
class MessageTemplateTestRequest(_RequestModel):
"""Request body for sending one message-template test notification."""
channel: Channel = Field(description="Target debug delivery channel used for the test send.")
inputs: dict[str, JsonValue] = Field(
default_factory=dict,
description="Variable values used when rendering the message template preview.",
)
class MessageTemplateTestResponse(ResponseModel):
"""Response body returned after one message-template test send."""
class FormAccessRequestResponse(ResponseModel):
"""Response body returned after creating one OTP challenge."""
expires_in_seconds: int = Field(description="Seconds until the current OTP challenge expires.")
resend_after_seconds: int = Field(description="Seconds until another OTP challenge may be requested.")
challenge_token: str = Field(description="The token used to complete the OTP challenge.")
class FormDefinitionResponse(ResponseModel):
"""Response body containing a resolved human-input form definition."""
form_content: str | None = Field(default=None, description="Rendered form body shown to the approver.")
inputs: list[FormInputConfig] = Field(default_factory=list, description="Resolved form input definitions.")
resolved_default_values: dict[str, str] = Field(
default_factory=dict,
description="Default values after variable resolution and stringification.",
)
user_actions: list[UserActionConfig] = Field(
default_factory=list,
description="Action buttons that can complete the form.",
)
expiration_time: int = Field(description="Unix timestamp when the current form expires.")
class ServiceFormQuery(_NoExtraModel):
"""Query params for reading one service-api human-input form."""
user: str = Field(min_length=1, description="End-user identifier used to scope the service API request.")
class BatchGetContactsQuery(_NoExtraModel):
contact_ids: list[ContactId] = Field(..., description="List of contact IDs to retrieve.")
class BatchGetContactsResponse(ResponseModel):
data: list[HumanInputContactSummary] = Field(..., description="List of retrieved human input contacts.")
class BatchGetContactOptionsQuery(_NoExtraModel):
contact_ids: list[ContactId] = Field(..., description="Contact IDs persisted in workflow recipient configuration.")
class BatchGetContactOptionsResponse(ResponseModel):
data: list[ContactOption] = Field(..., description="Selectable contacts resolved in request order.")
class HumanInputV2FormSubmitRequest(_RequestModel):
"""Public Human Input v2 submit payload, independent from the v1 form contract."""
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
action: str = Field(description="Identifier of the selected Human Input v2 action.")
challenge_token: str | None = Field(
default=None,
description="OTP challenge token returned by the Human Input v2 access-request endpoint.",
)
otp_code: str | None = Field(
default=None,
description="OTP code required when the current Human Input v2 approver uses email proof.",
)
@model_validator(mode="after")
def validate_complete_email_proof(self) -> Self:
has_challenge_token = self.challenge_token is not None
has_otp_code = self.otp_code is not None
if has_challenge_token != has_otp_code:
raise ValueError("challenge_token and otp_code must be provided together")
return self
class HumanInputV2ServiceFormSubmitRequest(_RequestModel):
"""Trusted Service API submit payload without public-web OTP proof fields."""
inputs: dict[str, JsonValue] = Field(description="Submitted form values keyed by output variable name.")
action: str = Field(description="Identifier of the selected Human Input v2 action.")
user: str = Field(min_length=1, description="End-user identifier scoped to the current app token.")
class FormUploadTokenResponse(ResponseModel):
"""Response body returned when issuing a Human Input v2 upload token."""
upload_token: str
expires_at: int
class FormSubmitResponse(ResponseModel):
"""Empty response body returned after a Human Input v2 form submission."""
# =================== Node migration related entities ===================
class LegacyHITLv1NodeData(HITLv1NodeData):
"""Legacy Human Input node data accepted by the v1-to-v2 migration helper.
Missing versions use the historical v1 default. Any explicit value other
than the string ``"1"`` is rejected before migration.
"""
model_config = ConfigDict(extra="ignore")
# Keep the mutable parent field type for static substitutability while
# preserving the literal transport schema and runtime validation.
version: str = Field(
default="1",
description=(
'Legacy Human Input node version. Missing values default to "1"; '
'any explicit value other than the string "1" is rejected.'
),
json_schema_extra={"const": "1"},
)
@field_validator("version")
@classmethod
def validate_version(cls, value: str) -> str:
if value != "1":
raise ValueError('version must be "1"')
return value
class NodeDataMigrationInput(_MigrationInputModel):
"""One legacy node submitted through the frontend migration adapter boundary."""
node_id: str = Field(
..., description="The identifier of node to migrate. Used to associate between request and response"
)
node_data: LegacyHITLv1NodeData = Field(..., description="The legacy Human Input node data to migrate.")
class NodeDataMigrationPayload(_MigrationInputModel):
"""Complete legacy-node batch submitted for one migration attempt."""
nodes: list[NodeDataMigrationInput] = Field(min_length=1)
@model_validator(mode="after")
def validate_unique_node_ids(self) -> Self:
node_ids = [node.node_id for node in self.nodes]
if len(node_ids) != len(set(node_ids)):
raise ValueError("node_id must be unique within one migration request")
return self
class NodeDataMigrationResult(ResponseModel):
"""One converted node returned with its frontend correlation identifier."""
node_id: str = Field(description="The identifier of the migrated node.")
node_data: HITLv2NodeData = Field(description="The complete converted Human Input v2 node data.")
class NodeDataMigrationResponse(ResponseModel):
"""Successful all-node conversion response."""
data: list[NodeDataMigrationResult]
NodeDataMigrationBlockerCode = Literal[
"unsupported-version",
"configured-disabled-method",
"unsupported-delivery-method",
"invalid-email-configuration",
"invalid-email",
"unresolved-member",
"conflicting-email-templates",
"missing-recipients",
]
class NodeDataMigrationBlocker(ResponseModel):
"""Stable node-scoped reason why the backend cannot produce lossless v2 data."""
node_id: str = Field(description="The identifier of the node that failed migration.")
node_title: str = Field(description="The node title used for actionable frontend feedback.")
code: NodeDataMigrationBlockerCode = Field(description="Machine-readable migration blocker code.")
method_id: str | None = Field(default=None, description="Legacy delivery method related to the blocker.")
value: str | None = Field(default=None, description="Safe legacy value related to the blocker.")
class NodeDataMigrationFailureResponse(ResponseModel):
"""Whole-batch failure response without partial converted node data."""
code: Literal["hitl_node_data_migration_failure"] = "hitl_node_data_migration_failure"
message: str = Field(..., description="overall error messages")
status: Literal[HTTPStatus.BAD_REQUEST] = HTTPStatus.BAD_REQUEST
blockers: list[NodeDataMigrationBlocker] = Field(
..., description="Node-scoped blockers that caused the whole batch to fail."
)
# =================== EmailProvider related entities ===================
class PreserveOriginalValue(_RequestModel):
tag: Literal["preserve_original_value"] = "preserve_original_value"
class ResendProviderUpdateConfig(_RequestModel):
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
api_key: str | PreserveOriginalValue = Field(
...,
description=(
"Resend API key. "
"Setting this to `PreserveOriginalValue` while updating will preserve the previously set credential."
),
)
sender_email: str = Field(
..., description="The email address shown as the sender. Its domain must be verified in Resend."
)
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
class ResendProviderConfigResponse(ResponseModel):
type: Literal[EmailProviderType.RESEND] = EmailProviderType.RESEND
api_key_configured: bool = Field(description="Whether a Resend API key has been configured.")
sender_email: str = Field(description="The email address shown as the sender.")
sender_name: str = Field("", description="The sender's name displayed in the recipient's inbox.")
EmailProviderUpdateConfig = ResendProviderUpdateConfig
EmailProviderConfigResponse = ResendProviderConfigResponse
class GetEmailProviderResponse(ResponseModel):
provider_config: EmailProviderConfigResponse | None = Field(
...,
description="The current email provider configuration. `None` if not set.",
)
class SetEmailProviderRequest(_RequestModel):
provider_config: EmailProviderUpdateConfig = Field(..., description="Email provider configuration update.")
class SetEmailProviderResponse(ResponseModel):
pass
class TestEmailProviderConfigRequest(_RequestModel):
pass
class TestEmailProviderConfigResponse(ResponseModel):
pass
__all__ = [
"AddPlatformContactsRequest",
"AddPlatformContactsResponse",
"BatchGetContactOptionsQuery",
"BatchGetContactOptionsResponse",
"ContactListQuery",
"ContactOption",
"ContactOptionsQuery",
"CreateIMSyncRunResponse",
"DeleteIMIntegrationQuery",
"DingTalkIMIntegrationCredentials",
"EmailProviderConfigResponse",
"EmailProviderType",
"EmailProviderUpdateConfig",
"ExternalContactCreateRequest",
"ExternalContactUpdateRequest",
"FeishuIMIntegrationCredentials",
"FormAccessRequestResponse",
"FormDefinitionResponse",
"FormSubmitResponse",
"FormUploadTokenResponse",
"GetContactResponse",
"GetEmailProviderResponse",
"GetIMIntegrationResponse",
"GetLatestIMSyncRunResponse",
"HumanInputContact",
"HumanInputContactType",
"HumanInputV2FormSubmitRequest",
"HumanInputV2ServiceFormSubmitRequest",
"IMIdentity",
"IMIdentityBindingStatus",
"IMIntegration",
"IMIntegrationCredentials",
"IMIntegrationStatus",
"IMProvider",
"IMSyncRemovalReason",
"IMSyncResultItem",
"IMSyncResultType",
"IMSyncRun",
"IMSyncRunResultCounts",
"IMSyncRunStatus",
"LarkIMIntegrationCredentials",
"ListContactOptionsResponse",
"ListContactsResponse",
"ListIMIdentitiesQuery",
"ListIMIdentitiesResponse",
"ListLatestIMSyncRunResultsQuery",
"ListLatestIMSyncRunResultsResponse",
"ListOrganizationCandidatesResponse",
"MSTeamsIMIntegrationCredentials",
"MessageTemplateTestRequest",
"MessageTemplateTestResponse",
"NodeDataMigrationFailureResponse",
"NodeDataMigrationPayload",
"NodeDataMigrationResponse",
"OrganizationCandidate",
"OrganizationCandidatesQuery",
"PreserveOriginalValue",
"RemoveContactsRequest",
"RemoveContactsResponse",
"ResendProviderConfigResponse",
"ResendProviderUpdateConfig",
"ResetContactIMOverrideResponse",
"ServiceFormQuery",
"SetContactIMOverrideRequest",
"SetContactIMOverrideResponse",
"SetEmailProviderRequest",
"SetEmailProviderResponse",
"SlackIMIntegrationCredentials",
"TestIMIntegrationRequest",
"TestIMIntegrationResponse",
"UpdateIMIntegrationRequest",
"UpdateIMIntegrationResponse",
"WeComIMIntegrationCredentials",
]
+4
View File
@@ -76,6 +76,7 @@ from .app import (
workflow_app_log,
workflow_comment,
workflow_draft_variable,
workflow_human_input_v2,
workflow_node_output_inspector,
workflow_run,
workflow_statistic,
@@ -138,6 +139,7 @@ from .workspace import (
account,
agent_providers,
endpoint,
human_input,
load_balancing_config,
members,
model_providers,
@@ -194,6 +196,7 @@ __all__ = [
"forgot_password",
"generator",
"hit_testing",
"human_input",
"human_input_form",
"init_validate",
"installed_app",
@@ -241,6 +244,7 @@ __all__ = [
"workflow_app_log",
"workflow_comment",
"workflow_draft_variable",
"workflow_human_input_v2",
"workflow_node_output_inspector",
"workflow_run",
"workflow_run_archive",
@@ -0,0 +1,70 @@
"""Draft Human Input v2 workflow controller stubs."""
from __future__ import annotations
from http import HTTPStatus
from flask import abort
from flask_restx import Resource
from controllers.common.human_input_v2_contracts import (
MessageTemplateTestRequest,
MessageTemplateTestResponse,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.console import console_ns
from controllers.console.wraps import (
RBACPermission,
RBACResourceScope,
account_initialization_required,
edit_permission_required,
rbac_permission_required,
setup_required,
with_current_user,
)
from libs.login import login_required
from models import Account
from models.model import AppMode
from .wraps import get_app_model
register_schema_models(console_ns, MessageTemplateTestRequest)
register_response_schema_models(console_ns, MessageTemplateTestResponse)
def _raise_stub_not_implemented() -> None:
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 draft stub endpoint is not implemented yet.")
@console_ns.route("/apps/<uuid:app_id>/workflows/draft/human-input/nodes/<string:node_id>/message-template/test")
class WorkflowDraftMessageTemplateTestApi(Resource):
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@get_app_model(mode=[AppMode.WORKFLOW])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model, node_id: str):
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route(
"/apps/<uuid:app_id>/advanced-chat/workflows/draft/human-input/nodes/<string:node_id>/message-template/test"
)
class AdvancedChatDraftMessageTemplateTestApi(Resource):
@console_ns.expect(console_ns.models[MessageTemplateTestRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[MessageTemplateTestResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@rbac_permission_required(RBACResourceScope.APP, RBACPermission.APP_TEST_AND_RUN)
@get_app_model(mode=[AppMode.ADVANCED_CHAT])
@with_current_user
@edit_permission_required
def post(self, current_user: Account, app_model, node_id: str):
MessageTemplateTestRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@@ -0,0 +1,541 @@
"""Workspace-level Human Input v2 controller stubs."""
from __future__ import annotations
from http import HTTPStatus
from flask import abort, request
from flask_restx import Resource
from controllers.common.human_input_v2_contracts import (
AddPlatformContactsRequest,
AddPlatformContactsResponse,
BatchGetContactOptionsQuery,
BatchGetContactOptionsResponse,
BatchGetContactsQuery,
BatchGetContactsResponse,
ContactListQuery,
ContactOption,
ContactOptionsQuery,
CreateIMBindingRequest,
CreateIMBindingResponse,
CreateIMSyncRunResponse,
DeleteIMBindingQuery,
DeleteIMBindingResponse,
DeleteIMIntegrationQuery,
ExternalContactCreateRequest,
ExternalContactCreateResponse,
ExternalContactUpdateRequest,
ExternalContactUpdateResponse,
GetContactResponse,
GetEmailProviderResponse,
GetIMIntegrationResponse,
GetLatestIMSyncRunResponse,
HumanInputContact,
HumanInputContactType,
IMIntegrationStatus,
IMProvider,
IMSyncResultType,
IMSyncRunStatus,
ListContactOptionsResponse,
ListContactsResponse,
ListIMIdentitiesQuery,
ListIMIdentitiesResponse,
ListLatestIMSyncRunResultsQuery,
ListLatestIMSyncRunResultsResponse,
ListOrganizationCandidatesResponse,
NodeDataMigrationFailureResponse,
NodeDataMigrationPayload,
NodeDataMigrationResponse,
OrganizationCandidatesQuery,
RemoveContactsRequest,
RemoveContactsResponse,
ResetContactIMOverrideResponse,
SetContactIMOverrideRequest,
SetContactIMOverrideResponse,
SetEmailProviderRequest,
SetEmailProviderResponse,
TestIMIntegrationRequest,
TestIMIntegrationResponse,
UpdateIMIntegrationRequest,
UpdateIMIntegrationResponse,
)
from controllers.common.schema import (
query_params_from_model,
query_params_from_request,
register_enum_models,
register_response_schema_models,
register_schema_models,
)
from controllers.console import console_ns
from controllers.console.wraps import (
account_initialization_required,
edit_permission_required,
is_admin_or_owner_required,
setup_required,
with_current_tenant_id,
)
from libs.login import login_required
register_enum_models(
console_ns,
HumanInputContactType,
IMIntegrationStatus,
IMSyncRunStatus,
IMSyncResultType,
IMProvider,
)
register_schema_models(
console_ns,
ContactListQuery,
ContactOptionsQuery,
BatchGetContactOptionsQuery,
OrganizationCandidatesQuery,
AddPlatformContactsRequest,
ExternalContactCreateRequest,
ExternalContactUpdateRequest,
RemoveContactsRequest,
UpdateIMIntegrationRequest,
DeleteIMIntegrationQuery,
TestIMIntegrationRequest,
ListIMIdentitiesQuery,
ListLatestIMSyncRunResultsQuery,
SetContactIMOverrideRequest,
CreateIMBindingRequest,
NodeDataMigrationPayload,
SetEmailProviderRequest,
)
register_response_schema_models(
console_ns,
HumanInputContact,
ContactOption,
GetContactResponse,
ExternalContactCreateResponse,
ExternalContactUpdateResponse,
AddPlatformContactsResponse,
ListContactsResponse,
ListContactOptionsResponse,
BatchGetContactOptionsResponse,
RemoveContactsResponse,
ListIMIdentitiesResponse,
GetIMIntegrationResponse,
UpdateIMIntegrationResponse,
TestIMIntegrationResponse,
CreateIMSyncRunResponse,
GetLatestIMSyncRunResponse,
ListLatestIMSyncRunResultsResponse,
ListOrganizationCandidatesResponse,
ResetContactIMOverrideResponse,
SetContactIMOverrideResponse,
CreateIMBindingResponse,
DeleteIMBindingResponse,
BatchGetContactsResponse,
NodeDataMigrationResponse,
NodeDataMigrationFailureResponse,
GetEmailProviderResponse,
SetEmailProviderResponse,
)
def _raise_stub_not_implemented() -> None:
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 stub endpoint is not implemented yet.")
@console_ns.route("/workspaces/current/human-input/contacts")
class WorkspaceContactsApi(Resource):
@console_ns.doc(params=query_params_from_model(ContactListQuery))
@console_ns.response(200, "Success", console_ns.models[ListContactsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
ContactListQuery.model_validate(request.args.to_dict(flat=True))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>")
class WorkspaceContactApi(Resource):
"""Read one contact only when it resolves in the current workspace scope."""
@console_ns.response(200, "Success", console_ns.models[GetContactResponse.__name__])
@console_ns.response(404, "Contact not found or absent in the current workspace")
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str, contact_id: str):
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contact-options")
class WorkspaceContactOptionsApi(Resource):
"""Search the current workspace's selectable Contact projection for workflow editors."""
@console_ns.doc(
params=query_params_from_model(ContactOptionsQuery),
description=(
"List editor-safe Contact options for static recipient selection. "
"The projection omits email, IM bindings, and management metadata; contacts that resolve as ABSENT "
"or are otherwise unavailable in the current workspace are omitted."
),
)
@console_ns.response(200, "Success", console_ns.models[ListContactOptionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def get(self, tenant_id: str):
ContactOptionsQuery.model_validate(request.args.to_dict(flat=True))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/organization-candidates")
class WorkspaceOrganizationCandidatesApi(Resource):
@console_ns.doc(params=query_params_from_model(OrganizationCandidatesQuery))
@console_ns.response(200, "Success", console_ns.models[ListOrganizationCandidatesResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
OrganizationCandidatesQuery.model_validate(request.args.to_dict(flat=True))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/platform")
class WorkspacePlatformContactsApi(Resource):
@console_ns.expect(console_ns.models[AddPlatformContactsRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[AddPlatformContactsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def post(self, tenant_id: str):
AddPlatformContactsRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/external")
class WorkspaceExternalContactsApi(Resource):
@console_ns.expect(console_ns.models[ExternalContactCreateRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[ExternalContactCreateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def post(self, tenant_id: str):
ExternalContactCreateRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/external/<uuid:contact_id>")
class WorkspaceExternalContactApi(Resource):
@console_ns.expect(console_ns.models[ExternalContactUpdateRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[ExternalContactUpdateResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def patch(self, tenant_id: str, contact_id: str):
ExternalContactUpdateRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/remove")
class WorkspaceContactsRemoveApi(Resource):
@console_ns.expect(console_ns.models[RemoveContactsRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[RemoveContactsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def post(self, tenant_id: str):
RemoveContactsRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-integration")
class WorkspaceIMIntegrationApi(Resource):
@console_ns.response(200, "Success", console_ns.models[GetIMIntegrationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
_raise_stub_not_implemented()
@console_ns.expect(console_ns.models[UpdateIMIntegrationRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[UpdateIMIntegrationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def put(self, tenant_id: str):
UpdateIMIntegrationRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.doc(params=query_params_from_model(DeleteIMIntegrationQuery))
@console_ns.response(204, "IM integration deleted successfully")
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def delete(self, tenant_id: str):
query_params_from_request(DeleteIMIntegrationQuery)
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-integration/test")
class WorkspaceIMIntegrationTestApi(Resource):
@console_ns.expect(console_ns.models[TestIMIntegrationRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[TestIMIntegrationResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def post(self, tenant_id: str):
TestIMIntegrationRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-sync-runs")
class WorkspaceIMSyncRunsApi(Resource):
@console_ns.response(200, "Success", console_ns.models[CreateIMSyncRunResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def post(self, tenant_id: str):
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest")
class WorkspaceLatestIMSyncRunApi(Resource):
@console_ns.doc(
description=(
"Return the latest IM sync run summary. The UI uses finished_at as the explicit sync time; "
"the response does not include started_by."
)
)
@console_ns.response(200, "Success", console_ns.models[GetLatestIMSyncRunResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-sync-runs/latest/results")
class WorkspaceLatestIMSyncRunResultsApi(Resource):
@console_ns.doc(
params=query_params_from_model(ListLatestIMSyncRunResultsQuery),
description=(
"Return one required result bucket from the latest IM sync run using page and limit pagination. "
"There is no all filter; the response contains page, limit, and total metadata without a run summary."
),
)
@console_ns.response(200, "Success", console_ns.models[ListLatestIMSyncRunResultsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
ListLatestIMSyncRunResultsQuery.model_validate(request.args.to_dict(flat=True))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/im-identities")
class WorkspaceIMIdentitiesApi(Resource):
@console_ns.doc(params=query_params_from_model(ListIMIdentitiesQuery))
@console_ns.response(200, "Success", console_ns.models[ListIMIdentitiesResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
ListIMIdentitiesQuery.model_validate(request.args.to_dict(flat=True))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-override")
class WorkspaceContactIMOverrideApi(Resource):
@console_ns.doc(
description=(
"Set or reset the IM override for a contact. "
"This endpoint is used to override the IM identity for a contact in the workspace."
),
)
@console_ns.expect(console_ns.models[SetContactIMOverrideRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[SetContactIMOverrideResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def put(self, tenant_id: str, contact_id: str):
# This API only works in EE.
SetContactIMOverrideRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.doc(
description=(
"Reset the IM override for a contact. "
"This endpoint is used to clear the IM identity override for a contact in the workspace."
),
)
@console_ns.response(200, "Success", console_ns.models[ResetContactIMOverrideResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def delete(self, tenant_id: str, contact_id: str):
# This API only works in EE.
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/<uuid:contact_id>/im-bindings")
class WorkspaceContactIMBindingsApi(Resource):
@console_ns.doc(
description=(
"Set an IM binding for a contact. Used for binding an IM identity to a contact. "
"This endpoint is not used for creating workspace IM override. "
"For that purpose, use WorkspaceContactIMOverrideApi.put instead."
),
)
@console_ns.expect(console_ns.models[CreateIMBindingRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[CreateIMBindingResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def put(self, tenant_id: str, contact_id: str):
CreateIMBindingRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.response(200, "Success", console_ns.models[DeleteIMBindingResponse.__name__])
@console_ns.doc(
params=query_params_from_model(DeleteIMBindingQuery),
description=(
"Delete an IM binding for a contact. Used for removing contact IM binding information. "
"This endpoint is not used for resetting workspace IM override. For that purpose, use "
"WorkspaceContactIMOverrideApi.delete instead."
),
)
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def delete(self, tenant_id: str, contact_id: str):
query_params_from_request(DeleteIMBindingQuery)
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contacts/batch")
class BatchGetContactsAPI(Resource):
@console_ns.doc(
params=query_params_from_model(BatchGetContactsQuery),
description=(
"Admin-only batch lookup for Contact management clients. "
"Workflow editors must use the editor-safe contact-options/batch projection."
),
)
@console_ns.response(200, "Success", console_ns.models[BatchGetContactsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
query_params_from_request(BatchGetContactsQuery, list_fields=("contact_ids",))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/contact-options/batch")
class BatchGetContactOptionsAPI(Resource):
"""Resolve persisted Contact IDs through the same editor-safe selection projection."""
@console_ns.doc(
params=query_params_from_model(BatchGetContactOptionsQuery),
description=(
"Resolve Contact IDs persisted in workflow recipient configuration. "
"Contacts that resolve as ABSENT or are otherwise unavailable in the current workspace are omitted."
),
)
@console_ns.response(200, "Success", console_ns.models[BatchGetContactOptionsResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def get(self, tenant_id: str):
query_params_from_request(BatchGetContactOptionsQuery, list_fields=("contact_ids",))
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/node-data-migration")
class NodeDataMigrationAPI(Resource):
@console_ns.doc(
description=(
"Migrate node data from HITLv1 to HITLv2. "
'A missing legacy version defaults to "1"; any other explicit version is rejected. '
"This endpoint only returns the migrated Human Input v2 node data to the client. "
"It does not update the workflow DSL."
),
)
@console_ns.expect(console_ns.models[NodeDataMigrationPayload.__name__])
@console_ns.response(200, "Success", console_ns.models[NodeDataMigrationResponse.__name__])
@console_ns.response(400, "Migration failed", console_ns.models[NodeDataMigrationFailureResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@edit_permission_required
@with_current_tenant_id
def post(self, tenant_id: str):
NodeDataMigrationPayload.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@console_ns.route("/workspaces/current/human-input/email-provider")
class HumanInputEmailProviderAPI(Resource):
@console_ns.doc(description="Retrieve the current email provider settings for human input")
@console_ns.response(200, "Success", console_ns.models[GetEmailProviderResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def get(self, tenant_id: str):
_raise_stub_not_implemented()
@console_ns.doc(description="update the current email provider settings for human input")
@console_ns.expect(console_ns.models[SetEmailProviderRequest.__name__])
@console_ns.response(200, "Success", console_ns.models[SetEmailProviderResponse.__name__])
@setup_required
@login_required
@account_initialization_required
@is_admin_or_owner_required
@with_current_tenant_id
def put(self, tenant_id: str):
SetEmailProviderRequest.model_validate(console_ns.payload or {})
_raise_stub_not_implemented()
@@ -8,14 +8,18 @@ paused human input forms in workflow/chatflow runs.
import json
import logging
from collections.abc import Sequence
from http import HTTPStatus
from typing import Any
from flask import Response
from flask import Response, abort, request
from flask_restx import Resource
from pydantic import ConfigDict, Field
from werkzeug.exceptions import BadRequest, NotFound
from controllers.common.human_input import HumanInputFormSubmitPayload, stringify_form_default_values
from controllers.common.human_input_v2_contracts import FormDefinitionResponse as HumanInputV2FormDefinitionResponse
from controllers.common.human_input_v2_contracts import FormSubmitResponse as HumanInputV2FormSubmitResponse
from controllers.common.human_input_v2_contracts import HumanInputV2ServiceFormSubmitRequest, ServiceFormQuery
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.service_api import service_api_ns
from controllers.service_api.schema import expect_with_user
@@ -43,8 +47,19 @@ class HumanInputFormSubmitResponse(ResponseModel):
model_config = ConfigDict(extra="forbid")
register_schema_models(service_api_ns, HumanInputFormSubmitPayload)
register_response_schema_models(service_api_ns, HumanInputFormDefinitionResponse, HumanInputFormSubmitResponse)
register_schema_models(
service_api_ns,
HumanInputFormSubmitPayload,
HumanInputV2ServiceFormSubmitRequest,
ServiceFormQuery,
)
register_response_schema_models(
service_api_ns,
HumanInputFormDefinitionResponse,
HumanInputFormSubmitResponse,
HumanInputV2FormDefinitionResponse,
HumanInputV2FormSubmitResponse,
)
def _jsonify_form_definition(form: Form, *, inputs: Sequence[FormInputConfig] = ()) -> Response:
@@ -188,3 +203,33 @@ class WorkflowHumanInputFormApi(Resource):
raise NotFound("Form not found")
return {}, 200
@service_api_ns.route("/form/human-input/<string:form_token>")
class WorkflowHumanInputV2FormApi(Resource):
"""Trusted Service API stub for Human Input v2 forms.
This route does not alias the v1 handler. The implementation must resolve a
v2 form and reject v1 form tokens before reading or submitting it.
"""
@service_api_ns.response(
200,
"Form retrieved successfully",
service_api_ns.models[HumanInputV2FormDefinitionResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.QUERY, required=True))
def get(self, app_model: App, end_user: EndUser, form_token: str):
ServiceFormQuery.model_validate(request.args.to_dict(flat=True))
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
@service_api_ns.expect(service_api_ns.models[HumanInputV2ServiceFormSubmitRequest.__name__])
@service_api_ns.response(
200,
"Form submitted successfully",
service_api_ns.models[HumanInputV2FormSubmitResponse.__name__],
)
@validate_app_token(fetch_user_arg=FetchUserArg(fetch_from=WhereisUserArg.JSON, required=True))
def post(self, app_model: App, end_user: EndUser, form_token: str):
HumanInputV2ServiceFormSubmitRequest.model_validate(service_api_ns.payload or {})
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 Service API stub endpoint is not implemented yet.")
+2
View File
@@ -25,6 +25,7 @@ from . import (
forgot_password,
human_input_file_upload,
human_input_form,
human_input_form_access_request,
login,
message,
passport,
@@ -49,6 +50,7 @@ __all__ = [
"forgot_password",
"human_input_file_upload",
"human_input_form",
"human_input_form_access_request",
"login",
"message",
"passport",
@@ -0,0 +1,67 @@
"""Public web Human Input v2 form stubs.
The hyphenated v2 routes are intentionally separate from the legacy underscored
Human Input form routes. Each runtime path must reject tokens owned by the other
version when the service implementation is added.
"""
from __future__ import annotations
from http import HTTPStatus
from flask import abort
from flask_restx import Resource
from controllers.common.human_input_v2_contracts import (
FormAccessRequestResponse,
FormDefinitionResponse,
FormSubmitResponse,
FormUploadTokenResponse,
HumanInputV2FormSubmitRequest,
)
from controllers.common.schema import register_response_schema_models, register_schema_models
from controllers.web import web_ns
register_schema_models(web_ns, HumanInputV2FormSubmitRequest)
register_response_schema_models(
web_ns,
FormAccessRequestResponse,
FormDefinitionResponse,
FormSubmitResponse,
FormUploadTokenResponse,
)
def _raise_stub_not_implemented() -> None:
abort(HTTPStatus.NOT_IMPLEMENTED, "Human Input v2 form stub endpoint is not implemented yet.")
@web_ns.route("/form/human-input/<string:form_token>")
class HumanInputV2FormApi(Resource):
"""Read or submit a Human Input v2 form without sharing v1 submission logic."""
@web_ns.response(200, "Success", web_ns.models[FormDefinitionResponse.__name__])
def get(self, form_token: str):
_raise_stub_not_implemented()
@web_ns.expect(web_ns.models[HumanInputV2FormSubmitRequest.__name__])
@web_ns.response(200, "Success", web_ns.models[FormSubmitResponse.__name__])
def post(self, form_token: str):
HumanInputV2FormSubmitRequest.model_validate(web_ns.payload or {})
_raise_stub_not_implemented()
@web_ns.route("/form/human-input/<string:form_token>/upload-token")
class HumanInputV2FormUploadTokenApi(Resource):
"""Issue an upload token for an active Human Input v2 form."""
@web_ns.response(200, "Success", web_ns.models[FormUploadTokenResponse.__name__])
def post(self, form_token: str):
_raise_stub_not_implemented()
@web_ns.route("/form/human-input/<string:form_token>/access-request")
class FormAccessRequestApi(Resource):
@web_ns.response(200, "Success", web_ns.models[FormAccessRequestResponse.__name__])
def post(self, form_token: str):
_raise_stub_not_implemented()
+3 -3
View File
@@ -88,7 +88,7 @@ from graphon.graph_events import (
from graphon.runtime import GraphRuntimeState, VariablePool
from graphon.variable_loader import DUMMY_VARIABLE_LOADER, VariableLoader, load_into_variable_pool
from models.workflow import Workflow
from tasks.mail_human_input_delivery_task import dispatch_human_input_email_task
from tasks.mail_human_input_delivery_task import dispatch_human_input_form_delivery_task
logger = logging.getLogger(__name__)
@@ -715,12 +715,12 @@ class WorkflowBasedAppRunner:
if not reason.form_id:
continue
try:
dispatch_human_input_email_task.apply_async(
dispatch_human_input_form_delivery_task.apply_async(
kwargs={"form_id": reason.form_id, "node_title": reason.node_title},
queue="mail",
)
except Exception: # pragma: no cover - defensive logging
logger.exception("Failed to enqueue human input email task for form %s", reason.form_id)
logger.exception("Failed to enqueue human input form delivery task for form %s", reason.form_id)
def _publish_event(self, event: AppQueueEvent):
self._queue_manager.publish(event, PublishFrom.APPLICATION_MANAGER)
View File
@@ -0,0 +1,236 @@
"""Human Input v2 recipient planning, Form, and OTP aggregate boundaries.
The package owns separate Form and OTP aggregate boundaries plus grant,
delivery, and upload facts. It exposes domain and persistence ports without
importing transport, provider, database-session, or ORM concerns.
"""
from .delivery import (
ConsoleEndpointConfiguration,
DeliveryAttempt,
DeliveryEndpoint,
DeliveryEndpointConfiguration,
EmailEndpointConfiguration,
EmailProviderConfiguration,
EndpointAccessCapability,
IMEndpointConfiguration,
UploadCapability,
UploadCapabilityRef,
UploadFileAssociation,
WebEndpointConfiguration,
)
from .form import (
FormCreation,
FormInactiveReason,
FormSnapshotIdentifierFactory,
FormState,
FrozenFormAction,
FrozenFormDefinition,
HumanInputForm,
InactiveFormState,
InvalidApproverGrantError,
InvalidSelectedActionError,
SubmissionTransitionDecision,
WaitingFormState,
)
from .frozen_values import FrozenJSONArray, FrozenJSONObject, JSONPrimitive
from .grants import ApproverGrant, ApproverGrantRef, DeliveryEndpointRef, FormRef, OTPChallengeRef
from .otp import (
Clock,
ContactOTPSubject,
CurrentEmailOTPIdentity,
EmailAddressOTPSubject,
EmailOTPProofAuthorizationDecision,
EmailOTPSubject,
OTPChallenge,
OTPChallengePublicPrimitive,
OTPChallengeRejectionReason,
OTPChallengeRepository,
OTPChallengeState,
OTPCodeHash,
OTPCodeHasher,
OTPReplacementDecision,
OTPVerificationDecision,
VerifiedEmailOTPProof,
VerifiedEmailOTPProofPrimitive,
authorize_email_otp_proof,
)
from .ports import FormDefinitionProjection, FormDeliveryProjection, FormRepository
from .recipient_resolution import (
ApprovalSubject,
CanonicalSubjectKey,
ConsoleEndpointPlan,
ContactApprovalSubject,
ContactInitiatorSnapshot,
DebugRecipientReplacement,
DeliveryCapabilitySnapshot,
DeliveryEndpointPlan,
EmailAddressApprovalSubject,
EmailEndpointPlan,
EndUserApprovalSubject,
EndUserInitiatorSnapshot,
IMEndpointPlan,
MatchedRecipientSource,
RecipientRejectionReason,
RecipientResolutionFailureReason,
RecipientResolver,
RecipientSourceKind,
RejectedRecipient,
ResolvedApprovalPlan,
ResolvedApprover,
SubjectSnapshot,
WebEndpointPlan,
)
from .recipient_specifications import (
ContactRecipientSpecification,
CurrentInitiatorRecipientSpecification,
DynamicEmailRecipientSpecification,
DynamicRecipientValue,
OneTimeEmailRecipientSpecification,
RecipientSpecification,
RecipientSpecificationKind,
UnsupportedDynamicRecipientValue,
WorkflowRecipientSpecificationAdapter,
)
from .submission_authorization import (
AccountSubmissionActor,
AuthorizationContext,
AuthorizedSubmission,
CurrentContactAuthorizationFacts,
CurrentEndUserAuthorizationFacts,
CurrentIMAuthorizationFacts,
EmailAddressSubmissionActor,
EndUserSubmissionActor,
SubmissionActor,
SubmissionAuthorizationDecision,
SubmissionAuthorizationRejection,
SubmissionAuthorizer,
VerifiedAccountSessionProof,
VerifiedIMIdentityProof,
VerifiedSubmissionProof,
VerifiedTrustedEndUserProof,
)
from .submission_ports import (
AuthorizedSubmissionCommit,
RetryableSubmissionPersistenceError,
SubmissionAttemptScope,
SubmissionCommitResult,
SubmissionCommitStatus,
SubmissionRepository,
SubmissionTransaction,
)
from .submission_records import FormAuthorizationAuditEvent, FormAuthorizationAuditEventType, FormSubmission
__all__ = [
"AccountSubmissionActor",
"ApprovalSubject",
"ApproverGrant",
"ApproverGrantRef",
"AuthorizationContext",
"AuthorizedSubmission",
"AuthorizedSubmissionCommit",
"CanonicalSubjectKey",
"Clock",
"ConsoleEndpointConfiguration",
"ConsoleEndpointPlan",
"ContactApprovalSubject",
"ContactInitiatorSnapshot",
"ContactOTPSubject",
"ContactRecipientSpecification",
"CurrentContactAuthorizationFacts",
"CurrentEmailOTPIdentity",
"CurrentEndUserAuthorizationFacts",
"CurrentIMAuthorizationFacts",
"CurrentInitiatorRecipientSpecification",
"DebugRecipientReplacement",
"DeliveryAttempt",
"DeliveryCapabilitySnapshot",
"DeliveryEndpoint",
"DeliveryEndpointConfiguration",
"DeliveryEndpointPlan",
"DeliveryEndpointRef",
"DynamicEmailRecipientSpecification",
"DynamicRecipientValue",
"EmailAddressApprovalSubject",
"EmailAddressOTPSubject",
"EmailAddressSubmissionActor",
"EmailEndpointConfiguration",
"EmailEndpointPlan",
"EmailOTPProofAuthorizationDecision",
"EmailOTPSubject",
"EmailProviderConfiguration",
"EndUserApprovalSubject",
"EndUserInitiatorSnapshot",
"EndUserSubmissionActor",
"EndpointAccessCapability",
"FormAuthorizationAuditEvent",
"FormAuthorizationAuditEventType",
"FormCreation",
"FormDefinitionProjection",
"FormDeliveryProjection",
"FormInactiveReason",
"FormRef",
"FormRepository",
"FormSnapshotIdentifierFactory",
"FormState",
"FormSubmission",
"FrozenFormAction",
"FrozenFormDefinition",
"FrozenJSONArray",
"FrozenJSONObject",
"HumanInputForm",
"IMEndpointConfiguration",
"IMEndpointPlan",
"InactiveFormState",
"InvalidApproverGrantError",
"InvalidSelectedActionError",
"JSONPrimitive",
"MatchedRecipientSource",
"OTPChallenge",
"OTPChallengePublicPrimitive",
"OTPChallengeRef",
"OTPChallengeRejectionReason",
"OTPChallengeRepository",
"OTPChallengeState",
"OTPCodeHash",
"OTPCodeHasher",
"OTPReplacementDecision",
"OTPVerificationDecision",
"OneTimeEmailRecipientSpecification",
"RecipientRejectionReason",
"RecipientResolutionFailureReason",
"RecipientResolver",
"RecipientSourceKind",
"RecipientSpecification",
"RecipientSpecificationKind",
"RejectedRecipient",
"ResolvedApprovalPlan",
"ResolvedApprover",
"RetryableSubmissionPersistenceError",
"SubjectSnapshot",
"SubmissionActor",
"SubmissionAttemptScope",
"SubmissionAuthorizationDecision",
"SubmissionAuthorizationRejection",
"SubmissionAuthorizer",
"SubmissionCommitResult",
"SubmissionCommitStatus",
"SubmissionRepository",
"SubmissionTransaction",
"SubmissionTransitionDecision",
"UnsupportedDynamicRecipientValue",
"UploadCapability",
"UploadCapabilityRef",
"UploadFileAssociation",
"VerifiedAccountSessionProof",
"VerifiedEmailOTPProof",
"VerifiedEmailOTPProofPrimitive",
"VerifiedIMIdentityProof",
"VerifiedSubmissionProof",
"VerifiedTrustedEndUserProof",
"WaitingFormState",
"WebEndpointConfiguration",
"WebEndpointPlan",
"WorkflowRecipientSpecificationAdapter",
"authorize_email_otp_proof",
]
@@ -0,0 +1,287 @@
"""Frozen delivery, provider, endpoint-token, and upload facts.
Endpoints describe where a form can be delivered or interacted with. Their
tokens are scoped capabilities only; this module deliberately exposes no actor
or verified-proof conversion. Delivery attempts are append-only diagnostics and
cannot mutate :class:`HumanInputForm` lifecycle state.
"""
from __future__ import annotations
from dataclasses import dataclass
from hashlib import sha256
from typing import assert_never
from core.human_input_v2.entities import (
EmailProviderType,
HumanInputDeliveryAttemptStatus,
HumanInputDeliveryChannel,
IMProvider,
)
from core.human_input_v2.shared import (
AccountId,
AppId,
DeliveryAttemptId,
DeliveryEndpointId,
EmailProviderId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
)
from .frozen_values import FrozenJSONObject
from .grants import ApproverGrantRef, DeliveryEndpointRef
from .recipient_resolution import (
ConsoleEndpointPlan,
DeliveryEndpointPlan,
EmailEndpointPlan,
IMEndpointPlan,
WebEndpointPlan,
)
def _validate_sha256(value: str, *, label: str) -> None:
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
@dataclass(frozen=True, slots=True)
class EndpointAccessCapability:
"""Hashed endpoint interaction capability that carries no identity proof."""
endpoint_ref: DeliveryEndpointRef
token_hash: str
def __post_init__(self) -> None:
_validate_sha256(self.token_hash, label="endpoint access token hash")
@dataclass(frozen=True, slots=True)
class EmailEndpointConfiguration:
"""Frozen Email delivery address."""
email_address: NormalizedEmail
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.EMAIL
@dataclass(frozen=True, slots=True)
class IMEndpointConfiguration:
"""Credential-free IM interaction snapshot owned by one integration."""
integration_id: IntegrationId
provider: IMProvider
provider_tenant_id: str
identity_id: IMIdentityId
binding_id: IMBindingId | None
provider_user_id: str
def __post_init__(self) -> None:
if not self.provider_tenant_id or not self.provider_user_id:
raise ValueError("IM endpoint provider identities must not be blank")
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.IM
@dataclass(frozen=True, slots=True)
class WebEndpointConfiguration:
"""Public or trusted-app web interaction surface."""
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.WEB
@dataclass(frozen=True, slots=True)
class ConsoleEndpointConfiguration:
"""Authenticated console interaction surface."""
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.CONSOLE
type DeliveryEndpointConfiguration = (
EmailEndpointConfiguration | IMEndpointConfiguration | WebEndpointConfiguration | ConsoleEndpointConfiguration
)
@dataclass(frozen=True, slots=True)
class DeliveryEndpoint:
"""Historical endpoint snapshot distinct from its approver grant."""
ref: DeliveryEndpointRef
configuration: DeliveryEndpointConfiguration
address_hash: str
access_capability: EndpointAccessCapability | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
_validate_sha256(self.address_hash, label="endpoint address hash")
if self.access_capability is not None and self.access_capability.endpoint_ref != self.ref:
raise ValueError("endpoint access capability owner does not match the endpoint")
@property
def id(self) -> DeliveryEndpointId:
return self.ref.endpoint_id
@property
def grant_ref(self) -> ApproverGrantRef:
return self.ref.grant_ref
@property
def channel(self) -> HumanInputDeliveryChannel:
return self.configuration.channel
@classmethod
def from_plan(
cls,
*,
endpoint_id: DeliveryEndpointId,
grant_ref: ApproverGrantRef,
endpoint_plan: DeliveryEndpointPlan,
access_capability: EndpointAccessCapability | None,
now: UtcTimestamp,
) -> DeliveryEndpoint:
endpoint_ref = grant_ref.endpoint(endpoint_id)
configuration: DeliveryEndpointConfiguration
canonical_address: str
match endpoint_plan:
case EmailEndpointPlan(email_address=email_address):
configuration = EmailEndpointConfiguration(email_address)
canonical_address = f"email:{email_address}"
case IMEndpointPlan(
integration_id=integration_id,
provider=provider,
provider_tenant_id=provider_tenant_id,
identity_id=identity_id,
binding_id=binding_id,
provider_user_id=provider_user_id,
):
configuration = IMEndpointConfiguration(
integration_id=integration_id,
provider=provider,
provider_tenant_id=provider_tenant_id,
identity_id=identity_id,
binding_id=binding_id,
provider_user_id=provider_user_id,
)
canonical_address = f"im:{integration_id}:{provider.value}:{provider_user_id}"
case WebEndpointPlan():
configuration = WebEndpointConfiguration()
canonical_address = "web"
case ConsoleEndpointPlan():
configuration = ConsoleEndpointConfiguration()
canonical_address = "console"
case _:
assert_never(endpoint_plan)
return cls(
ref=endpoint_ref,
configuration=configuration,
address_hash=sha256(canonical_address.encode()).hexdigest(),
access_capability=access_capability,
created_at=now,
updated_at=now,
)
@dataclass(frozen=True, slots=True)
class DeliveryAttempt:
"""Append-only provider delivery outcome scoped to one endpoint."""
id: DeliveryAttemptId
endpoint_ref: DeliveryEndpointRef
attempt_number: int
status: HumanInputDeliveryAttemptStatus
scheduled_at: UtcTimestamp
started_at: UtcTimestamp | None
finished_at: UtcTimestamp | None
provider_message_id: str | None
failure_code: str | None
failure_reason: str | None
provider_response: FrozenJSONObject | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if self.attempt_number < 1:
raise ValueError("delivery attempt number must be positive")
if self.status is HumanInputDeliveryAttemptStatus.FAILED:
if self.finished_at is None:
raise ValueError("failed delivery attempt requires finished_at")
has_failure_code = self.failure_code is not None and bool(self.failure_code.strip())
has_failure_reason = self.failure_reason is not None and bool(self.failure_reason.strip())
if not has_failure_code and not has_failure_reason and self.provider_response is None:
raise ValueError("failed delivery attempt requires a failure diagnostic")
if self.status is not HumanInputDeliveryAttemptStatus.FAILED and (
self.failure_code is not None or self.failure_reason is not None
):
raise ValueError("only failed delivery attempts may contain failure diagnostics")
@dataclass(frozen=True, slots=True)
class EmailProviderConfiguration:
"""Workspace provider configuration kept outside the form domain lifecycle."""
id: EmailProviderId
workspace_id: WorkspaceId
provider: EmailProviderType
sender_email: NormalizedEmail
sender_name: str
encrypted_credentials: FrozenJSONObject
configured_by_account_id: AccountId | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
@dataclass(frozen=True, slots=True)
class UploadCapabilityRef:
"""Upload capability reference carrying the complete endpoint owner chain."""
endpoint_ref: DeliveryEndpointRef
capability_id: UploadCapabilityId
app_id: AppId
@dataclass(frozen=True, slots=True)
class UploadCapability:
"""Hashed upload capability scoped to exactly one form endpoint."""
id: UploadCapabilityId
endpoint_ref: DeliveryEndpointRef
app_id: AppId
token_hash: str
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
_validate_sha256(self.token_hash, label="upload token hash")
@property
def ref(self) -> UploadCapabilityRef:
return UploadCapabilityRef(self.endpoint_ref, self.id, self.app_id)
@dataclass(frozen=True, slots=True)
class UploadFileAssociation:
"""Durable file fact whose scope is inherited from its upload capability."""
id: UploadFileAssociationId
capability_ref: UploadCapabilityRef
upload_file_id: str
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not self.upload_file_id.strip():
raise ValueError("upload file id must not be blank")
+261
View File
@@ -0,0 +1,261 @@
"""Rich Human Input v2 form aggregate and plan-to-snapshot creation.
``HumanInputForm`` directly owns every local lifecycle decision: persisted
status, node/global expiry, grant membership, and selected actions. It returns a
transition decision only; committing the first successful submission belongs to
the later submission transaction boundary.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from core.human_input_v2.entities import HumanInputV2FormKind, HumanInputV2FormStatus
from core.human_input_v2.shared import (
AppId,
ApproverGrantId,
DeliveryEndpointId,
UtcTimestamp,
)
from .delivery import DeliveryEndpoint
from .frozen_values import FrozenJSONObject
from .grants import ApproverGrant, FormRef
from .recipient_resolution import ResolvedApprovalPlan
class InvalidApproverGrantError(ValueError):
"""The selected grant does not belong to this form snapshot."""
class InvalidSelectedActionError(ValueError):
"""The selected action is absent from the frozen definition."""
@dataclass(frozen=True, slots=True)
class FrozenFormAction:
"""Immutable action values required for display and transition validation."""
id: str
title: str
button_style: str
def __post_init__(self) -> None:
if not self.id or not self.title or not self.button_style:
raise ValueError("frozen form action values must not be blank")
@dataclass(frozen=True, slots=True)
class FrozenFormDefinition:
"""Immutable render and validation definition captured at form creation."""
form_content: str
inputs: tuple[FrozenJSONObject, ...]
actions: tuple[FrozenFormAction, ...]
default_values: FrozenJSONObject
node_title: str | None
display_in_ui: bool | None
def __post_init__(self) -> None:
if not isinstance(self.inputs, tuple) or not isinstance(self.actions, tuple):
raise TypeError("frozen form definition collections must be immutable tuples")
action_ids = [action.id for action in self.actions]
if len(action_ids) != len(set(action_ids)):
raise ValueError("frozen form action identifiers must be unique")
def accepts_action(self, selected_action_id: str) -> bool:
return any(action.id == selected_action_id for action in self.actions)
class FormInactiveReason(StrEnum):
"""Transport-neutral reason why a form cannot accept a transition."""
SUBMITTED = "submitted"
TIMED_OUT = "timed_out"
STATUS_EXPIRED = "status_expired"
GLOBALLY_EXPIRED = "globally_expired"
@dataclass(frozen=True, slots=True)
class WaitingFormState:
"""Stable active-state result for a waiting form."""
is_waiting: bool = True
@dataclass(frozen=True, slots=True)
class InactiveFormState:
"""Stable inactive-state result independent from HTTP status codes."""
reason: FormInactiveReason
is_waiting: bool = False
type FormState = WaitingFormState | InactiveFormState
@dataclass(frozen=True, slots=True)
class SubmissionTransitionDecision:
"""Validated intent that does not claim persistence has committed."""
form_ref: FormRef
grant_id: ApproverGrantId
selected_action_id: str
decided_at: UtcTimestamp
class FormSnapshotIdentifierFactory(Protocol):
"""Provide child identifiers without coupling the domain to persistence."""
def new_grant_id(self) -> ApproverGrantId: ...
def new_endpoint_id(self) -> DeliveryEndpointId: ...
@dataclass(frozen=True, slots=True)
class FormCreation:
"""Complete form/grant/endpoint snapshot persisted by one transaction."""
form: HumanInputForm
endpoints: tuple[DeliveryEndpoint, ...]
def __post_init__(self) -> None:
if not isinstance(self.endpoints, tuple):
raise TypeError("form creation endpoints must be an immutable tuple")
grant_refs = {grant.ref for grant in self.form.grants}
if any(endpoint.grant_ref not in grant_refs for endpoint in self.endpoints):
raise ValueError("form creation contains an endpoint outside its grants")
@dataclass(frozen=True, slots=True)
class HumanInputForm:
"""Form root owning local lifecycle and submission transition invariants."""
ref: FormRef
app_id: AppId
definition: FrozenFormDefinition
rendered_content: str
node_timeout_at: UtcTimestamp
global_expires_at: UtcTimestamp
kind: HumanInputV2FormKind
status: HumanInputV2FormStatus
workflow_pause_id: str | None
node_execution_id: str | None
grants: tuple[ApproverGrant, ...]
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not isinstance(self.grants, tuple):
raise TypeError("form grants must be an immutable tuple")
if self.kind is HumanInputV2FormKind.RUNTIME and (
self.workflow_pause_id is None or self.node_execution_id is None
):
raise ValueError("runtime form requires workflow pause and node execution owners")
if any(grant.ref.form_ref != self.ref for grant in self.grants):
raise ValueError("form contains a grant from another owner")
grant_ids = [grant.id for grant in self.grants]
subject_keys = [grant.subject_key for grant in self.grants]
if len(grant_ids) != len(set(grant_ids)) or len(subject_keys) != len(set(subject_keys)):
raise ValueError("form grants must have unique identifiers and canonical subjects")
def state_at(self, now: UtcTimestamp) -> FormState:
"""Return a stable status/expiry decision without changing persisted state."""
match self.status:
case HumanInputV2FormStatus.SUBMITTED:
return InactiveFormState(FormInactiveReason.SUBMITTED)
case HumanInputV2FormStatus.TIMEOUT:
return InactiveFormState(FormInactiveReason.TIMED_OUT)
case HumanInputV2FormStatus.EXPIRED:
return InactiveFormState(FormInactiveReason.STATUS_EXPIRED)
case HumanInputV2FormStatus.WAITING:
if now.value >= self.global_expires_at.value:
return InactiveFormState(FormInactiveReason.GLOBALLY_EXPIRED)
if now.value >= self.node_timeout_at.value:
return InactiveFormState(FormInactiveReason.TIMED_OUT)
return WaitingFormState()
raise AssertionError(f"unsupported Human Input form status: {self.status}")
def decide_submission(
self,
*,
grant_id: ApproverGrantId,
selected_action_id: str,
now: UtcTimestamp,
) -> SubmissionTransitionDecision | InactiveFormState:
"""Validate one local transition without mutating or persisting the form."""
state = self.state_at(now)
if isinstance(state, InactiveFormState):
return state
if not any(grant.id == grant_id for grant in self.grants):
raise InvalidApproverGrantError(str(grant_id))
if not self.definition.accepts_action(selected_action_id):
raise InvalidSelectedActionError(selected_action_id)
return SubmissionTransitionDecision(
form_ref=self.ref,
grant_id=grant_id,
selected_action_id=selected_action_id,
decided_at=now,
)
@classmethod
def create_from_plan(
cls,
*,
ref: FormRef,
app_id: AppId,
definition: FrozenFormDefinition,
rendered_content: str,
node_timeout_at: UtcTimestamp,
global_expires_at: UtcTimestamp,
kind: HumanInputV2FormKind,
workflow_pause_id: str | None,
node_execution_id: str | None,
plan: ResolvedApprovalPlan,
identifier_factory: FormSnapshotIdentifierFactory,
now: UtcTimestamp,
) -> FormCreation:
"""Map one deterministic resolved plan into a complete frozen snapshot."""
if not plan.approvers:
raise ValueError("form creation requires resolved approvers")
grants: list[ApproverGrant] = []
endpoints: list[DeliveryEndpoint] = []
for approver in plan.approvers:
grant = ApproverGrant.from_resolved_approver(
grant_id=identifier_factory.new_grant_id(),
form_ref=ref,
approver=approver,
now=now,
)
grants.append(grant)
for endpoint_plan in approver.endpoints:
endpoints.append(
DeliveryEndpoint.from_plan(
endpoint_id=identifier_factory.new_endpoint_id(),
grant_ref=grant.ref,
endpoint_plan=endpoint_plan,
access_capability=None,
now=now,
)
)
form = cls(
ref=ref,
app_id=app_id,
definition=definition,
rendered_content=rendered_content,
node_timeout_at=node_timeout_at,
global_expires_at=global_expires_at,
kind=kind,
status=HumanInputV2FormStatus.WAITING,
workflow_pause_id=workflow_pause_id,
node_execution_id=node_execution_id,
grants=tuple(grants),
created_at=now,
updated_at=now,
)
return FormCreation(form=form, endpoints=tuple(endpoints))
@@ -0,0 +1,100 @@
"""Recursively immutable JSON values used by frozen form snapshots.
Domain code never receives mutable dictionaries from persistence or provider
boundaries. Explicit conversion methods create fresh primitive containers only
when a caller crosses such a boundary.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
type JSONScalar = str | int | float | bool | None
type JSONPrimitive = JSONScalar | list[JSONPrimitive] | dict[str, JSONPrimitive]
type FrozenJSONValue = JSONScalar | FrozenJSONArray | FrozenJSONObject
def _validate_frozen_json(value: object) -> None:
if value is None or isinstance(value, (str, bool, int)):
return
if isinstance(value, float):
if not isfinite(value):
raise ValueError("JSON numbers must be finite")
return
if isinstance(value, (FrozenJSONArray, FrozenJSONObject)):
return
raise TypeError(f"frozen JSON values cannot contain {type(value).__name__}")
def _freeze_json(value: object) -> FrozenJSONValue:
if value is None or isinstance(value, (str, bool, int)):
return value
if isinstance(value, float):
if not isfinite(value):
raise ValueError("JSON numbers must be finite")
return value
if isinstance(value, Mapping):
return FrozenJSONObject.from_mapping(value)
if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)):
return FrozenJSONArray(tuple(_freeze_json(item) for item in value))
raise TypeError(f"unsupported JSON value type: {type(value).__name__}")
def _thaw_json(value: FrozenJSONValue) -> JSONPrimitive:
if isinstance(value, FrozenJSONObject):
return value.to_mapping()
if isinstance(value, FrozenJSONArray):
return value.to_list()
return value
@dataclass(frozen=True, slots=True)
class FrozenJSONArray:
"""Immutable JSON array with an explicit primitive conversion boundary."""
values: tuple[FrozenJSONValue, ...]
def __post_init__(self) -> None:
if not isinstance(self.values, tuple):
raise TypeError("frozen JSON array values must be an immutable tuple")
for value in self.values:
_validate_frozen_json(value)
def to_list(self) -> list[JSONPrimitive]:
return [_thaw_json(value) for value in self.values]
@dataclass(frozen=True, slots=True)
class FrozenJSONObject:
"""Immutable ordered JSON object independent from Pydantic and ORM types."""
entries: tuple[tuple[str, FrozenJSONValue], ...]
def __post_init__(self) -> None:
if not isinstance(self.entries, tuple):
raise TypeError("frozen JSON object entries must be an immutable tuple")
seen_keys: set[str] = set()
for entry in self.entries:
if not isinstance(entry, tuple) or len(entry) != 2:
raise TypeError("frozen JSON object entries must be key-value tuples")
key, value = entry
if not isinstance(key, str):
raise TypeError("JSON objects require string keys")
if key in seen_keys:
raise ValueError(f"duplicate JSON object key: {key}")
seen_keys.add(key)
_validate_frozen_json(value)
@classmethod
def from_mapping(cls, values: Mapping[str, object]) -> FrozenJSONObject:
entries: list[tuple[str, FrozenJSONValue]] = []
for key in sorted(values, key=lambda candidate: str(candidate)):
if not isinstance(key, str):
raise TypeError("JSON objects require string keys")
entries.append((key, _freeze_json(values[key])))
return cls(tuple(entries))
def to_mapping(self) -> dict[str, JSONPrimitive]:
return {key: _thaw_json(value) for key, value in self.entries}
+134
View File
@@ -0,0 +1,134 @@
"""Historical approver grants and form-scoped logical references.
Grants capture candidate authority at form creation. They are intentionally not
current authorization proofs: later submission code must revalidate the current
identity behind the frozen subject.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import assert_never
from core.human_input_v2.shared import (
ApproverGrantId,
DeliveryEndpointId,
FormId,
OTPChallengeId,
UtcTimestamp,
WorkspaceId,
)
from .recipient_resolution import (
ApprovalSubject,
CanonicalSubjectKey,
ContactApprovalSubject,
EmailAddressApprovalSubject,
EndUserApprovalSubject,
MatchedRecipientSource,
ResolvedApprover,
SubjectSnapshot,
)
def _subject_key(subject: ApprovalSubject) -> CanonicalSubjectKey:
match subject:
case ContactApprovalSubject(contact_id=contact_id):
return CanonicalSubjectKey.for_contact(contact_id)
case EndUserApprovalSubject(end_user_id=end_user_id):
return CanonicalSubjectKey.for_end_user(end_user_id)
case EmailAddressApprovalSubject(normalized_email=normalized_email):
return CanonicalSubjectKey.for_email(normalized_email)
case _:
assert_never(subject)
@dataclass(frozen=True, slots=True)
class FormRef:
"""Workspace-owned root reference; authorization still requires scoped queries."""
workspace_id: WorkspaceId
form_id: FormId
def grant(self, grant_id: ApproverGrantId) -> ApproverGrantRef:
return ApproverGrantRef(self, grant_id)
@dataclass(frozen=True, slots=True)
class ApproverGrantRef:
"""Grant reference carrying its complete form owner chain."""
form_ref: FormRef
grant_id: ApproverGrantId
def endpoint(self, endpoint_id: DeliveryEndpointId) -> DeliveryEndpointRef:
return DeliveryEndpointRef(self, endpoint_id)
def challenge(self, challenge_id: OTPChallengeId) -> OTPChallengeRef:
return OTPChallengeRef(self, challenge_id)
@dataclass(frozen=True, slots=True)
class OTPChallengeRef:
"""OTP proof-session reference carrying its complete grant owner chain."""
grant_ref: ApproverGrantRef
challenge_id: OTPChallengeId
@property
def form_ref(self) -> FormRef:
return self.grant_ref.form_ref
@dataclass(frozen=True, slots=True)
class DeliveryEndpointRef:
"""Endpoint reference carrying grant, form, and workspace ownership."""
grant_ref: ApproverGrantRef
endpoint_id: DeliveryEndpointId
@property
def form_ref(self) -> FormRef:
return self.grant_ref.form_ref
@dataclass(frozen=True, slots=True)
class ApproverGrant:
"""Frozen candidate approver with historical matched-source facts."""
ref: ApproverGrantRef
subject: ApprovalSubject
subject_key: CanonicalSubjectKey
matched_sources: tuple[MatchedRecipientSource, ...]
subject_snapshot: SubjectSnapshot
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not isinstance(self.matched_sources, tuple):
raise TypeError("matched sources must be an immutable tuple")
if self.subject_key != _subject_key(self.subject):
raise ValueError("approver grant subject key does not match its subject")
@property
def id(self) -> ApproverGrantId:
return self.ref.grant_id
@classmethod
def from_resolved_approver(
cls,
*,
grant_id: ApproverGrantId,
form_ref: FormRef,
approver: ResolvedApprover,
now: UtcTimestamp,
) -> ApproverGrant:
return cls(
ref=form_ref.grant(grant_id),
subject=approver.subject,
subject_key=approver.subject_key,
matched_sources=approver.matched_sources,
subject_snapshot=approver.subject_snapshot,
created_at=now,
updated_at=now,
)
+460
View File
@@ -0,0 +1,460 @@
"""Independent Email OTP proof-session lifecycle and limited proof boundary.
The aggregate owns only challenge expiry, cooldown, counters, verification, and
invalidation. It never reads or mutates :class:`HumanInputForm`; submission code
must separately compare a verified proof with coherent current identity facts.
Plaintext codes are transient method inputs and are never retained or serialized.
"""
from __future__ import annotations
from dataclasses import dataclass, field, replace
from datetime import timedelta
from enum import StrEnum
from typing import Protocol, TypedDict
from core.human_input_v2.entities import HumanInputAuthorizationProofType, HumanInputOTPChallengeStatus
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
from .grants import ApproverGrantRef, OTPChallengeRef
OTP_EXPIRY = timedelta(minutes=10)
OTP_RESEND_COOLDOWN = timedelta(seconds=60)
OTP_MAX_SEND_COUNT = 5
OTP_MAX_ATTEMPT_COUNT = 5
def _validate_sha256(value: str, *, label: str) -> None:
if len(value) != 64 or any(character not in "0123456789abcdef" for character in value):
raise ValueError(f"{label} must be a lower-case SHA-256 digest")
class Clock(Protocol):
"""Narrow clock port used to make all lifecycle boundaries deterministic."""
def now(self) -> UtcTimestamp: ...
@dataclass(frozen=True, slots=True)
class OTPCodeHash:
"""Opaque encoded code digest plus the verifier algorithm discriminator."""
encoded_value: str
algorithm: str
def __post_init__(self) -> None:
if not self.encoded_value or not self.algorithm.strip():
raise ValueError("OTP code hash values must not be blank")
class OTPCodeHasher(Protocol):
"""Hash and verify transient plaintext without exposing implementation policy."""
def hash_code(self, plaintext_code: str) -> OTPCodeHash: ...
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool: ...
@dataclass(frozen=True, slots=True)
class ContactOTPSubject:
"""Contact incarnation captured when an OTP challenge is issued."""
contact_id: ContactId
@dataclass(frozen=True, slots=True)
class EmailAddressOTPSubject:
"""Standalone normalized Email subject captured from a one-time grant."""
normalized_email: NormalizedEmail
type EmailOTPSubject = ContactOTPSubject | EmailAddressOTPSubject
class OTPChallengeRejectionReason(StrEnum):
"""Transport-neutral reason for a rejected OTP or proof operation."""
EXPIRED = "expired"
RESEND_COOLDOWN = "resend_cooldown"
SEND_LIMIT_REACHED = "send_limit_reached"
ATTEMPT_LIMIT_REACHED = "attempt_limit_reached"
ALREADY_VERIFIED = "already_verified"
INVALIDATED = "invalidated"
INVALID_CODE = "invalid_code"
RAW_CODE_NOT_VERIFIED = "raw_code_not_verified"
GRANT_MISMATCH = "grant_mismatch"
STALE_IDENTITY = "stale_identity"
class OTPChallengePublicPrimitive(TypedDict):
"""Secret-free diagnostic form of challenge state."""
otp_challenge_id: str
form_id: str
approver_grant_id: str
status: str
email: str
send_count: int
attempt_count: int
expires_at: str
resend_after: str
verified_at: str | None
invalidated_at: str | None
class VerifiedEmailOTPProofPrimitive(TypedDict):
"""Primitive proof shape safe for authorization and audit boundaries."""
type: str
otp_challenge_id: str
form_id: str
approver_grant_id: str
subject_type: str
contact_id: str | None
verified_email: str
verified_at: str
@dataclass(frozen=True, slots=True)
class VerifiedEmailOTPProof:
"""Immutable Email verification fact that carries no submission authority."""
challenge_ref: OTPChallengeRef
subject: EmailOTPSubject
normalized_email: NormalizedEmail
verified_at: UtcTimestamp
def __post_init__(self) -> None:
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
raise ValueError("OTP proof subject email must match the verified email")
def to_primitive(self) -> VerifiedEmailOTPProofPrimitive:
contact_id: str | None = None
subject_type = "email_address"
if isinstance(self.subject, ContactOTPSubject):
subject_type = "contact"
contact_id = str(self.subject.contact_id)
return {
"type": HumanInputAuthorizationProofType.EMAIL_OTP.value,
"otp_challenge_id": str(self.challenge_ref.challenge_id),
"form_id": str(self.challenge_ref.form_ref.form_id),
"approver_grant_id": str(self.challenge_ref.grant_ref.grant_id),
"subject_type": subject_type,
"contact_id": contact_id,
"verified_email": str(self.normalized_email),
"verified_at": self.verified_at.to_primitive(),
}
@dataclass(frozen=True, slots=True)
class OTPChallengeState:
"""Stable current usability result independent from transport status codes."""
status: HumanInputOTPChallengeStatus
rejection: OTPChallengeRejectionReason | None
@property
def is_usable(self) -> bool:
return self.rejection is None
@dataclass(frozen=True, slots=True)
class OTPReplacementDecision:
"""Immutable replacement result; persistence commits both states atomically."""
previous: OTPChallenge
replacement: OTPChallenge | None
rejection: OTPChallengeRejectionReason | None
@dataclass(frozen=True, slots=True)
class OTPVerificationDecision:
"""Verification result containing either one limited proof or one rejection."""
challenge: OTPChallenge
proof: VerifiedEmailOTPProof | None
rejection: OTPChallengeRejectionReason | None
@dataclass(frozen=True, slots=True)
class OTPChallenge:
"""Grant-scoped proof session whose counters never touch Form lifecycle state.
Persisted sessions reconstruct only when cooldown and expiry are the exact
durations derived from ``created_at``; stored timestamps are not policy input.
"""
ref: OTPChallengeRef
subject: EmailOTPSubject
normalized_email: NormalizedEmail
challenge_token_hash: str = field(repr=False)
code_hash: OTPCodeHash = field(repr=False)
status: HumanInputOTPChallengeStatus
expires_at: UtcTimestamp
resend_after: UtcTimestamp
send_count: int
attempt_count: int
verified_at: UtcTimestamp | None
invalidated_at: UtcTimestamp | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
_validate_sha256(self.challenge_token_hash, label="challenge token hash")
if isinstance(self.subject, EmailAddressOTPSubject) and self.subject.normalized_email != self.normalized_email:
raise ValueError("OTP challenge subject email must match the destination email")
if not 1 <= self.send_count <= OTP_MAX_SEND_COUNT:
raise ValueError("OTP send count is outside the supported range")
if not 0 <= self.attempt_count <= OTP_MAX_ATTEMPT_COUNT:
raise ValueError("OTP attempt count is outside the supported range")
if self.resend_after.value != self.created_at.value + OTP_RESEND_COOLDOWN:
raise ValueError("OTP resend_after must equal created_at plus the resend cooldown")
if self.expires_at.value != self.created_at.value + OTP_EXPIRY:
raise ValueError("OTP expires_at must equal created_at plus the expiry duration")
if self.updated_at.value < self.created_at.value:
raise ValueError("OTP updated_at must not precede created_at")
if self.status is HumanInputOTPChallengeStatus.VERIFIED:
if self.verified_at is None or self.invalidated_at is not None:
raise ValueError("verified OTP challenge requires only verified_at")
elif self.status is HumanInputOTPChallengeStatus.INVALIDATED:
if self.invalidated_at is None or self.verified_at is not None:
raise ValueError("invalidated OTP challenge requires only invalidated_at")
elif self.verified_at is not None or self.invalidated_at is not None:
raise ValueError("pending and expired OTP challenges cannot have terminal timestamps")
@classmethod
def issue(
cls,
*,
challenge_ref: OTPChallengeRef,
subject: EmailOTPSubject,
normalized_email: NormalizedEmail,
challenge_token_hash: str,
plaintext_code: str,
send_count: int,
clock: Clock,
code_hasher: OTPCodeHasher,
) -> OTPChallenge:
"""Hash a transient code and create one pending proof session."""
now = clock.now()
return cls(
ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
challenge_token_hash=challenge_token_hash,
code_hash=code_hasher.hash_code(plaintext_code),
status=HumanInputOTPChallengeStatus.PENDING,
expires_at=UtcTimestamp(now.value + OTP_EXPIRY),
resend_after=UtcTimestamp(now.value + OTP_RESEND_COOLDOWN),
send_count=send_count,
attempt_count=0,
verified_at=None,
invalidated_at=None,
created_at=now,
updated_at=now,
)
def state_at(self, now: UtcTimestamp) -> OTPChallengeState:
match self.status:
case HumanInputOTPChallengeStatus.VERIFIED:
return OTPChallengeState(self.status, OTPChallengeRejectionReason.ALREADY_VERIFIED)
case HumanInputOTPChallengeStatus.INVALIDATED:
return OTPChallengeState(self.status, OTPChallengeRejectionReason.INVALIDATED)
case HumanInputOTPChallengeStatus.EXPIRED:
return OTPChallengeState(self.status, OTPChallengeRejectionReason.EXPIRED)
case HumanInputOTPChallengeStatus.PENDING:
if now.value >= self.expires_at.value:
return OTPChallengeState(HumanInputOTPChallengeStatus.EXPIRED, OTPChallengeRejectionReason.EXPIRED)
return OTPChallengeState(self.status, None)
raise AssertionError(f"unsupported OTP challenge status: {self.status}")
def replace(
self,
*,
challenge_ref: OTPChallengeRef,
challenge_token_hash: str,
plaintext_code: str,
clock: Clock,
code_hasher: OTPCodeHasher,
) -> OTPReplacementDecision:
"""Prepare an eligible replacement without persisting either state."""
now = clock.now()
state = self.state_at(now)
if (
state.rejection is OTPChallengeRejectionReason.EXPIRED
and self.status is HumanInputOTPChallengeStatus.PENDING
):
expired = replace(
self,
status=HumanInputOTPChallengeStatus.EXPIRED,
updated_at=now,
)
return OTPReplacementDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
if state.rejection is not None:
return OTPReplacementDecision(self, None, state.rejection)
if self.send_count >= OTP_MAX_SEND_COUNT:
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.SEND_LIMIT_REACHED)
if now.value < self.resend_after.value:
return OTPReplacementDecision(self, None, OTPChallengeRejectionReason.RESEND_COOLDOWN)
replacement_challenge = self.issue(
challenge_ref=challenge_ref,
subject=self.subject,
normalized_email=self.normalized_email,
challenge_token_hash=challenge_token_hash,
plaintext_code=plaintext_code,
send_count=self.send_count + 1,
clock=clock,
code_hasher=code_hasher,
)
invalidated = replace(
self,
status=HumanInputOTPChallengeStatus.INVALIDATED,
invalidated_at=now,
updated_at=now,
)
return OTPReplacementDecision(invalidated, replacement_challenge, None)
def verify(
self,
*,
plaintext_code: str,
clock: Clock,
code_hasher: OTPCodeHasher,
) -> OTPVerificationDecision:
"""Verify one transient code while preserving exact attempt boundaries."""
now = clock.now()
state = self.state_at(now)
if (
state.rejection is OTPChallengeRejectionReason.EXPIRED
and self.status is HumanInputOTPChallengeStatus.PENDING
):
expired = replace(
self,
status=HumanInputOTPChallengeStatus.EXPIRED,
updated_at=now,
)
return OTPVerificationDecision(expired, None, OTPChallengeRejectionReason.EXPIRED)
if state.rejection is not None:
return OTPVerificationDecision(self, None, state.rejection)
if self.attempt_count >= OTP_MAX_ATTEMPT_COUNT:
return OTPVerificationDecision(self, None, OTPChallengeRejectionReason.ATTEMPT_LIMIT_REACHED)
attempt_count = self.attempt_count + 1
if not code_hasher.verify_code(plaintext_code, self.code_hash):
attempted = replace(self, attempt_count=attempt_count, updated_at=now)
return OTPVerificationDecision(attempted, None, OTPChallengeRejectionReason.INVALID_CODE)
verified = replace(
self,
status=HumanInputOTPChallengeStatus.VERIFIED,
attempt_count=attempt_count,
verified_at=now,
updated_at=now,
)
proof = VerifiedEmailOTPProof(
challenge_ref=self.ref,
subject=self.subject,
normalized_email=self.normalized_email,
verified_at=now,
)
return OTPVerificationDecision(verified, proof, None)
def invalidate(self, *, clock: Clock) -> OTPChallenge:
"""Make a pending proof session unusable without changing its counters."""
if self.status is not HumanInputOTPChallengeStatus.PENDING:
return self
now = clock.now()
return replace(
self,
status=HumanInputOTPChallengeStatus.INVALIDATED,
invalidated_at=now,
updated_at=now,
)
def to_public_primitive(self) -> OTPChallengePublicPrimitive:
"""Return state diagnostics while deliberately excluding all hashes."""
return {
"otp_challenge_id": str(self.ref.challenge_id),
"form_id": str(self.ref.form_ref.form_id),
"approver_grant_id": str(self.ref.grant_ref.grant_id),
"status": self.status.value,
"email": str(self.normalized_email),
"send_count": self.send_count,
"attempt_count": self.attempt_count,
"expires_at": self.expires_at.to_primitive(),
"resend_after": self.resend_after.to_primitive(),
"verified_at": self.verified_at.to_primitive() if self.verified_at is not None else None,
"invalidated_at": self.invalidated_at.to_primitive() if self.invalidated_at is not None else None,
}
@dataclass(frozen=True, slots=True)
class CurrentEmailOTPIdentity:
"""Coherent current grant subject and Email facts loaded by submission persistence."""
grant_ref: ApproverGrantRef
subject: EmailOTPSubject | None
normalized_email: NormalizedEmail | None
@dataclass(frozen=True, slots=True)
class EmailOTPProofAuthorizationDecision:
"""OTP-specific proof decision consumed by the later Submission authorizer."""
proof: VerifiedEmailOTPProof | None
rejection: OTPChallengeRejectionReason | None
class OTPChallengeRepository(Protocol):
"""Grant-scoped atomic persistence operations for OTP proof sessions."""
def issue_initial(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPChallenge: ...
def replace_current(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPReplacementDecision: ...
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision: ...
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None: ...
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None: ...
def authorize_email_otp_proof(
candidate: object,
*,
current_identity: CurrentEmailOTPIdentity,
) -> EmailOTPProofAuthorizationDecision:
"""Reject raw codes and stale identity incarnations without authorizing submission."""
if not isinstance(candidate, VerifiedEmailOTPProof):
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.RAW_CODE_NOT_VERIFIED)
if candidate.challenge_ref.grant_ref != current_identity.grant_ref:
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.GRANT_MISMATCH)
if (
current_identity.subject is None
or current_identity.normalized_email is None
or candidate.subject != current_identity.subject
or candidate.normalized_email != current_identity.normalized_email
):
return EmailOTPProofAuthorizationDecision(None, OTPChallengeRejectionReason.STALE_IDENTITY)
return EmailOTPProofAuthorizationDecision(candidate, None)
+81
View File
@@ -0,0 +1,81 @@
"""Operation-oriented persistence ports for the Human Input v2 form boundary."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
from core.human_input_v2.entities import HumanInputV2FormStatus
from core.human_input_v2.shared import UtcTimestamp, WorkspaceId
from .delivery import DeliveryAttempt, DeliveryEndpoint, UploadCapability, UploadFileAssociation
from .form import FormCreation, FrozenFormDefinition, HumanInputForm
from .grants import ApproverGrant, DeliveryEndpointRef, FormRef
@dataclass(frozen=True, slots=True)
class FormDefinitionProjection:
"""Read model for rendering a form through one endpoint capability."""
form_ref: FormRef
endpoint_ref: DeliveryEndpointRef
definition: FrozenFormDefinition
rendered_content: str
status: HumanInputV2FormStatus
node_timeout_at: UtcTimestamp
global_expires_at: UtcTimestamp
@dataclass(frozen=True, slots=True)
class FormDeliveryProjection:
"""Read model containing only data needed to deliver one endpoint."""
form_ref: FormRef
grant: ApproverGrant
endpoint: DeliveryEndpoint
definition: FrozenFormDefinition
rendered_content: str
class FormRepository(Protocol):
"""Deep adapter contract whose operations own their query and transaction shape."""
def create_form(self, creation: FormCreation) -> HumanInputForm:
"""Persist form, grants, and endpoints atomically."""
...
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
"""Load the form and grants required for local transition decisions."""
...
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
"""Load exactly one endpoint with its grant and form delivery values."""
...
def load_definition_by_endpoint_token(
self,
*,
workspace_id: WorkspaceId,
token_hash: str,
) -> FormDefinitionProjection | None:
"""Resolve a scoped interaction capability without creating authority."""
...
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
"""Append one delivery fact without changing form lifecycle status."""
...
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
"""Persist one endpoint-scoped upload capability."""
...
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
"""Associate a file only after validating the full capability owner chain."""
...
@@ -0,0 +1,775 @@
"""Canonical approval plan values and single-entry recipient resolution.
``RecipientResolver.resolve`` is the only public operation that converts saved
recipient specifications into approvers. Validation, Contact upgrade, subject
deduplication, matched-source aggregation, debug replacement, and endpoint
planning stay behind that interface so callers cannot apply them inconsistently.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
from hashlib import sha256
from typing import assert_never
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactDirectoryPolicy,
ContactDirectorySnapshot,
ContactResolution,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel, IMProvider
from core.human_input_v2.im_integration import EffectiveIMBindingSnapshot
from core.human_input_v2.shared import (
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
)
from .recipient_specifications import (
ContactRecipientSpecification,
CurrentInitiatorRecipientSpecification,
DynamicEmailRecipientSpecification,
DynamicRecipientValue,
OneTimeEmailRecipientSpecification,
RecipientSpecification,
UnsupportedDynamicRecipientValue,
)
@dataclass(frozen=True, slots=True)
class CanonicalSubjectKey:
"""Portable form-scoped deduplication key, not an authorization identity."""
value: str
def __post_init__(self) -> None:
namespace, separator, identity = self.value.partition(":")
valid_named_identity = namespace in {"contact", "end_user"} and bool(identity)
valid_email_digest = (
namespace == "email_address"
and len(identity) == 64
and all(character in "0123456789abcdef" for character in identity)
)
if not separator or not (valid_named_identity or valid_email_digest):
raise ValueError("canonical subject key has an invalid portable format")
@classmethod
def for_contact(cls, contact_id: ContactId) -> CanonicalSubjectKey:
return cls(f"contact:{contact_id}")
@classmethod
def for_end_user(cls, end_user_id: EndUserId) -> CanonicalSubjectKey:
return cls(f"end_user:{end_user_id}")
@classmethod
def for_email(cls, normalized_email: NormalizedEmail) -> CanonicalSubjectKey:
digest = sha256(normalized_email.value.encode()).hexdigest()
return cls(f"email_address:{digest}")
@dataclass(frozen=True, slots=True)
class ContactApprovalSubject:
"""Approval authority backed by one canonical Contact."""
contact_id: ContactId
@property
def subject_type(self) -> HumanInputApproverGrantSubjectType:
return HumanInputApproverGrantSubjectType.CONTACT
def to_primitive(self) -> dict[str, object]:
return {"type": self.subject_type.value, "contact_id": self.contact_id.to_primitive()}
@dataclass(frozen=True, slots=True)
class EndUserApprovalSubject:
"""Approval authority backed by one app-scoped EndUser."""
end_user_id: EndUserId
@property
def subject_type(self) -> HumanInputApproverGrantSubjectType:
return HumanInputApproverGrantSubjectType.END_USER
def to_primitive(self) -> dict[str, object]:
return {"type": self.subject_type.value, "end_user_id": self.end_user_id.to_primitive()}
@dataclass(frozen=True, slots=True)
class EmailAddressApprovalSubject:
"""Task-scoped approval authority backed by one normalized Email address."""
normalized_email: NormalizedEmail
@property
def subject_type(self) -> HumanInputApproverGrantSubjectType:
return HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
def to_primitive(self) -> dict[str, object]:
return {"type": self.subject_type.value, "normalized_email": self.normalized_email.to_primitive()}
type ApprovalSubject = ContactApprovalSubject | EndUserApprovalSubject | EmailAddressApprovalSubject
class RecipientSourceKind(StrEnum):
"""Stable source discriminator retained after canonicalization."""
STATIC_CONTACT = "static_contact"
ONE_TIME_EMAIL = "one_time_email"
DYNAMIC_EMAIL = "dynamic_email"
CURRENT_INITIATOR = "current_initiator"
DEBUG_REPLACEMENT = "debug_replacement"
@dataclass(frozen=True, slots=True)
class MatchedRecipientSource:
"""One ordered configured or request-scoped source of an approver."""
kind: RecipientSourceKind
position: int
reference: str | None
def __post_init__(self) -> None:
if self.position < 0:
raise ValueError("recipient source position must not be negative")
def to_primitive(self) -> dict[str, object]:
return {"kind": self.kind.value, "position": self.position, "reference": self.reference}
@dataclass(frozen=True, slots=True)
class SubjectSnapshot:
"""Display-only identity facts captured by resolution."""
display_name: str | None
email: str | None
def to_primitive(self) -> dict[str, object]:
return {"display_name": self.display_name, "email": self.email}
@dataclass(frozen=True, slots=True)
class EmailEndpointPlan:
"""Email delivery destination for one canonical approver."""
email_address: NormalizedEmail
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.EMAIL
def to_primitive(self) -> dict[str, object]:
return {"channel": self.channel.value, "email_address": self.email_address.to_primitive()}
@dataclass(frozen=True, slots=True)
class IMEndpointPlan:
"""Credential-free IM delivery destination frozen from an effective binding."""
integration_id: IntegrationId
provider: IMProvider
provider_tenant_id: str
identity_id: IMIdentityId
binding_id: IMBindingId | None
provider_user_id: str
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.IM
def to_primitive(self) -> dict[str, object]:
return {
"channel": self.channel.value,
"integration_id": self.integration_id.to_primitive(),
"provider": self.provider.value,
"provider_tenant_id": self.provider_tenant_id,
"identity_id": self.identity_id.to_primitive(),
"binding_id": self.binding_id.to_primitive() if self.binding_id is not None else None,
"provider_user_id": self.provider_user_id,
}
@dataclass(frozen=True, slots=True)
class WebEndpointPlan:
"""Public or trusted-app web interaction surface without a saved token."""
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.WEB
def to_primitive(self) -> dict[str, object]:
return {"channel": self.channel.value}
@dataclass(frozen=True, slots=True)
class ConsoleEndpointPlan:
"""Authenticated console interaction surface without a notification address."""
@property
def channel(self) -> HumanInputDeliveryChannel:
return HumanInputDeliveryChannel.CONSOLE
def to_primitive(self) -> dict[str, object]:
return {"channel": self.channel.value}
type DeliveryEndpointPlan = EmailEndpointPlan | IMEndpointPlan | WebEndpointPlan | ConsoleEndpointPlan
class RecipientRejectionReason(StrEnum):
"""Transport-neutral reason for rejecting one recipient source."""
INVALID_CONTACT_ID = "invalid_contact_id"
CONTACT_UNAVAILABLE = "contact_unavailable"
INVALID_DYNAMIC_SELECTOR = "invalid_dynamic_selector"
DYNAMIC_VALUE_UNAVAILABLE = "dynamic_value_unavailable"
UNSUPPORTED_DYNAMIC_TYPE = "unsupported_dynamic_type"
INVALID_EMAIL = "invalid_email"
INITIATOR_UNAVAILABLE = "initiator_unavailable"
NO_USABLE_ENDPOINT = "no_usable_endpoint"
@dataclass(frozen=True, slots=True)
class RejectedRecipient:
"""Machine-readable source failure retained alongside valid approvers."""
source: MatchedRecipientSource
reason: RecipientRejectionReason
rejected_value: str | None
def to_primitive(self) -> dict[str, object]:
return {
"source": self.source.to_primitive(),
"reason": self.reason.value,
"rejected_value": self.rejected_value,
}
@dataclass(frozen=True, slots=True)
class ResolvedApprover:
"""One canonical subject with all matched sources and usable endpoints."""
subject: ApprovalSubject
subject_key: CanonicalSubjectKey
matched_sources: tuple[MatchedRecipientSource, ...]
subject_snapshot: SubjectSnapshot
endpoints: tuple[DeliveryEndpointPlan, ...]
def __post_init__(self) -> None:
if not isinstance(self.matched_sources, tuple) or not isinstance(self.endpoints, tuple):
raise TypeError("resolved approver collections must be immutable tuples")
if self.subject_key != _canonical_key_for_subject(self.subject):
raise ValueError("resolved approver subject key does not match its subject")
def to_primitive(self) -> dict[str, object]:
return {
"subject": self.subject.to_primitive(),
"subject_key": self.subject_key.value,
"matched_sources": [source.to_primitive() for source in self.matched_sources],
"subject_snapshot": self.subject_snapshot.to_primitive(),
"endpoints": [endpoint.to_primitive() for endpoint in self.endpoints],
}
class RecipientResolutionFailureReason(StrEnum):
"""Stable whole-plan failure independent from HTTP or provider semantics."""
NO_VALID_RECIPIENTS = "no_valid_recipients"
@dataclass(frozen=True, slots=True)
class ResolvedApprovalPlan:
"""Immutable complete output of one recipient resolution request."""
approvers: tuple[ResolvedApprover, ...]
rejected_recipients: tuple[RejectedRecipient, ...]
failure_reason: RecipientResolutionFailureReason | None
def __post_init__(self) -> None:
if not isinstance(self.approvers, tuple) or not isinstance(self.rejected_recipients, tuple):
raise TypeError("approval plan collections must be immutable tuples")
if self.approvers and self.failure_reason is not None:
raise ValueError("a plan with approvers cannot have a failure reason")
if not self.approvers and self.failure_reason is None:
raise ValueError("a plan without approvers must have a failure reason")
def to_primitive(self) -> dict[str, object]:
return {
"approvers": [approver.to_primitive() for approver in self.approvers],
"rejected_recipients": [rejection.to_primitive() for rejection in self.rejected_recipients],
"failure_reason": self.failure_reason.value if self.failure_reason is not None else None,
}
@dataclass(frozen=True, slots=True)
class ContactInitiatorSnapshot:
"""Current request initiator resolved to a canonical Contact reference."""
contact_id: ContactId
@dataclass(frozen=True, slots=True)
class EndUserInitiatorSnapshot:
"""Current request initiator resolved to one app-scoped EndUser."""
end_user_id: EndUserId
display_name: str | None
email: str | None
type InitiatorSnapshot = ContactInitiatorSnapshot | EndUserInitiatorSnapshot
@dataclass(frozen=True, slots=True)
class DebugRecipientReplacement:
"""Valid debug actor that replaces saved recipients for one request only."""
subject: InitiatorSnapshot
@dataclass(frozen=True, slots=True)
class DeliveryCapabilitySnapshot:
"""Request-scoped effective delivery and interaction capabilities.
IM values are already resolved by the IM control plane; this domain never
sees credentials, raw provider clients, or invalid binding candidates.
Explicit Web/Console sets prevent recipient resolution from inventing
interaction surfaces that the current runtime cannot actually expose.
"""
im_bindings: tuple[EffectiveIMBindingSnapshot, ...] = ()
contact_web_ids: frozenset[ContactId] = frozenset()
contact_console_ids: frozenset[ContactId] = frozenset()
end_user_web_ids: frozenset[EndUserId] = frozenset()
email_address_web_available: bool = False
def __post_init__(self) -> None:
if not isinstance(self.im_bindings, tuple):
raise TypeError("effective IM bindings must be an immutable tuple")
if not all(
isinstance(values, frozenset)
for values in (self.contact_web_ids, self.contact_console_ids, self.end_user_web_ids)
):
raise TypeError("interaction capability identifiers must be immutable frozensets")
@dataclass(slots=True)
class _PendingApprover:
"""Private mutable accumulator hidden behind the immutable resolver result."""
subject: ApprovalSubject
subject_key: CanonicalSubjectKey
subject_snapshot: SubjectSnapshot
first_source_position: int
matched_sources: list[MatchedRecipientSource] = field(default_factory=list)
endpoints: list[DeliveryEndpointPlan] = field(default_factory=list)
class RecipientResolver:
"""Resolve all recipient semantics through one deterministic domain entry."""
@staticmethod
def resolve(
*,
specifications: tuple[RecipientSpecification, ...],
directory: ContactDirectorySnapshot,
dynamic_values: tuple[DynamicRecipientValue, ...],
initiator: InitiatorSnapshot | None,
capabilities: DeliveryCapabilitySnapshot,
debug_replacement: DebugRecipientReplacement | None = None,
) -> ResolvedApprovalPlan:
"""Resolve immutable request inputs into one complete approval plan.
Invalid or unavailable sources are returned as typed rejection facts;
the method raises only when a caller violates an immutable input shape.
No database, provider, transport, or mutation side effects occur.
"""
if not isinstance(specifications, tuple) or not isinstance(dynamic_values, tuple):
raise TypeError("recipient resolution inputs must be immutable tuples")
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover] = {}
rejected_recipients: list[RejectedRecipient] = []
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue] = {}
for dynamic_value in dynamic_values:
dynamic_values_by_selector.setdefault(dynamic_value.selector, dynamic_value)
if debug_replacement is not None:
source = MatchedRecipientSource(RecipientSourceKind.DEBUG_REPLACEMENT, 0, None)
RecipientResolver._resolve_initiator(
debug_replacement.subject,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
else:
for position, specification in enumerate(specifications):
RecipientResolver._resolve_specification(
specification,
position,
directory,
dynamic_values_by_selector,
initiator,
capabilities,
pending_approvers,
rejected_recipients,
)
approvers: list[ResolvedApprover] = []
for pending in sorted(
pending_approvers.values(),
key=lambda candidate: (candidate.first_source_position, candidate.subject_key.value),
):
matched_sources = tuple(sorted(pending.matched_sources, key=_source_sort_key))
endpoints = tuple(sorted(pending.endpoints, key=_endpoint_sort_key))
if not endpoints:
rejected_recipients.extend(
RejectedRecipient(
source=source,
reason=RecipientRejectionReason.NO_USABLE_ENDPOINT,
rejected_value=pending.subject_key.value,
)
for source in matched_sources
)
continue
approvers.append(
ResolvedApprover(
subject=pending.subject,
subject_key=pending.subject_key,
matched_sources=matched_sources,
subject_snapshot=pending.subject_snapshot,
endpoints=endpoints,
)
)
ordered_rejections = tuple(sorted(rejected_recipients, key=_rejection_sort_key))
failure_reason = None if approvers else RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
return ResolvedApprovalPlan(tuple(approvers), ordered_rejections, failure_reason)
@staticmethod
def _resolve_specification(
specification: RecipientSpecification,
position: int,
directory: ContactDirectorySnapshot,
dynamic_values_by_selector: dict[tuple[str, ...], DynamicRecipientValue],
initiator: InitiatorSnapshot | None,
capabilities: DeliveryCapabilitySnapshot,
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
rejected_recipients: list[RejectedRecipient],
) -> None:
if isinstance(specification, ContactRecipientSpecification):
source = MatchedRecipientSource(
RecipientSourceKind.STATIC_CONTACT,
position,
specification.contact_id,
)
try:
contact_id = ContactId(specification.contact_id)
except ValueError:
rejected_recipients.append(
RejectedRecipient(source, RecipientRejectionReason.INVALID_CONTACT_ID, specification.contact_id)
)
return
RecipientResolver._resolve_contact(
contact_id,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
return
if isinstance(specification, OneTimeEmailRecipientSpecification):
source = MatchedRecipientSource(
RecipientSourceKind.ONE_TIME_EMAIL,
position,
specification.email,
)
RecipientResolver._resolve_email(
specification.email,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
return
if isinstance(specification, DynamicEmailRecipientSpecification):
selector_reference = ".".join(specification.selector)
source = MatchedRecipientSource(
RecipientSourceKind.DYNAMIC_EMAIL,
position,
selector_reference,
)
if not specification.selector or any(not component.strip() for component in specification.selector):
rejected_recipients.append(
RejectedRecipient(source, RecipientRejectionReason.INVALID_DYNAMIC_SELECTOR, selector_reference)
)
return
dynamic_value = dynamic_values_by_selector.get(specification.selector)
if dynamic_value is None:
rejected_recipients.append(
RejectedRecipient(source, RecipientRejectionReason.DYNAMIC_VALUE_UNAVAILABLE, selector_reference)
)
return
if isinstance(dynamic_value.value, UnsupportedDynamicRecipientValue):
rejected_recipients.append(
RejectedRecipient(
source,
RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE,
dynamic_value.value.value_type,
)
)
return
RecipientResolver._resolve_email(
dynamic_value.value,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
return
if isinstance(specification, CurrentInitiatorRecipientSpecification):
source = MatchedRecipientSource(RecipientSourceKind.CURRENT_INITIATOR, position, None)
if initiator is None:
rejected_recipients.append(
RejectedRecipient(source, RecipientRejectionReason.INITIATOR_UNAVAILABLE, None)
)
return
RecipientResolver._resolve_initiator(
initiator,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
return
assert_never(specification)
@staticmethod
def _resolve_initiator(
initiator: InitiatorSnapshot,
source: MatchedRecipientSource,
directory: ContactDirectorySnapshot,
capabilities: DeliveryCapabilitySnapshot,
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
rejected_recipients: list[RejectedRecipient],
) -> None:
if isinstance(initiator, ContactInitiatorSnapshot):
RecipientResolver._resolve_contact(
initiator.contact_id,
source,
directory,
capabilities,
pending_approvers,
rejected_recipients,
)
return
normalized_email: NormalizedEmail | None = None
if initiator.email is not None:
try:
normalized_email = NormalizedEmail(initiator.email)
except ValueError:
normalized_email = None
subject = EndUserApprovalSubject(initiator.end_user_id)
endpoints: list[DeliveryEndpointPlan] = []
if normalized_email is not None:
endpoints.append(EmailEndpointPlan(normalized_email))
if initiator.end_user_id in capabilities.end_user_web_ids:
endpoints.append(WebEndpointPlan())
RecipientResolver._add_approver(
subject,
source,
SubjectSnapshot(initiator.display_name, normalized_email.value if normalized_email is not None else None),
endpoints,
pending_approvers,
)
@staticmethod
def _resolve_contact(
contact_id: ContactId,
source: MatchedRecipientSource,
directory: ContactDirectorySnapshot,
capabilities: DeliveryCapabilitySnapshot,
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
rejected_recipients: list[RejectedRecipient],
) -> None:
try:
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact_id)
except ContactDirectoryError:
resolution = ContactResolution.ABSENT
contact = directory.find(contact_id)
if resolution is ContactResolution.ABSENT or contact is None:
rejected_recipients.append(
RejectedRecipient(source, RecipientRejectionReason.CONTACT_UNAVAILABLE, contact_id.value)
)
return
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
@staticmethod
def _resolve_email(
email: str,
source: MatchedRecipientSource,
directory: ContactDirectorySnapshot,
capabilities: DeliveryCapabilitySnapshot,
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
rejected_recipients: list[RejectedRecipient],
) -> None:
"""Resolve Email input without bypassing an existing Contact's policy.
EmailAddress authority is valid only when the directory contains no
matching Contact. A matching but unavailable Contact fails closed so
callers cannot bypass Account availability or workspace visibility.
"""
try:
normalized_email = NormalizedEmail(email)
except ValueError:
rejected_recipients.append(RejectedRecipient(source, RecipientRejectionReason.INVALID_EMAIL, email))
return
matching_contacts = sorted(
(contact for contact in directory.contacts if contact.normalized_email == normalized_email),
key=lambda contact: contact.id.value,
)
for contact in matching_contacts:
try:
resolution = ContactDirectoryPolicy.resolve_for_workspace(directory, contact.id)
except ContactDirectoryError:
continue
if resolution is not ContactResolution.ABSENT:
RecipientResolver._add_contact_approver(contact, source, capabilities, pending_approvers)
return
if matching_contacts:
rejected_recipients.append(
RejectedRecipient(
source,
RecipientRejectionReason.CONTACT_UNAVAILABLE,
normalized_email.value,
)
)
return
subject = EmailAddressApprovalSubject(normalized_email)
endpoints: list[DeliveryEndpointPlan] = [EmailEndpointPlan(normalized_email)]
if capabilities.email_address_web_available:
endpoints.append(WebEndpointPlan())
RecipientResolver._add_approver(
subject,
source,
SubjectSnapshot(None, normalized_email.value),
endpoints,
pending_approvers,
)
@staticmethod
def _add_contact_approver(
contact: Contact,
source: MatchedRecipientSource,
capabilities: DeliveryCapabilitySnapshot,
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
) -> None:
endpoints: list[DeliveryEndpointPlan] = []
if contact.normalized_email is not None:
endpoints.append(EmailEndpointPlan(contact.normalized_email))
endpoints.extend(
IMEndpointPlan(
integration_id=binding.integration_id,
provider=binding.provider,
provider_tenant_id=binding.provider_tenant_id,
identity_id=binding.identity_id,
binding_id=binding.binding_id,
provider_user_id=binding.provider_user_id,
)
for binding in capabilities.im_bindings
if binding.contact_id == contact.id
)
if contact.id in capabilities.contact_web_ids:
endpoints.append(WebEndpointPlan())
if contact.id in capabilities.contact_console_ids:
endpoints.append(ConsoleEndpointPlan())
RecipientResolver._add_approver(
ContactApprovalSubject(contact.id),
source,
SubjectSnapshot(contact.name, contact.email),
endpoints,
pending_approvers,
)
@staticmethod
def _add_approver(
subject: ApprovalSubject,
source: MatchedRecipientSource,
subject_snapshot: SubjectSnapshot,
endpoints: list[DeliveryEndpointPlan],
pending_approvers: dict[CanonicalSubjectKey, _PendingApprover],
) -> None:
subject_key = _canonical_key_for_subject(subject)
pending = pending_approvers.get(subject_key)
if pending is None:
pending = _PendingApprover(
subject=subject,
subject_key=subject_key,
subject_snapshot=subject_snapshot,
first_source_position=source.position,
)
pending_approvers[subject_key] = pending
if source not in pending.matched_sources:
pending.matched_sources.append(source)
for endpoint in endpoints:
if endpoint not in pending.endpoints:
pending.endpoints.append(endpoint)
def _canonical_key_for_subject(subject: ApprovalSubject) -> CanonicalSubjectKey:
if isinstance(subject, ContactApprovalSubject):
return CanonicalSubjectKey.for_contact(subject.contact_id)
if isinstance(subject, EndUserApprovalSubject):
return CanonicalSubjectKey.for_end_user(subject.end_user_id)
return CanonicalSubjectKey.for_email(subject.normalized_email)
_CHANNEL_ORDER = {
HumanInputDeliveryChannel.EMAIL: 0,
HumanInputDeliveryChannel.IM: 1,
HumanInputDeliveryChannel.WEB: 2,
HumanInputDeliveryChannel.CONSOLE: 3,
}
def _source_sort_key(source: MatchedRecipientSource) -> tuple[int, str, str]:
return source.position, source.kind.value, source.reference or ""
def _endpoint_sort_key(endpoint: DeliveryEndpointPlan) -> tuple[int, str, str, str]:
channel_order = _CHANNEL_ORDER[endpoint.channel]
if isinstance(endpoint, EmailEndpointPlan):
return channel_order, endpoint.email_address.value, "", ""
if isinstance(endpoint, IMEndpointPlan):
return (
channel_order,
endpoint.integration_id.value,
endpoint.provider.value,
endpoint.identity_id.value,
)
return channel_order, "", "", ""
def _rejection_sort_key(rejection: RejectedRecipient) -> tuple[int, str, str, str]:
source_key = _source_sort_key(rejection.source)
return source_key[0], source_key[1], rejection.reason.value, rejection.rejected_value or ""
@@ -0,0 +1,145 @@
"""Immutable recipient specifications at the workflow-to-approval boundary.
Saved node configuration intentionally retains unvalidated Email text. Runtime
validation belongs to :class:`RecipientResolver`, which can retain a typed
rejection without making workflow configuration parsing fail early.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import assert_never
from core.workflow.nodes.human_input_v2.entities import (
Contact as WorkflowContactRecipient,
)
from core.workflow.nodes.human_input_v2.entities import (
DynamicEmail as WorkflowDynamicEmailRecipient,
)
from core.workflow.nodes.human_input_v2.entities import (
HumanInputNodeData,
)
from core.workflow.nodes.human_input_v2.entities import (
Initiator as WorkflowInitiatorRecipient,
)
from core.workflow.nodes.human_input_v2.entities import (
OnetimeEmail as WorkflowOneTimeEmailRecipient,
)
class RecipientSpecificationKind(StrEnum):
"""Stable workflow recipient discriminator."""
CONTACT = "contact"
DYNAMIC_EMAIL = "dynamic_email"
ONETIME_EMAIL = "onetime_email"
INITIATOR = "initiator"
@dataclass(frozen=True, slots=True)
class ContactRecipientSpecification:
"""Saved reference to one Contact; current availability is resolved later."""
contact_id: str
def to_primitive(self) -> dict[str, object]:
return {"type": RecipientSpecificationKind.CONTACT.value, "contact_id": self.contact_id}
@dataclass(frozen=True, slots=True)
class OneTimeEmailRecipientSpecification:
"""Saved one-time Email text whose validity is decided per resolution."""
email: str
def to_primitive(self) -> dict[str, object]:
return {"type": RecipientSpecificationKind.ONETIME_EMAIL.value, "email": self.email}
@dataclass(frozen=True, slots=True)
class DynamicEmailRecipientSpecification:
"""Saved workflow selector whose current value is supplied separately."""
selector: tuple[str, ...]
def __post_init__(self) -> None:
if not isinstance(self.selector, tuple):
raise TypeError("dynamic email selector must be an immutable tuple")
def to_primitive(self) -> dict[str, object]:
return {"type": RecipientSpecificationKind.DYNAMIC_EMAIL.value, "selector": list(self.selector)}
@dataclass(frozen=True, slots=True)
class CurrentInitiatorRecipientSpecification:
"""Request-scoped current initiator recipient marker."""
def to_primitive(self) -> dict[str, object]:
return {"type": RecipientSpecificationKind.INITIATOR.value}
type RecipientSpecification = (
ContactRecipientSpecification
| OneTimeEmailRecipientSpecification
| DynamicEmailRecipientSpecification
| CurrentInitiatorRecipientSpecification
)
@dataclass(frozen=True, slots=True)
class UnsupportedDynamicRecipientValue:
"""Safe snapshot of a non-string workflow value without retaining its graph."""
value_type: str
def __post_init__(self) -> None:
if not self.value_type:
raise ValueError("unsupported dynamic recipient value type must not be blank")
@dataclass(frozen=True, slots=True)
class DynamicRecipientValue:
"""One evaluated selector value captured for a single resolution request."""
selector: tuple[str, ...]
value: str | UnsupportedDynamicRecipientValue
def __post_init__(self) -> None:
if not isinstance(self.selector, tuple):
raise TypeError("dynamic recipient value selector must be an immutable tuple")
@classmethod
def from_runtime(cls, selector: tuple[str, ...], value: object) -> DynamicRecipientValue:
"""Capture a runtime value without retaining mutable unsupported data."""
captured_value: str | UnsupportedDynamicRecipientValue
if isinstance(value, str):
captured_value = value
else:
captured_value = UnsupportedDynamicRecipientValue(type(value).__name__)
return cls(selector=selector, value=captured_value)
class WorkflowRecipientSpecificationAdapter:
"""Convert versioned workflow node values into approval-domain inputs."""
@staticmethod
def from_node_data(node_data: HumanInputNodeData) -> tuple[RecipientSpecification, ...]:
"""Copy ordered v2 node recipients into immutable domain values."""
specifications: list[RecipientSpecification] = []
for configured_recipient in node_data.recipients_spec:
specification: RecipientSpecification
if isinstance(configured_recipient, WorkflowContactRecipient):
specification = ContactRecipientSpecification(contact_id=configured_recipient.contact_id)
elif isinstance(configured_recipient, WorkflowOneTimeEmailRecipient):
specification = OneTimeEmailRecipientSpecification(email=configured_recipient.email)
elif isinstance(configured_recipient, WorkflowDynamicEmailRecipient):
specification = DynamicEmailRecipientSpecification(selector=tuple(configured_recipient.selector))
elif isinstance(configured_recipient, WorkflowInitiatorRecipient):
specification = CurrentInitiatorRecipientSpecification()
else:
assert_never(configured_recipient)
specifications.append(specification)
return tuple(specifications)
@@ -0,0 +1,405 @@
"""Pure current-state authorization for Human Input v2 submissions.
The module owns the cross-snapshot decision only. Callers must verify transport
credentials before constructing a proof and must load one coherent
``AuthorizationContext`` through persistence. The authorizer performs no I/O,
does not retain raw credentials, and never reloads Contact or IM binding facts.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from typing import assert_never
from core.human_input_v2.entities import IMProvider
from core.human_input_v2.shared import (
AccountId,
AppId,
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
)
from .delivery import DeliveryEndpoint
from .form import (
FormInactiveReason,
HumanInputForm,
InactiveFormState,
InvalidApproverGrantError,
InvalidSelectedActionError,
SubmissionTransitionDecision,
)
from .grants import ApproverGrant, DeliveryEndpointRef
from .otp import ContactOTPSubject, EmailAddressOTPSubject, VerifiedEmailOTPProof
from .recipient_resolution import ContactApprovalSubject, EmailAddressApprovalSubject, EndUserApprovalSubject
@dataclass(frozen=True, slots=True)
class VerifiedAccountSessionProof:
"""Current Account identity produced by a trusted session verifier."""
account_id: AccountId
@dataclass(frozen=True, slots=True)
class VerifiedTrustedEndUserProof:
"""Current EndUser identity produced by a trusted app-token boundary."""
end_user_id: EndUserId
app_id: AppId
@dataclass(frozen=True, slots=True)
class VerifiedIMIdentityProof:
"""Current provider identity evidence without callback credentials or payloads."""
integration_id: IntegrationId
identity_id: IMIdentityId
binding_id: IMBindingId | None
provider: IMProvider
provider_tenant_id: str
provider_user_id: str
def __post_init__(self) -> None:
if not self.provider_tenant_id.strip() or not self.provider_user_id.strip():
raise ValueError("verified IM provider identities must not be blank")
type VerifiedSubmissionProof = (
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof
)
@dataclass(frozen=True, slots=True)
class AccountSubmissionActor:
"""Current Dify Account that completed a submission."""
account_id: AccountId
@dataclass(frozen=True, slots=True)
class EndUserSubmissionActor:
"""Current app-scoped EndUser that completed a submission."""
end_user_id: EndUserId
@dataclass(frozen=True, slots=True)
class EmailAddressSubmissionActor:
"""Verified normalized Email identity that completed a submission."""
normalized_email: NormalizedEmail
type SubmissionActor = AccountSubmissionActor | EndUserSubmissionActor | EmailAddressSubmissionActor
@dataclass(frozen=True, slots=True)
class CurrentContactAuthorizationFacts:
"""Current Contact incarnation, Email, Account, and workspace availability."""
contact_id: ContactId
account_id: AccountId | None
normalized_email: NormalizedEmail | None
account_active: bool
workspace_available: bool
@dataclass(frozen=True, slots=True)
class CurrentEndUserAuthorizationFacts:
"""Current tenant/app ownership facts for one EndUser identity."""
end_user_id: EndUserId
app_id: AppId
workspace_available: bool
@dataclass(frozen=True, slots=True)
class CurrentIMAuthorizationFacts:
"""Credential-free effective IM binding observed in the authorization snapshot."""
integration_id: IntegrationId
provider: IMProvider
provider_tenant_id: str
contact_id: ContactId
account_id: AccountId | None
identity_id: IMIdentityId
binding_id: IMBindingId | None
provider_user_id: str
@dataclass(frozen=True, slots=True)
class AuthorizationContext:
"""One immutable tenant-scoped view used without later identity reloads."""
form: HumanInputForm
grant: ApproverGrant
endpoint: DeliveryEndpoint | None
current_contact: CurrentContactAuthorizationFacts | None
current_end_user: CurrentEndUserAuthorizationFacts | None
current_im_binding: CurrentIMAuthorizationFacts | None
def __post_init__(self) -> None:
if self.grant.ref.form_ref != self.form.ref or self.grant not in self.form.grants:
raise ValueError("authorization grant does not belong to the form snapshot")
if self.endpoint is not None and self.endpoint.grant_ref != self.grant.ref:
raise ValueError("authorization endpoint does not belong to the target grant")
class SubmissionAuthorizationRejection(StrEnum):
"""Stable transport-neutral reasons for denied submission authority."""
RAW_CREDENTIAL_NOT_VERIFIED = "raw_credential_not_verified"
FORM_ALREADY_SUBMITTED = "form_already_submitted"
FORM_TIMED_OUT = "form_timed_out"
FORM_STATUS_EXPIRED = "form_status_expired"
FORM_GLOBALLY_EXPIRED = "form_globally_expired"
GRANT_NOT_MATCHED = "grant_not_matched"
INVALID_SELECTED_ACTION = "invalid_selected_action"
STALE_IDENTITY = "stale_identity"
ACCOUNT_DISABLED = "account_disabled"
WORKSPACE_UNAVAILABLE = "workspace_unavailable"
END_USER_UNAVAILABLE = "end_user_unavailable"
IM_BINDING_CHANGED = "im_binding_changed"
@dataclass(frozen=True, slots=True)
class AuthorizedSubmission:
"""Current authority and local form transition prepared for atomic persistence."""
transition: SubmissionTransitionDecision
proof: VerifiedSubmissionProof
actor: SubmissionActor
endpoint_ref: DeliveryEndpointRef | None
@dataclass(frozen=True, slots=True)
class SubmissionAuthorizationDecision:
"""Exactly one authorized value or stable rejection."""
authorized: AuthorizedSubmission | None
rejection: SubmissionAuthorizationRejection | None
def __post_init__(self) -> None:
if (self.authorized is None) == (self.rejection is None):
raise ValueError("authorization decision requires exactly one outcome")
@classmethod
def accept(cls, authorized: AuthorizedSubmission) -> SubmissionAuthorizationDecision:
return cls(authorized, None)
@classmethod
def reject(cls, reason: SubmissionAuthorizationRejection) -> SubmissionAuthorizationDecision:
return cls(None, reason)
class SubmissionAuthorizer:
"""Stateless cross-snapshot policy that resolves one current business actor."""
@classmethod
def authorize(
cls,
*,
context: AuthorizationContext,
proof: object,
selected_action_id: str,
now: UtcTimestamp,
) -> SubmissionAuthorizationDecision:
"""Authorize verified proof against one already-loaded coherent context."""
if not isinstance(
proof,
VerifiedAccountSessionProof | VerifiedTrustedEndUserProof | VerifiedEmailOTPProof | VerifiedIMIdentityProof,
):
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED)
state = context.form.state_at(now)
if isinstance(state, InactiveFormState):
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(state.reason))
try:
transition = context.form.decide_submission(
grant_id=context.grant.id,
selected_action_id=selected_action_id,
now=now,
)
except InvalidApproverGrantError:
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.GRANT_NOT_MATCHED)
except InvalidSelectedActionError:
return SubmissionAuthorizationDecision.reject(SubmissionAuthorizationRejection.INVALID_SELECTED_ACTION)
if isinstance(transition, InactiveFormState):
return SubmissionAuthorizationDecision.reject(cls._inactive_rejection(transition.reason))
actor_or_rejection = cls._resolve_actor(context, proof)
if isinstance(actor_or_rejection, SubmissionAuthorizationRejection):
return SubmissionAuthorizationDecision.reject(actor_or_rejection)
return SubmissionAuthorizationDecision.accept(
AuthorizedSubmission(
transition=transition,
proof=proof,
actor=actor_or_rejection,
endpoint_ref=context.endpoint.ref if context.endpoint is not None else None,
)
)
@staticmethod
def _inactive_rejection(reason: FormInactiveReason) -> SubmissionAuthorizationRejection:
match reason:
case FormInactiveReason.SUBMITTED:
return SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED
case FormInactiveReason.TIMED_OUT:
return SubmissionAuthorizationRejection.FORM_TIMED_OUT
case FormInactiveReason.STATUS_EXPIRED:
return SubmissionAuthorizationRejection.FORM_STATUS_EXPIRED
case FormInactiveReason.GLOBALLY_EXPIRED:
return SubmissionAuthorizationRejection.FORM_GLOBALLY_EXPIRED
assert_never(reason)
@classmethod
def _resolve_actor(
cls,
context: AuthorizationContext,
proof: VerifiedSubmissionProof,
) -> SubmissionActor | SubmissionAuthorizationRejection:
match proof:
case VerifiedAccountSessionProof():
return cls._authorize_account(context, proof)
case VerifiedTrustedEndUserProof():
return cls._authorize_end_user(context, proof)
case VerifiedEmailOTPProof():
return cls._authorize_email(context, proof)
case VerifiedIMIdentityProof():
return cls._authorize_im(context, proof)
assert_never(proof)
@classmethod
def _authorize_account(
cls,
context: AuthorizationContext,
proof: VerifiedAccountSessionProof,
) -> SubmissionActor | SubmissionAuthorizationRejection:
if not isinstance(context.grant.subject, ContactApprovalSubject):
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
current = cls._validate_current_contact(context, context.grant.subject.contact_id)
if isinstance(current, SubmissionAuthorizationRejection):
return current
if current.account_id != proof.account_id:
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
return AccountSubmissionActor(proof.account_id)
@staticmethod
def _authorize_end_user(
context: AuthorizationContext,
proof: VerifiedTrustedEndUserProof,
) -> SubmissionActor | SubmissionAuthorizationRejection:
if not isinstance(context.grant.subject, EndUserApprovalSubject):
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
current = context.current_end_user
if (
current is None
or current.end_user_id != context.grant.subject.end_user_id
or current.end_user_id != proof.end_user_id
or current.app_id != context.form.app_id
or current.app_id != proof.app_id
):
return SubmissionAuthorizationRejection.END_USER_UNAVAILABLE
if not current.workspace_available:
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
return EndUserSubmissionActor(current.end_user_id)
@classmethod
def _authorize_email(
cls,
context: AuthorizationContext,
proof: VerifiedEmailOTPProof,
) -> SubmissionActor | SubmissionAuthorizationRejection:
if proof.challenge_ref.grant_ref != context.grant.ref:
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
subject = context.grant.subject
if isinstance(subject, EmailAddressApprovalSubject):
if (
not isinstance(proof.subject, EmailAddressOTPSubject)
or proof.subject.normalized_email != subject.normalized_email
or proof.normalized_email != subject.normalized_email
):
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
return EmailAddressSubmissionActor(subject.normalized_email)
if not isinstance(subject, ContactApprovalSubject) or not isinstance(proof.subject, ContactOTPSubject):
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
if proof.subject.contact_id != subject.contact_id:
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
current = cls._validate_current_contact(context, subject.contact_id)
if isinstance(current, SubmissionAuthorizationRejection):
return current
if current.normalized_email is None or current.normalized_email != proof.normalized_email:
return SubmissionAuthorizationRejection.STALE_IDENTITY
if current.account_id is not None:
return AccountSubmissionActor(current.account_id)
return EmailAddressSubmissionActor(proof.normalized_email)
@classmethod
def _authorize_im(
cls,
context: AuthorizationContext,
proof: VerifiedIMIdentityProof,
) -> SubmissionActor | SubmissionAuthorizationRejection:
if not isinstance(context.grant.subject, ContactApprovalSubject):
return SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
current_contact = cls._validate_current_contact(context, context.grant.subject.contact_id)
if isinstance(current_contact, SubmissionAuthorizationRejection):
return current_contact
if current_contact.account_id is None:
return SubmissionAuthorizationRejection.STALE_IDENTITY
current_im = context.current_im_binding
if current_im is None:
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
if (
current_im.contact_id != context.grant.subject.contact_id
or current_im.account_id != current_contact.account_id
or current_im.integration_id != proof.integration_id
or current_im.identity_id != proof.identity_id
or current_im.binding_id != proof.binding_id
or current_im.provider is not proof.provider
or current_im.provider_tenant_id != proof.provider_tenant_id
or current_im.provider_user_id != proof.provider_user_id
):
return SubmissionAuthorizationRejection.IM_BINDING_CHANGED
return AccountSubmissionActor(current_contact.account_id)
@staticmethod
def _validate_current_contact(
context: AuthorizationContext,
contact_id: ContactId,
) -> CurrentContactAuthorizationFacts | SubmissionAuthorizationRejection:
current = context.current_contact
if current is None or current.contact_id != contact_id:
return SubmissionAuthorizationRejection.STALE_IDENTITY
if current.account_id is not None and not current.account_active:
return SubmissionAuthorizationRejection.ACCOUNT_DISABLED
if not current.workspace_available:
return SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
return current
__all__ = [
"AccountSubmissionActor",
"AuthorizationContext",
"AuthorizedSubmission",
"CurrentContactAuthorizationFacts",
"CurrentEndUserAuthorizationFacts",
"CurrentIMAuthorizationFacts",
"EmailAddressSubmissionActor",
"EndUserSubmissionActor",
"SubmissionActor",
"SubmissionAuthorizationDecision",
"SubmissionAuthorizationRejection",
"SubmissionAuthorizer",
"VerifiedAccountSessionProof",
"VerifiedIMIdentityProof",
"VerifiedSubmissionProof",
"VerifiedTrustedEndUserProof",
]
@@ -0,0 +1,117 @@
"""Transaction-oriented ports for current authorization and first-success commit.
The transaction object keeps one coherent authorization context alive through
the winning write set. Generic CRUD is intentionally absent; persistence owns
the Form lock, rejection audit append, and atomic authorized commit shape.
"""
from __future__ import annotations
from contextlib import AbstractContextManager
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from core.human_input_v2.shared import (
ApproverGrantId,
AuditEventId,
DeliveryEndpointId,
SubmissionId,
UtcTimestamp,
)
from .frozen_values import FrozenJSONObject
from .grants import FormRef
from .submission_authorization import AuthorizationContext, AuthorizedSubmission
from .submission_records import FormAuthorizationAuditEvent, FormSubmission
class RetryableSubmissionPersistenceError(RuntimeError):
"""A complete submission transaction must be retried with a fresh snapshot."""
@dataclass(frozen=True, slots=True)
class SubmissionAttemptScope:
"""Complete logical owner chain selected before the transaction begins."""
form_ref: FormRef
approver_grant_id: ApproverGrantId
endpoint_id: DeliveryEndpointId | None
@dataclass(frozen=True, slots=True)
class AuthorizedSubmissionCommit:
"""Caller-owned identities and structured values for one authorized write set."""
submission_id: SubmissionId
authorization_audit_event_id: AuditEventId
authorized: AuthorizedSubmission
input_snapshot: FrozenJSONObject
canonical_values: FrozenJSONObject
def to_submission(
self,
*,
form_ref: FormRef,
approver_grant_id: ApproverGrantId,
endpoint_id: DeliveryEndpointId | None,
submitted_at: UtcTimestamp,
) -> FormSubmission:
"""Build the immutable record value after owner-scope validation."""
return FormSubmission(
id=self.submission_id,
form_ref=form_ref,
approver_grant_id=approver_grant_id,
endpoint_id=endpoint_id,
authorization_audit_event_id=self.authorization_audit_event_id,
actor=self.authorized.actor,
selected_action_id=self.authorized.transition.selected_action_id,
input_snapshot=self.input_snapshot,
canonical_values=self.canonical_values,
submitted_at=submitted_at,
created_at=submitted_at,
updated_at=submitted_at,
)
class SubmissionCommitStatus(StrEnum):
"""Stable first-success persistence outcome."""
COMMITTED = "committed"
ALREADY_COMPLETED = "already_completed"
@dataclass(frozen=True, slots=True)
class SubmissionCommitResult:
"""Committed winning submission or stable loser result."""
status: SubmissionCommitStatus
submission: FormSubmission | None
class SubmissionTransaction(Protocol):
"""One session-bound authorization and commit transaction."""
def load_authorization_context(self, *, proof: object) -> AuthorizationContext: ...
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None: ...
def commit_authorized_submission_once(self, commit: AuthorizedSubmissionCommit) -> SubmissionCommitResult: ...
class SubmissionRepository(Protocol):
"""Factory for one short transaction owning the complete submission use case."""
def transaction(self, scope: SubmissionAttemptScope) -> AbstractContextManager[SubmissionTransaction]: ...
__all__ = [
"AuthorizedSubmissionCommit",
"RetryableSubmissionPersistenceError",
"SubmissionAttemptScope",
"SubmissionCommitResult",
"SubmissionCommitStatus",
"SubmissionRepository",
"SubmissionTransaction",
]
@@ -0,0 +1,102 @@
"""Immutable submission and shared authorization-audit persistence values.
These values preserve business identity and structured snapshots without
exposing ORM records. Persistence mappers alone translate them to storage
columns and Pydantic JSON values.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from core.human_input_v2.entities import HumanInputDeliveryChannel
from core.human_input_v2.shared import (
ApproverGrantId,
AuditEventId,
DeliveryEndpointId,
SubmissionId,
UtcTimestamp,
)
from .frozen_values import FrozenJSONObject
from .grants import FormRef
from .submission_authorization import SubmissionActor, VerifiedEmailOTPProof, VerifiedSubmissionProof
class FormAuthorizationAuditEventType(StrEnum):
"""Stable append-only event names owned by the shared audit table."""
OTP_CHALLENGE_ISSUED = "otp_challenge_issued"
SUBMISSION_AUTHORIZED = "submission_authorized"
SUBMISSION_REJECTED = "submission_rejected"
@dataclass(frozen=True, slots=True)
class FormAuthorizationAuditEvent:
"""Secret-free authorized, rejected, or OTP issuance audit fact."""
id: AuditEventId
event_type: FormAuthorizationAuditEventType
form_ref: FormRef
approver_grant_id: ApproverGrantId | None
endpoint_id: DeliveryEndpointId | None
channel: HumanInputDeliveryChannel | None
reason_code: str | None
reason_message: str | None
authorization_proof: VerifiedSubmissionProof | None
payload: FrozenJSONObject | None
occurred_at: UtcTimestamp
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED:
if self.approver_grant_id is None or self.authorization_proof is None:
raise ValueError("authorized audit event requires a grant and verified proof")
if self.reason_code is not None:
raise ValueError("authorized audit event cannot contain a rejection reason")
self.validate_authorization_proof_owner()
if self.event_type is FormAuthorizationAuditEventType.SUBMISSION_REJECTED and not self.reason_code:
raise ValueError("rejected audit event requires a stable reason code")
def validate_authorization_proof_owner(self) -> None:
"""Reject authorized Email evidence captured for another form or grant."""
proof = self.authorization_proof
if not isinstance(proof, VerifiedEmailOTPProof):
return
if (
proof.challenge_ref.form_ref != self.form_ref
or proof.challenge_ref.grant_ref.grant_id != self.approver_grant_id
):
raise ValueError("authorized Email proof owner does not match the audit event")
@dataclass(frozen=True, slots=True)
class FormSubmission:
"""Immutable winning submission mapped independently from ORM lifetime."""
id: SubmissionId
form_ref: FormRef
approver_grant_id: ApproverGrantId
endpoint_id: DeliveryEndpointId | None
authorization_audit_event_id: AuditEventId
actor: SubmissionActor
selected_action_id: str
input_snapshot: FrozenJSONObject
canonical_values: FrozenJSONObject
submitted_at: UtcTimestamp
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not self.selected_action_id.strip():
raise ValueError("submission selected action must not be blank")
__all__ = [
"FormAuthorizationAuditEvent",
"FormAuthorizationAuditEventType",
"FormSubmission",
]
@@ -0,0 +1,37 @@
"""Infrastructure-free Contact Directory domain boundary.
Transport and persistence layers may depend on this package. This package must
not import controllers, Flask, SQLAlchemy, sessions, or ORM records.
"""
from .entities import (
Contact,
ContactIdentitySource,
ContactOwner,
ContactSnapshot,
ExternalContactOwner,
OrganizationAccountOwner,
PlatformWorkspaceEntry,
WorkspaceMemberOwner,
)
from .errors import ContactDirectoryError, ContactRejection, ContactRejectionCode
from .policy import ContactDirectoryPolicy, ContactDirectorySnapshot, ContactResolution
from .ports import ContactDirectoryRepository
__all__ = [
"Contact",
"ContactDirectoryError",
"ContactDirectoryPolicy",
"ContactDirectoryRepository",
"ContactDirectorySnapshot",
"ContactIdentitySource",
"ContactOwner",
"ContactRejection",
"ContactRejectionCode",
"ContactResolution",
"ContactSnapshot",
"ExternalContactOwner",
"OrganizationAccountOwner",
"PlatformWorkspaceEntry",
"WorkspaceMemberOwner",
]
@@ -0,0 +1,229 @@
"""Canonical Contact identity independent of workspace-relative resolution.
Contacts own identity invariants and current profile facts. Membership,
allow-list state, database I/O, and transport serialization remain outside the
entity so its immutable lifecycle source cannot be confused with a query result.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from core.human_input_v2.shared import (
AccountId,
ContactId,
DeploymentScope,
DirectoryScope,
NormalizedEmail,
PlatformEntryId,
UtcTimestamp,
WorkspaceId,
WorkspaceScope,
)
from .errors import ContactRejectionCode, reject
class ContactIdentitySource(StrEnum):
"""Immutable lifecycle source of a canonical Contact."""
ORGANIZATION_ACCOUNT = "organization_account"
WORKSPACE_MEMBER = "workspace_member"
EXTERNAL = "external"
@dataclass(frozen=True, slots=True)
class OrganizationAccountOwner:
"""Deployment-wide EE owner reference backed by one Account."""
account_id: AccountId
@dataclass(frozen=True, slots=True)
class WorkspaceMemberOwner:
"""Workspace owner reference backed by one current or historical Account."""
workspace_id: WorkspaceId
account_id: AccountId
@dataclass(frozen=True, slots=True)
class ExternalContactOwner:
"""Workspace owner reference for an address managed by administrators."""
workspace_id: WorkspaceId
type ContactOwner = OrganizationAccountOwner | WorkspaceMemberOwner | ExternalContactOwner
@dataclass(frozen=True, slots=True)
class Contact:
"""Canonical identity whose source and owner remain immutable.
Use the named factories for normal construction. ``create`` exists for
persistence mapping and validates the same source/owner invariant.
"""
id: ContactId
identity_source: ContactIdentitySource
owner: ContactOwner
name: str
normalized_name: str
email: str | None
normalized_email: NormalizedEmail | None
avatar_file_id: str | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
expected_owner_type = {
ContactIdentitySource.ORGANIZATION_ACCOUNT: OrganizationAccountOwner,
ContactIdentitySource.WORKSPACE_MEMBER: WorkspaceMemberOwner,
ContactIdentitySource.EXTERNAL: ExternalContactOwner,
}[self.identity_source]
if not isinstance(self.owner, expected_owner_type):
raise reject(ContactRejectionCode.INVALID_OWNER)
if not self.name.strip():
raise reject(ContactRejectionCode.INVALID_NAME)
if self.identity_source is ContactIdentitySource.EXTERNAL and self.normalized_email is None:
raise reject(ContactRejectionCode.INVALID_EMAIL)
if self.email is None and self.normalized_email is not None:
raise reject(ContactRejectionCode.INVALID_EMAIL)
if self.email is not None:
try:
normalized_email = NormalizedEmail(self.email)
except ValueError as error:
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
if self.normalized_email != normalized_email:
raise reject(ContactRejectionCode.INVALID_EMAIL)
object.__setattr__(self, "email", self.email.strip())
normalized_name = self.name.strip().casefold()
if self.normalized_name != normalized_name:
object.__setattr__(self, "normalized_name", normalized_name)
object.__setattr__(self, "name", self.name.strip())
@classmethod
def create(
cls,
*,
contact_id: ContactId,
identity_source: ContactIdentitySource,
owner: ContactOwner,
name: str,
email: str | None,
now: UtcTimestamp,
avatar_file_id: str | None = None,
created_at: UtcTimestamp | None = None,
) -> Contact:
normalized_email: NormalizedEmail | None = None
if email is not None:
try:
normalized_email = NormalizedEmail(email)
except ValueError as error:
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
return cls(
id=contact_id,
identity_source=identity_source,
owner=owner,
name=name,
normalized_name=name.strip().casefold(),
email=email,
normalized_email=normalized_email,
avatar_file_id=avatar_file_id,
created_at=created_at or now,
updated_at=now,
)
@classmethod
def organization_account(
cls,
*,
contact_id: ContactId,
account_id: AccountId,
name: str,
email: str | None,
now: UtcTimestamp,
) -> Contact:
return cls.create(
contact_id=contact_id,
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
owner=OrganizationAccountOwner(account_id),
name=name,
email=email,
now=now,
)
@classmethod
def workspace_member(
cls,
*,
contact_id: ContactId,
workspace_id: WorkspaceId,
account_id: AccountId,
name: str,
email: str | None,
now: UtcTimestamp,
) -> Contact:
return cls.create(
contact_id=contact_id,
identity_source=ContactIdentitySource.WORKSPACE_MEMBER,
owner=WorkspaceMemberOwner(workspace_id, account_id),
name=name,
email=email,
now=now,
)
@classmethod
def external(
cls,
*,
contact_id: ContactId,
workspace_id: WorkspaceId,
name: str,
email: str,
now: UtcTimestamp,
avatar_file_id: str | None = None,
) -> Contact:
return cls.create(
contact_id=contact_id,
identity_source=ContactIdentitySource.EXTERNAL,
owner=ExternalContactOwner(workspace_id),
name=name,
email=email,
now=now,
avatar_file_id=avatar_file_id,
)
@property
def account_id(self) -> AccountId | None:
if isinstance(self.owner, OrganizationAccountOwner | WorkspaceMemberOwner):
return self.owner.account_id
return None
@property
def directory_scope(self) -> DirectoryScope:
if isinstance(self.owner, OrganizationAccountOwner):
return DeploymentScope()
return WorkspaceScope(self.owner.workspace_id)
@dataclass(frozen=True, slots=True)
class ContactSnapshot:
"""Immutable Contact plus current Account availability for one operation."""
contact: Contact
account_available: bool
@dataclass(frozen=True, slots=True)
class PlatformWorkspaceEntry:
"""One workspace allow-list fact for an Organization Account Contact."""
id: PlatformEntryId
workspace_id: WorkspaceId
contact_id: ContactId
added_by_account_id: AccountId
created_at: UtcTimestamp
updated_at: UtcTimestamp
@@ -0,0 +1,48 @@
"""Transport-neutral Contact Directory rejection contracts."""
from dataclasses import dataclass
from enum import StrEnum
class ContactRejectionCode(StrEnum):
"""Stable machine-readable reasons returned by Contact Directory operations."""
INVALID_OWNER = "invalid_owner"
INVALID_EMAIL = "invalid_email"
INVALID_NAME = "invalid_name"
CONFLICTING_IDENTITY = "conflicting_identity"
CROSS_ORGANIZATION = "cross_organization"
ACCOUNT_UNAVAILABLE = "account_unavailable"
CONTACT_NOT_FOUND = "contact_not_found"
SETUP_ROW_MISSING = "setup_row_missing"
PERSISTENCE_FAILURE = "persistence_failure"
@dataclass(frozen=True, slots=True)
class ContactRejection:
"""Serializable domain rejection without HTTP or RPC semantics."""
reason: ContactRejectionCode
def to_primitive(self) -> dict[str, str]:
return {"reason": self.reason.value}
class ContactDirectoryError(Exception):
"""Exception carrier for one transport-neutral Contact rejection."""
rejection: ContactRejection
def __init__(self, rejection: ContactRejection) -> None:
self.rejection = rejection
super().__init__(rejection.reason.value)
@property
def code(self) -> ContactRejectionCode:
return self.rejection.reason
def reject(reason: ContactRejectionCode) -> ContactDirectoryError:
"""Build a domain exception while keeping reason construction consistent."""
return ContactDirectoryError(ContactRejection(reason))
@@ -0,0 +1,101 @@
"""Pure Contact Directory resolution and lifecycle policies.
The immutable snapshot supplies all operation-scoped facts. Policies never load
membership, Account, allow-list, or Contact records themselves.
"""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from core.human_input_v2.shared import AccountId, ContactId, NormalizedEmail, UtcTimestamp, WorkspaceId
from .entities import Contact, ExternalContactOwner, OrganizationAccountOwner, WorkspaceMemberOwner
from .errors import ContactRejectionCode, reject
class ContactResolution(StrEnum):
"""Workspace-relative availability of one canonical Contact."""
WORKSPACE = "workspace"
PLATFORM = "platform"
EXTERNAL = "external"
ABSENT = "absent"
@dataclass(frozen=True, slots=True)
class ContactDirectorySnapshot:
"""Coherent, request-scoped Contact facts for one workspace.
The snapshot is deliberately not a cache. Authorization callers that need
current facts must load a new snapshot in their own operation.
"""
workspace_id: WorkspaceId
contacts: tuple[Contact, ...] = ()
member_account_ids: frozenset[AccountId] = frozenset()
platform_contact_ids: frozenset[ContactId] = frozenset()
unavailable_account_ids: frozenset[AccountId] = frozenset()
def find(self, contact_id: ContactId) -> Contact | None:
return next((contact for contact in self.contacts if contact.id == contact_id), None)
class ContactDirectoryPolicy:
"""Stateless policy for workspace resolution and External lifecycle rules."""
@staticmethod
def resolve_for_workspace(snapshot: ContactDirectorySnapshot, contact_id: ContactId) -> ContactResolution:
contact = snapshot.find(contact_id)
if contact is None:
return ContactResolution.ABSENT
owner = contact.owner
if isinstance(owner, ExternalContactOwner):
if owner.workspace_id != snapshot.workspace_id:
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
return ContactResolution.EXTERNAL
if isinstance(owner, WorkspaceMemberOwner) and owner.workspace_id != snapshot.workspace_id:
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
account_id = contact.account_id
if account_id is None or account_id in snapshot.unavailable_account_ids:
return ContactResolution.ABSENT
if account_id in snapshot.member_account_ids:
return ContactResolution.WORKSPACE
if isinstance(owner, OrganizationAccountOwner) and contact.id in snapshot.platform_contact_ids:
return ContactResolution.PLATFORM
return ContactResolution.ABSENT
@staticmethod
def admit_external(
snapshot: ContactDirectorySnapshot,
*,
contact_id: ContactId,
name: str,
email: str,
now: UtcTimestamp,
avatar_file_id: str | None = None,
) -> Contact:
try:
normalized_email = NormalizedEmail(email)
except ValueError as error:
raise reject(ContactRejectionCode.INVALID_EMAIL) from error
if any(contact.normalized_email == normalized_email for contact in snapshot.contacts):
raise reject(ContactRejectionCode.CONFLICTING_IDENTITY)
return Contact.external(
contact_id=contact_id,
workspace_id=snapshot.workspace_id,
name=name,
email=email,
now=now,
avatar_file_id=avatar_file_id,
)
@staticmethod
def ensure_external_deletable(contact: Contact, workspace_id: WorkspaceId) -> None:
if not isinstance(contact.owner, ExternalContactOwner):
raise reject(ContactRejectionCode.INVALID_OWNER)
if contact.owner.workspace_id != workspace_id:
raise reject(ContactRejectionCode.CROSS_ORGANIZATION)
@@ -0,0 +1,47 @@
"""Aggregate-oriented persistence ports for Contact Directory operations.
Implementations own transactions, owner predicates, locking, mapping, and
rollback. Callers receive domain values and never persistence records.
"""
from typing import Protocol
from core.human_input_v2.shared import AccountId, ContactId, WorkspaceId
from .entities import Contact
from .policy import ContactDirectorySnapshot
class ContactDirectoryRepository(Protocol):
"""Persistence contract centered on coherent directory invariants."""
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
"""Load one immutable workspace-scoped directory view."""
...
def save_organization_contact(self, contact: Contact) -> Contact:
"""Create or update one deployment-owned Organization Contact with a serialized Email claim."""
...
def save_workspace_member_contact(self, contact: Contact) -> Contact:
"""Create or update one Contact backed by current workspace membership."""
...
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
"""Atomically admit one External Contact against tenant and configured Organization identities."""
...
def set_platform_availability(
self,
workspace_id: WorkspaceId,
contact_id: ContactId,
*,
added_by_account_id: AccountId,
enabled: bool,
) -> None:
"""Atomically add or remove one EE Platform allow-list fact."""
...
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
"""Delete an External Contact without retaining an identity tombstone."""
...
+197
View File
@@ -0,0 +1,197 @@
from enum import StrEnum
from typing import NewType
from core.human_input_v2.shared import AccountId, NormalizedEmail, UtcTimestamp, WorkspaceId
class HumanInputContactType(StrEnum):
"""Concrete contact classification resolved in one workspace."""
WORKSPACE = "workspace"
PLATFORM = "platform"
EXTERNAL = "external"
class HumanInputApproverGrantSubjectType(StrEnum):
"""Business subject receiving approval authority for one Human Input form."""
CONTACT = "contact"
END_USER = "end_user"
EMAIL_ADDRESS = "email_address"
class HumanInputSubmissionActorType(StrEnum):
"""Business identity that completed one Human Input form submission."""
ACCOUNT = "account"
END_USER = "end_user"
EMAIL_ADDRESS = "email_address"
class HumanInputV2FormKind(StrEnum):
"""Persistence kind for an independently stored Human Input v2 form."""
RUNTIME = "runtime"
DELIVERY_TEST = "delivery_test"
class HumanInputV2FormStatus(StrEnum):
"""Lifecycle state of an independently stored Human Input v2 form."""
WAITING = "waiting"
EXPIRED = "expired"
SUBMITTED = "submitted"
TIMEOUT = "timeout"
class HumanInputAuthorizationProofType(StrEnum):
"""Verified evidence type retained for a Human Input authorization audit event."""
ACCOUNT_SESSION = "account_session"
EMAIL_OTP = "email_otp"
IM_IDENTITY = "im_identity"
TRUSTED_END_USER = "trusted_end_user"
class HumanInputDeliveryChannel(StrEnum):
"""Notification or interaction channel frozen for one form endpoint."""
EMAIL = "email"
IM = "im"
WEB = "web"
CONSOLE = "console"
class HumanInputDeliveryAttemptStatus(StrEnum):
"""Delivery lifecycle kept separate from the form state machine."""
QUEUED = "queued"
SENDING = "sending"
SENT = "sent"
FAILED = "failed"
class HumanInputOTPChallengeStatus(StrEnum):
"""Current usability of an email proof challenge."""
PENDING = "pending"
VERIFIED = "verified"
INVALIDATED = "invalidated"
EXPIRED = "expired"
class IMProvider(StrEnum):
"""IM provider supported by Human Input contact and delivery flows."""
FEISHU = "feishu"
SLACK = "slack"
DING_TALK = "ding_talk"
MS_TEAMS = "ms_teams"
WE_COM = "we_com"
LARK = "lark"
class IMBindingScope(StrEnum):
"""Resolution scope of a contact-to-IM-identity binding."""
WORKSPACE = "workspace"
ORGANIZATION = "organization"
class IMIntegrationStatus(StrEnum):
"""Connectivity state of an organization-level IM integration."""
NOT_CONFIGURED = "not_configured"
CONFIGURED = "configured"
CONNECTED = "connected"
PERMISSION_ISSUE = "permission_issue"
CALLBACK_ERROR = "callback_error"
CONNECTION_ERROR = "connection_error"
class IMIdentityBindingStatus(StrEnum):
"""Whether a synchronized IM identity is currently bound."""
UNBOUND = "unbound"
BOUND = "bound"
class IMSyncRunStatus(StrEnum):
"""Lifecycle state of a manual IM directory synchronization."""
QUEUED = "queued"
RUNNING = "running"
SUCCEEDED = "succeeded"
FAILED = "failed"
class IMSyncResultType(StrEnum):
"""Stable reconciliation bucket for one synchronized directory entry."""
ADDED = "added"
NOT_MATCHED = "not_matched"
FAILED = "failed"
REMOVED = "removed"
SKIPPED = "skipped"
class IMSyncRemovalReason(StrEnum):
"""Stable reason for removing or replacing a current IM binding."""
NOT_PRESENT_IN_DIRECTORY = "not_present_in_directory"
BINDING_INVALIDATED = "binding_invalidated"
BINDING_REPLACED = "binding_replaced"
class EmailProviderType(StrEnum):
"""Email provider supported by organization-level Human Input delivery."""
RESEND = "resend"
# Identifiers for organization candidates and contacts.
OrganizationCandidateId = NewType("OrganizationCandidateId", str)
# Legacy transport identifier. Contact Directory code uses the richer value
# object from ``core.human_input_v2.shared`` at its domain boundary.
ContactId = NewType("ContactId", str)
# Identifiers for synced IM identiies. This is not the same as user_id or account_id
# on the IM provier side. It is the identifier for the synced IM user record in Dify.
IMIdentityId = NewType("IMIdentityId", str)
# Identifiers for a full IM user synchorization.
IMSyncRunId = NewType("IMSyncRunId", str)
# Identifier for an IM binding, an association between an IM identity and a Dify contact.
IMBindingId = NewType("IMBindingId", str)
__all__ = [
"AccountId",
"ContactId",
"EmailProviderType",
"HumanInputApproverGrantSubjectType",
"HumanInputAuthorizationProofType",
"HumanInputContactType",
"HumanInputDeliveryAttemptStatus",
"HumanInputDeliveryChannel",
"HumanInputOTPChallengeStatus",
"HumanInputSubmissionActorType",
"HumanInputV2FormKind",
"HumanInputV2FormStatus",
"IMBindingId",
"IMBindingScope",
"IMIdentityBindingStatus",
"IMIdentityId",
"IMIntegrationStatus",
"IMProvider",
"IMSyncRemovalReason",
"IMSyncResultType",
"IMSyncRunId",
"IMSyncRunStatus",
"NormalizedEmail",
"OrganizationCandidateId",
"UtcTimestamp",
"WorkspaceId",
]
@@ -0,0 +1,82 @@
"""Infrastructure-free IM configuration, synchronization, and binding boundary.
This package depends on canonical Contact Directory facts and shared primitive
values. Provider clients, controllers, SQLAlchemy sessions, ORM records, and
provider transport payload types belong outside this boundary. The public API
hides configuration CAS, sync matching, and effective-binding priority behind
domain decisions and transaction-oriented ports.
"""
from .binding_resolution import (
BindingResolutionKind,
BindingResolutionResult,
EffectiveBindingResolver,
EffectiveIMBindingSnapshot,
)
from .integration import (
ConfigurationTransition,
ConfigurationTransitionKind,
CurrentStateInvalidation,
EncryptedCredentials,
IMIntegration,
IntegrationDeletion,
IntegrationRevisionToken,
ProviderTenantIdentity,
StaleRevision,
)
from .ports import (
ActiveRunDecision,
ActiveRunDecisionKind,
ApplyReconciliationResult,
ApplyReconciliationStatus,
IMControlPlaneRepository,
)
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
from .state import IMIntegrationState
from .sync_reconciliation import (
IMSyncRun,
MatchKind,
ProviderDirectoryEntry,
ReconciliationAction,
ReconciliationPlan,
ReconciliationSnapshot,
SyncContactSnapshot,
SyncIdentitySnapshot,
SyncReconciler,
SyncResultFact,
)
__all__ = [
"ActiveRunDecision",
"ActiveRunDecisionKind",
"ApplyReconciliationResult",
"ApplyReconciliationStatus",
"BindingResolutionKind",
"BindingResolutionResult",
"ConfigurationTransition",
"ConfigurationTransitionKind",
"CurrentStateInvalidation",
"EffectiveBindingResolver",
"EffectiveIMBindingSnapshot",
"EncryptedCredentials",
"IMBinding",
"IMControlPlaneRepository",
"IMIdentity",
"IMIntegration",
"IMIntegrationState",
"IMSyncRun",
"IntegrationDeletion",
"IntegrationRevisionToken",
"MatchKind",
"OpaqueProviderPayload",
"ProviderDirectoryEntry",
"ProviderTenantIdentity",
"ReconciliationAction",
"ReconciliationPlan",
"ReconciliationSnapshot",
"StaleRevision",
"SyncContactSnapshot",
"SyncIdentitySnapshot",
"SyncReconciler",
"SyncResultFact",
]
@@ -0,0 +1,178 @@
"""Effective binding priority and credential-free consumer snapshots."""
from __future__ import annotations
from dataclasses import dataclass
from enum import StrEnum
from core.human_input_v2.contact_directory import ContactSnapshot
from core.human_input_v2.entities import IMBindingScope, IMProvider
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IntegrationId,
WorkspaceId,
)
from .integration import IntegrationRevisionToken, ProviderTenantIdentity
from .records import IMBinding, IMIdentity
class BindingResolutionKind(StrEnum):
"""Stable priority result returned to control-plane consumers."""
WORKSPACE_OVERRIDE = "workspace_override"
ORGANIZATION_BINDING = "organization_binding"
EMAIL_FALLBACK = "email_fallback"
NOT_AVAILABLE = "not_available"
INVALID_BINDING = "invalid_binding"
@dataclass(frozen=True, slots=True)
class EffectiveIMBindingSnapshot:
"""Consumer-safe effective channel facts without credentials or raw payloads."""
integration_id: IntegrationId
integration_config_version: int
provider: IMProvider
provider_tenant_id: str
contact_id: ContactId
account_id: AccountId | None
identity_id: IMIdentityId
binding_id: IMBindingId | None
provider_user_id: str
display_name: str | None
email: str | None
@dataclass(frozen=True, slots=True)
class BindingResolutionResult:
"""Effective binding or stable rejection without leaking invalid records."""
kind: BindingResolutionKind
binding: EffectiveIMBindingSnapshot | None
class EffectiveBindingResolver:
"""Resolve workspace override, organization binding, then Email fallback."""
@staticmethod
def resolve(
*,
integration_revision: IntegrationRevisionToken,
provider_tenant: ProviderTenantIdentity,
workspace_id: WorkspaceId,
contact: ContactSnapshot,
identities: tuple[IMIdentity, ...],
bindings: tuple[IMBinding, ...],
) -> BindingResolutionResult:
identities_by_id = {identity.id: identity for identity in identities}
candidates = [binding for binding in bindings if binding.contact_id == contact.contact.id]
workspace_binding = next(
(
binding
for binding in candidates
if binding.scope is IMBindingScope.WORKSPACE and binding.scope_id == str(workspace_id)
),
None,
)
organization_binding = next(
(
binding
for binding in candidates
if binding.scope is IMBindingScope.ORGANIZATION
and binding.scope_id == str(integration_revision.integration_id)
),
None,
)
selected = workspace_binding or organization_binding
if selected is not None:
identity = identities_by_id.get(selected.identity_id)
if not EffectiveBindingResolver._matches_integration(
selected,
identity,
integration_revision,
provider_tenant,
):
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
assert identity is not None
kind = (
BindingResolutionKind.WORKSPACE_OVERRIDE
if selected is workspace_binding
else BindingResolutionKind.ORGANIZATION_BINDING
)
return BindingResolutionResult(
kind,
EffectiveBindingResolver._snapshot(
integration_revision,
provider_tenant,
contact,
identity,
selected.id,
),
)
normalized_email = contact.contact.normalized_email
if normalized_email is not None:
identity = next(
(
candidate
for candidate in identities
if candidate.integration_id == integration_revision.integration_id
and candidate.provider is provider_tenant.provider
and candidate.normalized_email == normalized_email
),
None,
)
if identity is not None:
return BindingResolutionResult(
BindingResolutionKind.EMAIL_FALLBACK,
EffectiveBindingResolver._snapshot(
integration_revision,
provider_tenant,
contact,
identity,
None,
),
)
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
@staticmethod
def _matches_integration(
binding: IMBinding,
identity: IMIdentity | None,
integration_revision: IntegrationRevisionToken,
provider_tenant: ProviderTenantIdentity,
) -> bool:
return (
identity is not None
and binding.integration_id == integration_revision.integration_id
and identity.integration_id == integration_revision.integration_id
and binding.provider is provider_tenant.provider
and identity.provider is provider_tenant.provider
)
@staticmethod
def _snapshot(
revision: IntegrationRevisionToken,
provider_tenant: ProviderTenantIdentity,
contact: ContactSnapshot,
identity: IMIdentity,
binding_id: IMBindingId | None,
) -> EffectiveIMBindingSnapshot:
return EffectiveIMBindingSnapshot(
integration_id=revision.integration_id,
integration_config_version=revision.config_version,
provider=provider_tenant.provider,
provider_tenant_id=provider_tenant.provider_tenant_id,
contact_id=contact.contact.id,
account_id=contact.contact.account_id,
identity_id=identity.id,
binding_id=binding_id,
provider_user_id=identity.provider_user_id,
display_name=identity.display_name,
email=identity.email,
)
@@ -0,0 +1,236 @@
"""IM Integration aggregate and complete compare-and-swap revision values."""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass, replace
from enum import StrEnum
from pydantic import JsonValue
from core.human_input_v2.entities import IMIntegrationStatus, IMProvider
from core.human_input_v2.shared import AccountId, IntegrationId, UtcTimestamp, WorkspaceId
@dataclass(frozen=True, slots=True)
class EncryptedCredentials:
"""Immutable opaque encrypted configuration passed through the domain boundary."""
_serialized: str
@classmethod
def from_mapping(cls, values: Mapping[str, JsonValue]) -> EncryptedCredentials:
if not values:
raise ValueError("encrypted credentials must not be empty")
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
def to_mapping(self) -> dict[str, JsonValue]:
value = json.loads(self._serialized)
if not isinstance(value, dict):
raise ValueError("encrypted credentials must be a JSON object")
return value
@dataclass(frozen=True, slots=True)
class ProviderTenantIdentity:
"""Provider plus its confirmed organization or workspace identity."""
provider: IMProvider
provider_tenant_id: str
def __post_init__(self) -> None:
if not self.provider_tenant_id.strip():
raise ValueError("provider tenant id must not be blank")
object.__setattr__(self, "provider_tenant_id", self.provider_tenant_id.strip())
@dataclass(frozen=True, slots=True)
class IntegrationRevisionToken:
"""Complete CAS token that prevents identity-replacement ABA."""
integration_id: IntegrationId
config_version: int
def __post_init__(self) -> None:
if self.config_version < 1:
raise ValueError("config version must be positive")
@dataclass(frozen=True, slots=True)
class StaleRevision:
"""Stable rejection for a token that no longer names current configuration."""
expected: IntegrationRevisionToken
actual: IntegrationRevisionToken | None
class ConfigurationTransitionKind(StrEnum):
"""Current-state effect selected by one confirmed configuration write."""
CREDENTIAL_ROTATION = "credential_rotation"
PROVIDER_REPLACEMENT = "provider_replacement"
@dataclass(frozen=True, slots=True)
class CurrentStateInvalidation:
"""Current identity and binding cleanup owned by the configuration transaction."""
invalidate_identities: bool
invalidate_bindings: bool
@dataclass(frozen=True, slots=True)
class ConfigurationTransition:
"""Atomic configuration write plus its current-state cleanup decision."""
expected_revision: IntegrationRevisionToken
kind: ConfigurationTransitionKind
integration: IMIntegration
invalidation: CurrentStateInvalidation
@dataclass(frozen=True, slots=True)
class IntegrationDeletion:
"""CAS-authorized deletion and current-state invalidation decision."""
expected_revision: IntegrationRevisionToken
invalidation: CurrentStateInvalidation = CurrentStateInvalidation(True, True)
@dataclass(frozen=True, slots=True)
class IMIntegration:
"""Organization IM configuration aggregate.
Provider reads and credential encryption happen before construction.
Configuration transitions return decisions; persistence adapters alone make
them atomic and decide whether the expected revision is still current.
Connectivity diagnostics are non-configuration state and retain the token.
"""
id: IntegrationId
workspace_id: WorkspaceId | None
provider_tenant: ProviderTenantIdentity
encrypted_credentials: EncryptedCredentials
configured_by_account_id: AccountId | None
callback_url: str | None
config_version: int
status: IMIntegrationStatus
safe_status_reason: str | None
last_checked_at: UtcTimestamp | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if self.config_version < 1:
raise ValueError("config version must be positive")
@classmethod
def create(
cls,
*,
integration_id: IntegrationId,
workspace_id: WorkspaceId | None,
provider_tenant: ProviderTenantIdentity,
encrypted_credentials: EncryptedCredentials,
configured_by_account_id: AccountId | None,
callback_url: str | None,
now: UtcTimestamp,
) -> IMIntegration:
return cls(
id=integration_id,
workspace_id=workspace_id,
provider_tenant=provider_tenant,
encrypted_credentials=encrypted_credentials,
configured_by_account_id=configured_by_account_id,
callback_url=callback_url,
config_version=1,
status=IMIntegrationStatus.CONFIGURED,
safe_status_reason=None,
last_checked_at=None,
created_at=now,
updated_at=now,
)
@property
def revision(self) -> IntegrationRevisionToken:
return IntegrationRevisionToken(self.id, self.config_version)
def reconfigure(
self,
*,
expected_revision: IntegrationRevisionToken,
provider_tenant: ProviderTenantIdentity,
encrypted_credentials: EncryptedCredentials,
configured_by_account_id: AccountId | None,
callback_url: str | None,
now: UtcTimestamp,
replacement_integration_id: IntegrationId | None = None,
) -> ConfigurationTransition | StaleRevision:
"""Plan a confirmed rotation or replacement without performing I/O."""
if expected_revision != self.revision:
return StaleRevision(expected_revision, self.revision)
if provider_tenant == self.provider_tenant:
if replacement_integration_id not in (None, self.id):
raise ValueError("credential rotation must preserve integration identity")
updated = replace(
self,
encrypted_credentials=encrypted_credentials,
configured_by_account_id=configured_by_account_id,
callback_url=callback_url,
config_version=self.config_version + 1,
status=IMIntegrationStatus.CONFIGURED,
safe_status_reason=None,
last_checked_at=None,
updated_at=now,
)
return ConfigurationTransition(
expected_revision=expected_revision,
kind=ConfigurationTransitionKind.CREDENTIAL_ROTATION,
integration=updated,
invalidation=CurrentStateInvalidation(False, False),
)
if replacement_integration_id is None or replacement_integration_id == self.id:
raise ValueError("provider replacement requires a new integration identity")
replacement = IMIntegration.create(
integration_id=replacement_integration_id,
workspace_id=self.workspace_id,
provider_tenant=provider_tenant,
encrypted_credentials=encrypted_credentials,
configured_by_account_id=configured_by_account_id,
callback_url=callback_url,
now=now,
)
return ConfigurationTransition(
expected_revision=expected_revision,
kind=ConfigurationTransitionKind.PROVIDER_REPLACEMENT,
integration=replacement,
invalidation=CurrentStateInvalidation(True, True),
)
def plan_deletion(self, expected_revision: IntegrationRevisionToken) -> IntegrationDeletion | StaleRevision:
"""Return deletion cleanup only when the complete token is current."""
if expected_revision != self.revision:
return StaleRevision(expected_revision, self.revision)
return IntegrationDeletion(expected_revision)
def record_diagnostics(
self,
*,
status: IMIntegrationStatus,
safe_status_reason: str | None,
checked_at: UtcTimestamp,
) -> IMIntegration:
"""Update connection diagnostics without advancing configuration."""
return replace(
self,
status=status,
safe_status_reason=safe_status_reason,
last_checked_at=checked_at,
updated_at=checked_at,
)
@@ -0,0 +1,123 @@
"""Transaction-oriented persistence ports for IM Control Plane invariants.
Implementations own CAS predicates, Integration row locks, eager loading,
revision-guarded apply, rollback, and append-only result persistence. Generic
table CRUD is intentionally absent.
"""
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from core.human_input_v2.entities import IMProvider
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMSyncRunId,
IntegrationId,
UtcTimestamp,
WorkspaceId,
)
from .binding_resolution import BindingResolutionResult
from .integration import (
ConfigurationTransition,
IMIntegration,
IntegrationDeletion,
IntegrationRevisionToken,
StaleRevision,
)
from .sync_reconciliation import IMSyncRun, ReconciliationPlan, ReconciliationSnapshot, SyncResultFact
class ActiveRunDecisionKind(StrEnum):
"""Outcome of Integration-locked sync run creation."""
CREATED = "created"
EXISTING_ACTIVE = "existing_active"
STALE_REVISION = "stale_revision"
@dataclass(frozen=True, slots=True)
class ActiveRunDecision:
"""New or existing active run, or a stable stale-revision rejection."""
kind: ActiveRunDecisionKind
run: IMSyncRun | None
stale_revision: StaleRevision | None = None
class ApplyReconciliationStatus(StrEnum):
"""Stable outcome of one idempotent revision-guarded apply."""
APPLIED = "applied"
ALREADY_APPLIED = "already_applied"
STALE_REVISION = "stale_revision"
@dataclass(frozen=True, slots=True)
class ApplyReconciliationResult:
"""Run and append-only facts returned after reconciliation apply."""
status: ApplyReconciliationStatus
run: IMSyncRun
results: tuple[SyncResultFact, ...]
class IMControlPlaneRepository(Protocol):
"""Atomic persistence capabilities required by the IM domain."""
def create_integration(self, integration: IMIntegration) -> IMIntegration:
"""Create the first integration configuration for its owner scope."""
...
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
"""Atomically apply rotation or replacement and its invalidation plan."""
...
def compare_and_swap_delete(self, deletion: IntegrationDeletion) -> None | StaleRevision:
"""Delete current configuration and current children under complete CAS."""
...
def create_or_get_active_run(
self,
integration_revision: IntegrationRevisionToken,
*,
sync_run_id: IMSyncRunId,
started_by_account_id: AccountId | None,
now: UtcTimestamp,
) -> ActiveRunDecision:
"""Lock Integration and return at most one active run."""
...
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
"""Load current identities, bindings, and eligible Contact facts."""
...
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
"""Apply one plan using its persisted sync run capture as CAS authority."""
...
def resolve_effective_binding(
self,
*,
integration_id: IntegrationId,
provider: IMProvider,
workspace_id: WorkspaceId,
contact_id: ContactId,
) -> BindingResolutionResult:
"""Load and resolve one credential-free effective binding snapshot."""
...
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
"""Append diagnostic result facts without changing current state."""
...
__all__ = [
"ActiveRunDecision",
"ActiveRunDecisionKind",
"ApplyReconciliationResult",
"ApplyReconciliationStatus",
"IMControlPlaneRepository",
]
@@ -0,0 +1,145 @@
"""Current IM identity and binding values shared by sync and resolution."""
from __future__ import annotations
import json
from collections.abc import Mapping
from dataclasses import dataclass
from pydantic import JsonValue
from core.human_input_v2.entities import IMBindingScope, IMProvider
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
)
@dataclass(frozen=True, slots=True)
class OpaqueProviderPayload:
"""Immutable provider JSON retained only for persistence diagnostics."""
_serialized: str
@classmethod
def from_mapping(cls, values: Mapping[str, JsonValue]) -> OpaqueProviderPayload:
return cls(json.dumps(dict(values), sort_keys=True, separators=(",", ":")))
def to_mapping(self) -> dict[str, JsonValue]:
value = json.loads(self._serialized)
if not isinstance(value, dict):
raise ValueError("provider payload must be a JSON object")
return value
@dataclass(frozen=True, slots=True)
class IMIdentity:
"""Current provider identity independent from ORM lifetime and raw clients."""
id: IMIdentityId
integration_id: IntegrationId
provider: IMProvider
provider_user_id: str
display_name: str | None
normalized_name: str | None
email: str | None
normalized_email: NormalizedEmail | None
raw_payload: OpaqueProviderPayload
last_seen_sync_run_id: IMSyncRunId | None
last_seen_at: UtcTimestamp | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not self.provider_user_id.strip():
raise ValueError("provider user id must not be blank")
@classmethod
def create(
cls,
*,
identity_id: IMIdentityId,
integration_id: IntegrationId,
provider: IMProvider,
provider_user_id: str,
display_name: str | None,
email: str | None,
raw_payload: Mapping[str, JsonValue],
last_seen_sync_run_id: IMSyncRunId | None,
last_seen_at: UtcTimestamp | None,
now: UtcTimestamp,
created_at: UtcTimestamp | None = None,
) -> IMIdentity:
clean_name = display_name.strip() if display_name is not None else None
clean_email = email.strip() if email is not None else None
return cls(
id=identity_id,
integration_id=integration_id,
provider=provider,
provider_user_id=provider_user_id.strip(),
display_name=clean_name,
normalized_name=clean_name.casefold() if clean_name else None,
email=clean_email,
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
last_seen_sync_run_id=last_seen_sync_run_id,
last_seen_at=last_seen_at,
created_at=created_at or now,
updated_at=now,
)
@dataclass(frozen=True, slots=True)
class IMBinding:
"""Current Contact-to-provider identity association in one resolution scope."""
id: IMBindingId
integration_id: IntegrationId
scope: IMBindingScope
scope_id: str
contact_id: ContactId
identity_id: IMIdentityId
provider: IMProvider
bound_by_account_id: AccountId | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
def __post_init__(self) -> None:
if not self.scope_id.strip():
raise ValueError("binding scope id must not be blank")
if self.scope is IMBindingScope.ORGANIZATION and self.scope_id != str(self.integration_id):
raise ValueError("organization binding scope must be its integration")
@classmethod
def create(
cls,
*,
binding_id: IMBindingId,
integration_id: IntegrationId,
scope: IMBindingScope,
scope_id: str,
contact_id: ContactId,
identity_id: IMIdentityId,
provider: IMProvider,
bound_by_account_id: AccountId | None,
now: UtcTimestamp,
created_at: UtcTimestamp | None = None,
) -> IMBinding:
return cls(
id=binding_id,
integration_id=integration_id,
scope=scope,
scope_id=scope_id,
contact_id=contact_id,
identity_id=identity_id,
provider=provider,
bound_by_account_id=bound_by_account_id,
created_at=created_at or now,
updated_at=now,
)
@@ -0,0 +1,18 @@
"""Explicit aggregate-load snapshot spanning IM persistence records."""
from dataclasses import dataclass
from .integration import IMIntegration
from .records import IMBinding, IMIdentity
from .sync_reconciliation import IMSyncRun, SyncResultFact
@dataclass(frozen=True, slots=True)
class IMIntegrationState:
"""Eagerly loaded Integration with mapped current and historical children."""
integration: IMIntegration
identities: tuple[IMIdentity, ...]
bindings: tuple[IMBinding, ...]
sync_runs: tuple[IMSyncRun, ...]
sync_results: tuple[SyncResultFact, ...]
@@ -0,0 +1,276 @@
"""Pure provider directory matching and immutable reconciliation plans."""
from __future__ import annotations
from dataclasses import dataclass, replace
from enum import StrEnum
from pydantic import JsonValue
from core.human_input_v2.contact_directory import ContactIdentitySource, ContactSnapshot
from core.human_input_v2.entities import IMProvider, IMSyncRemovalReason, IMSyncResultType, IMSyncRunStatus
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
)
from .integration import IntegrationRevisionToken
from .records import IMBinding, IMIdentity, OpaqueProviderPayload
class MatchKind(StrEnum):
"""Stable explanation for how one provider entry was classified."""
PROVIDER_USER_ID = "provider_user_id"
NORMALIZED_EMAIL = "normalized_email"
UNMATCHED = "unmatched"
@dataclass(frozen=True, slots=True)
class ProviderDirectoryEntry:
"""Provider-neutral directory values consumed by the pure reconciler."""
provider_user_id: str
display_name: str | None
email: str | None
normalized_email: NormalizedEmail | None
raw_payload: OpaqueProviderPayload
@classmethod
def create(
cls,
*,
provider_user_id: str,
display_name: str | None,
email: str | None,
raw_payload: dict[str, JsonValue],
) -> ProviderDirectoryEntry:
clean_email = email.strip() if email is not None else None
return cls(
provider_user_id=provider_user_id.strip(),
display_name=display_name.strip() if display_name is not None else None,
email=clean_email,
normalized_email=NormalizedEmail(clean_email) if clean_email else None,
raw_payload=OpaqueProviderPayload.from_mapping(raw_payload),
)
@dataclass(frozen=True, slots=True)
class ReconciliationSnapshot:
"""Coherent current facts loaded before provider entries are matched."""
identities: tuple[IMIdentity, ...] = ()
bindings: tuple[IMBinding, ...] = ()
contacts: tuple[ContactSnapshot, ...] = ()
@dataclass(frozen=True, slots=True)
class ReconciliationAction:
"""One provider entry match without persistence side effects."""
entry: ProviderDirectoryEntry
match_kind: MatchKind
identity_id: IMIdentityId | None
binding_id: IMBindingId | None
contact_id: ContactId | None
@dataclass(frozen=True, slots=True)
class ReconciliationPlan:
"""Immutable plan whose captured revision must be checked again at apply."""
sync_run_id: IMSyncRunId
integration_revision: IntegrationRevisionToken
provider: IMProvider
actions: tuple[ReconciliationAction, ...]
removed_identity_ids: tuple[IMIdentityId, ...]
@dataclass(frozen=True, slots=True)
class SyncResultFact:
"""Append-only outcome for one action, removed binding, or diagnostic.
Removing an identity emits one fact per removed binding so every scope
override remains auditable. An identity without bindings emits one fact
whose binding and Contact fields are absent.
"""
id: IMSyncResultId
integration_id: IntegrationId
sync_run_id: IMSyncRunId
result_type: IMSyncResultType
provider_user_id: str | None
display_name: str | None
email: str | None
normalized_email: NormalizedEmail | None
contact_id: ContactId | None
identity_id: IMIdentityId | None
binding_id: IMBindingId | None
removal_reason: IMSyncRemovalReason | None
reason_code: str | None
reason_message: str | None
directory_entry_payload: OpaqueProviderPayload | None
contact_snapshot: SyncContactSnapshot | None
identity_snapshot: SyncIdentitySnapshot | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
@dataclass(frozen=True, slots=True)
class SyncContactSnapshot:
"""Immutable Contact display values retained by a historical result."""
contact_id: ContactId
name: str
email: str | None
avatar_file_id: str | None
@dataclass(frozen=True, slots=True)
class SyncIdentitySnapshot:
"""Immutable last-known provider identity retained after current deletion."""
identity_id: IMIdentityId
provider: IMProvider
provider_user_id: str
display_name: str | None
email: str | None
@dataclass(frozen=True, slots=True)
class IMSyncRun:
"""Independent sync aggregate that captures one complete Integration token."""
id: IMSyncRunId
integration_revision: IntegrationRevisionToken
provider: IMProvider
status: IMSyncRunStatus
added_count: int
not_matched_count: int
failed_count: int
removed_count: int
skipped_count: int
started_by_account_id: AccountId | None
started_at: UtcTimestamp | None
finished_at: UtcTimestamp | None
error_code: str | None
error_message: str | None
created_at: UtcTimestamp
updated_at: UtcTimestamp
@classmethod
def create(
cls,
*,
sync_run_id: IMSyncRunId,
integration_revision: IntegrationRevisionToken,
provider: IMProvider,
started_by_account_id: AccountId | None,
now: UtcTimestamp,
) -> IMSyncRun:
return cls(
id=sync_run_id,
integration_revision=integration_revision,
provider=provider,
status=IMSyncRunStatus.QUEUED,
added_count=0,
not_matched_count=0,
failed_count=0,
removed_count=0,
skipped_count=0,
started_by_account_id=started_by_account_id,
started_at=None,
finished_at=None,
error_code=None,
error_message=None,
created_at=now,
updated_at=now,
)
@property
def is_active(self) -> bool:
return self.status in (IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)
def start(self, now: UtcTimestamp) -> IMSyncRun:
if self.status is not IMSyncRunStatus.QUEUED:
return self
return replace(self, status=IMSyncRunStatus.RUNNING, started_at=now, updated_at=now)
class SyncReconciler:
"""Stateless matching policy with no provider or persistence dependencies.
Email fallback accepts available account-backed Contacts: EE Organization
Accounts and CE/SaaS workspace members. External Contacts never participate.
"""
@staticmethod
def reconcile(
*,
sync_run_id: IMSyncRunId,
integration_revision: IntegrationRevisionToken,
provider: IMProvider,
entries: tuple[ProviderDirectoryEntry, ...],
snapshot: ReconciliationSnapshot,
) -> ReconciliationPlan:
identities = {
identity.provider_user_id: identity
for identity in snapshot.identities
if identity.integration_id == integration_revision.integration_id and identity.provider is provider
}
bindings_by_identity: dict[IMIdentityId, IMBinding] = {}
for binding in sorted(snapshot.bindings, key=lambda item: item.scope.value, reverse=True):
bindings_by_identity.setdefault(binding.identity_id, binding)
contacts_by_email = {
item.contact.normalized_email: item.contact
for item in snapshot.contacts
if item.account_available
and item.contact.identity_source
in (ContactIdentitySource.ORGANIZATION_ACCOUNT, ContactIdentitySource.WORKSPACE_MEMBER)
and item.contact.normalized_email is not None
}
actions: list[ReconciliationAction] = []
seen_provider_user_ids: set[str] = set()
for entry in entries:
seen_provider_user_ids.add(entry.provider_user_id)
identity = identities.get(entry.provider_user_id)
if identity is not None:
matched_binding = bindings_by_identity.get(identity.id)
actions.append(
ReconciliationAction(
entry=entry,
match_kind=MatchKind.PROVIDER_USER_ID,
identity_id=identity.id,
binding_id=matched_binding.id if matched_binding is not None else None,
contact_id=matched_binding.contact_id if matched_binding is not None else None,
)
)
continue
contact = contacts_by_email.get(entry.normalized_email) if entry.normalized_email is not None else None
actions.append(
ReconciliationAction(
entry=entry,
match_kind=MatchKind.NORMALIZED_EMAIL if contact is not None else MatchKind.UNMATCHED,
identity_id=None,
binding_id=None,
contact_id=contact.id if contact is not None else None,
)
)
removed = tuple(
identity.id
for identity in snapshot.identities
if identity.integration_id == integration_revision.integration_id
and identity.provider is provider
and identity.provider_user_id not in seen_provider_user_ids
)
return ReconciliationPlan(sync_run_id, integration_revision, provider, tuple(actions), removed)
@@ -0,0 +1,63 @@
"""Stable Human Input v2 values shared across domain contexts.
This package contains infrastructure-free values only. Feature-specific ownership
and lifecycle rules belong to their bounded context rather than this package.
"""
from .values import (
AccountId,
AppId,
ApproverGrantId,
AuditEventId,
ContactId,
DeliveryAttemptId,
DeliveryEndpointId,
DeploymentScope,
DirectoryScope,
EmailProviderId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
OTPChallengeId,
PlatformEntryId,
SubmissionId,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
WorkspaceScope,
)
__all__ = [
"AccountId",
"AppId",
"ApproverGrantId",
"AuditEventId",
"ContactId",
"DeliveryAttemptId",
"DeliveryEndpointId",
"DeploymentScope",
"DirectoryScope",
"EmailProviderId",
"EndUserId",
"FormId",
"IMBindingId",
"IMIdentityId",
"IMSyncResultId",
"IMSyncRunId",
"IntegrationId",
"NormalizedEmail",
"OTPChallengeId",
"PlatformEntryId",
"SubmissionId",
"UploadCapabilityId",
"UploadFileAssociationId",
"UtcTimestamp",
"WorkspaceId",
"WorkspaceScope",
]
+173
View File
@@ -0,0 +1,173 @@
"""Primitive-independent identifiers, scopes, email, and time values."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import override
@dataclass(frozen=True, slots=True)
class _Identifier:
"""Non-empty string identifier with explicit primitive serialization."""
value: str
def __post_init__(self) -> None:
if not isinstance(self.value, str) or not self.value.strip():
raise ValueError(f"{type(self).__name__} must not be blank")
object.__setattr__(self, "value", self.value.strip())
@override
def __str__(self) -> str:
return self.value
def to_primitive(self) -> str:
return self.value
class AccountId(_Identifier):
"""Identifier of an Account record."""
class ContactId(_Identifier):
"""Identifier of a canonical Contact."""
class EndUserId(_Identifier):
"""Identifier of one app-scoped EndUser identity."""
class PlatformEntryId(_Identifier):
"""Identifier of one Platform allow-list entry."""
class WorkspaceId(_Identifier):
"""Identifier of the workspace that owns or resolves a Contact."""
class AppId(_Identifier):
"""Identifier of the application that owns a Human Input form."""
class FormId(_Identifier):
"""Identifier of one Human Input v2 form root."""
class ApproverGrantId(_Identifier):
"""Identifier of one form-scoped approver grant."""
class OTPChallengeId(_Identifier):
"""Identifier of one grant-scoped OTP proof session."""
class DeliveryEndpointId(_Identifier):
"""Identifier of one frozen form delivery endpoint."""
class DeliveryAttemptId(_Identifier):
"""Identifier of one append-only delivery attempt."""
class SubmissionId(_Identifier):
"""Identifier of one immutable winning form submission."""
class AuditEventId(_Identifier):
"""Identifier of one append-only Human Input audit event."""
class EmailProviderId(_Identifier):
"""Identifier of one workspace email provider configuration."""
class UploadCapabilityId(_Identifier):
"""Identifier of one endpoint-scoped upload capability."""
class UploadFileAssociationId(_Identifier):
"""Identifier of one durable uploaded-file association."""
class IntegrationId(_Identifier):
"""Identifier of one IM Integration configuration identity."""
class IMIdentityId(_Identifier):
"""Identifier of one current synchronized provider identity."""
class IMBindingId(_Identifier):
"""Identifier of one current Contact-to-IM-identity binding."""
class IMSyncRunId(_Identifier):
"""Identifier of one IM directory synchronization run."""
class IMSyncResultId(_Identifier):
"""Identifier of one append-only synchronization result fact."""
@dataclass(frozen=True, slots=True)
class NormalizedEmail:
"""Case-insensitive canonical email used for identity comparisons."""
value: str
def __post_init__(self) -> None:
if not isinstance(self.value, str):
raise ValueError("value must be a valid email")
normalized = self.value.strip().casefold()
local, separator, domain = normalized.partition("@")
if not separator or not local or not domain or " " in normalized or "@" in domain:
raise ValueError("value must be a valid email")
object.__setattr__(self, "value", normalized)
@override
def __str__(self) -> str:
return self.value
def to_primitive(self) -> str:
return self.value
@dataclass(frozen=True, slots=True)
class DeploymentScope:
"""Deployment-wide owner scope used by EE Organization contacts."""
def to_primitive(self) -> dict[str, str]:
return {"kind": "deployment"}
@dataclass(frozen=True, slots=True)
class WorkspaceScope:
"""Owner scope for workspace-owned contacts and directory operations."""
workspace_id: WorkspaceId
def to_primitive(self) -> dict[str, str]:
return {"kind": "workspace", "workspace_id": self.workspace_id.to_primitive()}
type DirectoryScope = DeploymentScope | WorkspaceScope
@dataclass(frozen=True, slots=True)
class UtcTimestamp:
"""Timezone-aware timestamp normalized to UTC at construction."""
value: datetime
def __post_init__(self) -> None:
if not isinstance(self.value, datetime) or self.value.tzinfo is None or self.value.utcoffset() is None:
raise ValueError("value must be a timezone-aware datetime")
object.__setattr__(self, "value", self.value.astimezone(UTC))
@classmethod
def now(cls) -> UtcTimestamp:
return cls(datetime.now(UTC))
def to_primitive(self) -> str:
return self.value.isoformat().replace("+00:00", "Z")
@@ -14,6 +14,10 @@ from core.workflow.human_input_adapter import (
EmailDeliveryMethod,
EmailRecipients,
ExternalRecipient,
InstantMessageChannelRecipient,
InstantMessageDeliveryConfig,
InstantMessageDeliveryMethod,
InstantMessageUserRecipient,
InteractiveSurfaceDeliveryMethod,
is_human_input_webapp_enabled,
)
@@ -32,6 +36,7 @@ from models.human_input import (
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
InstantMessageRecipientPayload,
RecipientType,
StandaloneWebAppRecipientPayload,
)
@@ -323,9 +328,48 @@ class HumanInputFormRepositoryImpl:
recipients_config=email_recipients_config,
)
)
case InstantMessageDeliveryMethod():
recipients.extend(
self._build_instant_message_recipients(
form_id=form_id,
delivery_id=delivery_id,
config=delivery_method.config,
)
)
return _DeliveryAndRecipients(delivery=delivery_model, recipients=recipients)
@staticmethod
def _build_instant_message_recipients(
*,
form_id: str,
delivery_id: str,
config: InstantMessageDeliveryConfig,
) -> list[HumanInputFormRecipient]:
recipient_models: list[HumanInputFormRecipient] = []
for recipient in config.recipients.items:
match recipient:
case InstantMessageChannelRecipient():
payload = InstantMessageRecipientPayload(
provider=config.provider,
recipient_kind=recipient.type,
channel_id=recipient.channel_id,
)
case InstantMessageUserRecipient():
payload = InstantMessageRecipientPayload(
provider=config.provider,
recipient_kind=recipient.type,
user_id=recipient.user_id,
)
recipient_models.append(
HumanInputFormRecipient.new(
form_id=form_id,
delivery_id=delivery_id,
payload=payload,
)
)
return recipient_models
def _build_email_recipients(
self,
session: Session,
+80 -4
View File
@@ -5,6 +5,8 @@ Dify-owned field spellings and value shapes. Adapt them here before handing the
payload to Graphon so Graphon-owned models only see current contracts.
"""
# TODO(QuantumGhost): This should be migrated back to human_input.HumanInputNodeData.
from __future__ import annotations
import enum
@@ -26,6 +28,7 @@ from graphon.variables.consts import SELECTORS_LENGTH
class DeliveryMethodType(enum.StrEnum):
WEBAPP = enum.auto()
EMAIL = enum.auto()
IM = enum.auto()
class EmailRecipientType(enum.StrEnum):
@@ -34,6 +37,17 @@ class EmailRecipientType(enum.StrEnum):
EXTERNAL = "external"
class InstantMessageProvider(enum.StrEnum):
SLACK = "slack"
TEAMS = "teams"
DISCORD = "discord"
class InstantMessageRecipientType(enum.StrEnum):
CHANNEL = "channel"
USER = "user"
class _InteractiveSurfaceDeliveryConfig(BaseModel):
pass
@@ -56,6 +70,25 @@ MemberRecipient = BoundRecipient
EmailRecipient = Annotated[BoundRecipient | ExternalRecipient, Field(discriminator="type")]
class InstantMessageChannelRecipient(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal[InstantMessageRecipientType.CHANNEL] = InstantMessageRecipientType.CHANNEL
channel_id: str
class InstantMessageUserRecipient(BaseModel):
model_config = ConfigDict(extra="forbid")
type: Literal[InstantMessageRecipientType.USER] = InstantMessageRecipientType.USER
user_id: str
InstantMessageRecipient = Annotated[
InstantMessageChannelRecipient | InstantMessageUserRecipient, Field(discriminator="type")
]
class EmailRecipients(BaseModel):
model_config = ConfigDict(extra="forbid")
@@ -66,6 +99,12 @@ class EmailRecipients(BaseModel):
items: list[EmailRecipient] = Field(default_factory=list)
class InstantMessageRecipients(BaseModel):
model_config = ConfigDict(extra="forbid")
items: list[InstantMessageRecipient] = Field(default_factory=list)
class EmailDeliveryConfig(BaseModel):
URL_PLACEHOLDER: ClassVar[str] = "{{#url#}}"
_ALLOWED_HTML_TAGS: ClassVar[list[str]] = [
@@ -141,6 +180,14 @@ class EmailDeliveryConfig(BaseModel):
return " ".join(sanitized.split())
class InstantMessageDeliveryConfig(BaseModel):
model_config = ConfigDict(extra="forbid")
provider: InstantMessageProvider
recipients: InstantMessageRecipients = Field(default_factory=InstantMessageRecipients)
message: str | None = None
class _DeliveryMethodBase(BaseModel):
enabled: bool = True
id: uuid.UUID = Field(default_factory=uuid.uuid4)
@@ -170,10 +217,31 @@ class EmailDeliveryMethod(_DeliveryMethodBase):
return selectors
class InstantMessageDeliveryMethod(_DeliveryMethodBase):
type: Literal[DeliveryMethodType.IM] = DeliveryMethodType.IM
config: InstantMessageDeliveryConfig
@override
def extract_variable_selectors(self) -> Sequence[Sequence[str]]:
if not self.config.message:
return ()
variable_template_parser = VariableTemplateParser(template=self.config.message)
selectors: list[Sequence[str]] = []
for variable_selector in variable_template_parser.extract_variable_selectors():
value_selector = list(variable_selector.value_selector)
if len(value_selector) < SELECTORS_LENGTH:
continue
selectors.append(value_selector[:SELECTORS_LENGTH])
return selectors
WebAppDeliveryMethod = InteractiveSurfaceDeliveryMethod
_WebAppDeliveryConfig = _InteractiveSurfaceDeliveryConfig
DeliveryChannelConfig = Annotated[InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod, Field(discriminator="type")]
DeliveryChannelConfig = Annotated[
InteractiveSurfaceDeliveryMethod | EmailDeliveryMethod | InstantMessageDeliveryMethod,
Field(discriminator="type"),
]
_DELIVERY_METHODS_ADAPTER = TypeAdapter(list[DeliveryChannelConfig])
@@ -204,9 +272,10 @@ def adapt_human_input_node_data_for_graph(node_data: Mapping[str, Any] | BaseMod
config_mapping = _copy_mapping(method_mapping.get("config"))
if config_mapping is not None:
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
if recipients_mapping is not None:
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
if method_mapping.get("type") == DeliveryMethodType.EMAIL:
recipients_mapping = _copy_mapping(config_mapping.get("recipients"))
if recipients_mapping is not None:
config_mapping["recipients"] = _normalize_email_recipients(recipients_mapping)
method_mapping["config"] = config_mapping
normalized_methods.append(method_mapping)
@@ -377,6 +446,13 @@ __all__ = [
"EmailRecipientType",
"EmailRecipients",
"ExternalRecipient",
"InstantMessageChannelRecipient",
"InstantMessageDeliveryConfig",
"InstantMessageDeliveryMethod",
"InstantMessageProvider",
"InstantMessageRecipientType",
"InstantMessageRecipients",
"InstantMessageUserRecipient",
"MemberRecipient",
"WebAppDeliveryMethod",
"_WebAppDeliveryConfig",
@@ -13,6 +13,7 @@ from typing import Annotated, Any, Literal, Self, assert_never, override
from pydantic import BaseModel, Field, NonNegativeInt, field_validator, model_validator
from core.workflow.human_input_adapter import DeliveryChannelConfig
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
from graphon.file.enums import FileTransferMethod, FileType
@@ -372,3 +373,10 @@ def validate_human_input_submission(
missing_list = ", ".join(missing_inputs)
msg = f"Missing required inputs: {missing_list}"
raise HumanInputSubmissionValidationError(msg)
class HumanInputNodeDataFull(HumanInputNodeData):
# This model is the full definition of HumanInputNodeData.
#
# The model above lacks some fields due to migration between Graphon and Dify. This model add them back.
delivery_methods: list[DeliveryChannelConfig] = Field(default_factory=list[DeliveryChannelConfig])
@@ -0,0 +1,91 @@
import enum
from collections.abc import Sequence
from typing import Annotated, Final, Literal
from pydantic import AfterValidator, BaseModel, ConfigDict, Discriminator, Field
from core.human_input_v2.entities import IMProvider
from core.workflow.nodes.human_input.entities import FormInputConfig, TimeoutUnit, UserActionConfig
from graphon.entities.base_node_data import BaseNodeData
from graphon.enums import BuiltinNodeTypes, NodeType
class RecipientType(enum.StrEnum):
CONTACT = enum.auto()
DYNAMIC_EMAIL = enum.auto()
ONETIME_EMAIL = enum.auto()
INITIATOR = enum.auto()
class Contact(BaseModel):
type: Literal[RecipientType.CONTACT] = RecipientType.CONTACT
contact_id: str
class DynamicEmail(BaseModel):
type: Literal[RecipientType.DYNAMIC_EMAIL] = RecipientType.DYNAMIC_EMAIL
selector: Sequence[str]
class OnetimeEmail(BaseModel):
type: Literal[RecipientType.ONETIME_EMAIL] = RecipientType.ONETIME_EMAIL
email: str
class Initiator(BaseModel):
type: Literal[RecipientType.INITIATOR] = RecipientType.INITIATOR
RecipientConfig = Annotated[Contact | DynamicEmail | OnetimeEmail | Initiator, Discriminator("type")]
class MessageTemplateConfig(BaseModel):
subject: str
body: str
class Channel(enum.StrEnum):
EMAIL = enum.auto()
FEISHU = IMProvider.FEISHU.value
SLACK = IMProvider.SLACK.value
DING_TALK = IMProvider.DING_TALK.value
MS_TEAMS = IMProvider.MS_TEAMS.value
WE_COM = IMProvider.WE_COM.value
LARK = IMProvider.LARK.value
class DebugModeConfig(BaseModel):
enabled: bool = False
channels: Sequence[Channel]
HUMAN_INPUT_V2_VERSION: Final = "2"
def _version_validator(version: str) -> str:
if version != HUMAN_INPUT_V2_VERSION:
raise ValueError(f"Human Input v2 requires version='{HUMAN_INPUT_V2_VERSION}'")
return version
class HumanInputNodeData(BaseNodeData):
"""Human Input node data."""
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
type: NodeType = BuiltinNodeTypes.HUMAN_INPUT
version: Annotated[str, AfterValidator(_version_validator)] = HUMAN_INPUT_V2_VERSION
recipients_spec: list[RecipientConfig]
message_template: MessageTemplateConfig
debug_mode: DebugModeConfig
form_content: str = ""
inputs: list[FormInputConfig] = Field(default_factory=list[FormInputConfig])
user_actions: list[UserActionConfig] = Field(default_factory=list[UserActionConfig])
timeout: int = 36
timeout_unit: TimeoutUnit = TimeoutUnit.HOUR
+12
View File
@@ -0,0 +1,12 @@
from pydantic import Field, PositiveInt
class PaginationParamsMixin:
page: PositiveInt = Field(1, description="1-based page number.")
limit: PositiveInt = Field(default=20, le=100, description="Maximum number of records returned per page.")
class PaginationResultMixin:
limit: int = Field(description="Page size used for the current query.")
total: int = Field(description="Total number of candidates matching the current query.")
page: int = Field(description="Current 1-based page number.")
+5
View File
@@ -0,0 +1,5 @@
from typing import Annotated
from pydantic import Field
Timestamp = Annotated[int, Field(..., description="Unix timestamp in milliseconds")]
@@ -0,0 +1,141 @@
"""add human input v2 contact directory
Revision ID: 5c8f1a2b3d4e
Revises: d2825e7b9c10
Create Date: 2026-07-25 10:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "5c8f1a2b3d4e"
down_revision = "d2825e7b9c10"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"human_input_contacts",
sa.Column("name", sa.String(length=255), nullable=False, comment="Display name shown in contact surfaces."),
sa.Column(
"normalized_name",
sa.String(length=255),
nullable=False,
comment="Lower-cased search value maintained by the application.",
),
sa.Column(
"identity_source",
sa.String(length=20),
nullable=False,
comment="Immutable identity source that determines the Contact lifecycle owner.",
),
sa.Column(
"tenant_id",
models.types.StringUUID(),
nullable=True,
comment=(
"Ownership boundary: null only for EE Organization contacts; otherwise the owning tenants.id for "
"workspace-owned contacts. CE and SaaS must never persist a null value."
),
),
sa.Column(
"account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to accounts.id for an account-backed contact.",
),
sa.Column(
"email",
sa.String(length=320),
nullable=True,
comment="Current deliverable email address, when available.",
),
sa.Column(
"normalized_email",
sa.String(length=320),
nullable=True,
comment="Full lower-cased email used for equality matching.",
),
sa.Column(
"avatar_file_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to upload_files.id for an external contact avatar.",
),
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="human_input_contacts_pkey"),
sa.UniqueConstraint("tenant_id", "account_id", name="human_input_contacts_tenant_account_uq"),
sa.UniqueConstraint("tenant_id", "normalized_email", name="human_input_contacts_tenant_email_uq"),
sa.CheckConstraint(
"(identity_source = 'organization_account' AND tenant_id IS NULL AND account_id IS NOT NULL) OR "
"(identity_source = 'workspace_member' AND tenant_id IS NOT NULL AND account_id IS NOT NULL) OR "
"(identity_source = 'external' AND tenant_id IS NOT NULL AND account_id IS NULL)",
name="identity_owner",
),
sa.CheckConstraint(
"identity_source <> 'external' OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
name="external_email",
),
sa.CheckConstraint(
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
name="email_normalization_pair",
),
comment=(
"Canonical Human Input contact identities. EE Organization Account contacts have tenant_id IS NULL; "
"workspace-owned contacts have tenant_id = tenants.id; CE and SaaS must not create contacts with "
"tenant_id IS NULL."
),
)
op.create_index(
"human_input_contacts_tenant_normalized_name_idx",
"human_input_contacts",
["tenant_id", "normalized_name"],
)
op.create_table(
"human_input_platform_contact_workspace_entries",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column(
"contact_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_contacts.id.",
),
sa.Column(
"added_by_account_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to accounts.id for the administrator who added this directory entry.",
),
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="human_input_platform_contact_workspace_entries_pkey"),
sa.UniqueConstraint("tenant_id", "contact_id", name="hipcwe_tenant_contact_uq"),
comment=(
"EE-only workspace allow-list for Organization Account contacts. Workspace membership and External "
"contact ownership must not create rows in this table."
),
)
op.create_index(
"hipcwe_tenant_created_at_id_idx",
"human_input_platform_contact_workspace_entries",
["tenant_id", "created_at", "id"],
)
op.create_index(
"hipcwe_contact_id_idx",
"human_input_platform_contact_workspace_entries",
["contact_id"],
)
def downgrade() -> None:
op.drop_table("human_input_platform_contact_workspace_entries")
op.drop_table("human_input_contacts")
@@ -0,0 +1,320 @@
"""add human input v2 im control plane
Revision ID: 6d9f2b4c5e7a
Revises: 5c8f1a2b3d4e
Create Date: 2026-07-25 11:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "6d9f2b4c5e7a"
down_revision = "5c8f1a2b3d4e"
branch_labels = None
depends_on = None
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
return (
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
)
def upgrade() -> None:
op.create_table(
"human_input_im_integrations",
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured IM provider discriminator."),
sa.Column(
"encrypted_credentials",
models.types.LongText(),
nullable=False,
comment="Provider-specific encrypted credential model serialized as JSON text.",
),
sa.Column(
"tenant_id",
models.types.StringUUID(),
nullable=True,
comment="Logical tenants.id owner in CE/SaaS; null for an EE deployment-wide integration.",
),
sa.Column(
"provider_tenant_id",
sa.String(length=255),
nullable=False,
comment="Confirmed provider-side organization or workspace identity.",
),
sa.Column("status", sa.String(length=20), nullable=False, comment="Last connectivity diagnostic status."),
sa.Column(
"config_version",
sa.Integer(),
nullable=False,
comment="Monotonic configuration revision used with the integration ID for CAS.",
),
sa.Column(
"configured_by_account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical accounts.id for the latest configuration writer.",
),
sa.Column("callback_url", sa.String(length=1024), nullable=True, comment="Configured callback URL."),
sa.Column(
"safe_status_reason",
models.types.LongText(),
nullable=True,
comment="Operator-safe connection diagnostic.",
),
sa.Column("last_checked_at", sa.DateTime(), nullable=True, comment="Latest connectivity check timestamp."),
*_default_fields("human_input_im_integrations"),
sa.UniqueConstraint("tenant_id", name="human_input_im_integrations_tenant_uq"),
sa.CheckConstraint("config_version > 0", name="config_version_positive"),
comment="Organization-level Human Input IM integration configuration.",
)
op.create_table(
"human_input_im_identities",
sa.Column(
"integration_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_im_integrations.id owner.",
),
sa.Column("provider", sa.String(length=20), nullable=False, comment="Provider identity discriminator."),
sa.Column(
"provider_user_id", sa.String(length=255), nullable=False, comment="Provider user matching identity."
),
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Latest provider display name."),
sa.Column("normalized_name", sa.String(length=255), nullable=True, comment="Case-folded provider name."),
sa.Column("email", sa.String(length=320), nullable=True, comment="Latest provider email."),
sa.Column(
"normalized_email", sa.String(length=320), nullable=True, comment="Case-folded fallback matching email."
),
sa.Column(
"raw_payload",
models.types.LongText(),
nullable=False,
comment="Opaque provider payload serialized as JSON text for diagnostics.",
),
sa.Column(
"last_seen_sync_run_id",
models.types.StringUUID(),
nullable=True,
comment="Logical human_input_im_sync_runs.id that last observed this identity.",
),
sa.Column("last_seen_at", sa.DateTime(), nullable=True, comment="Timestamp last observed."),
*_default_fields("human_input_im_identities"),
sa.UniqueConstraint(
"integration_id",
"provider",
"provider_user_id",
name="human_input_im_identities_integration_provider_user_uq",
),
sa.CheckConstraint(
"(email IS NULL AND normalized_email IS NULL) OR (email IS NOT NULL AND normalized_email IS NOT NULL)",
name="email_normalization_pair",
),
comment="Current synchronized IM directory identities.",
)
op.create_index(
"hiimi_integration_provider_email_idx",
"human_input_im_identities",
["integration_id", "provider", "normalized_email"],
)
op.create_index(
"hiimi_integration_provider_name_idx",
"human_input_im_identities",
["integration_id", "provider", "normalized_name"],
)
op.create_index(
"hiimi_integration_last_seen_run_idx",
"human_input_im_identities",
["integration_id", "last_seen_sync_run_id"],
)
op.create_table(
"human_input_im_bindings",
sa.Column(
"integration_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_im_integrations.id owner.",
),
sa.Column("scope", sa.String(length=20), nullable=False, comment="Organization or workspace scope."),
sa.Column("scope_id", models.types.StringUUID(), nullable=False, comment="Scope owner identity."),
sa.Column("contact_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_contacts.id."),
sa.Column(
"im_identity_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_im_identities.id.",
),
sa.Column("provider", sa.String(length=20), nullable=False, comment="Denormalized provider discriminator."),
sa.Column(
"bound_by_account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical accounts.id for an administrative override.",
),
*_default_fields("human_input_im_bindings"),
sa.UniqueConstraint(
"scope",
"scope_id",
"contact_id",
"provider",
name="human_input_im_bindings_scope_contact_provider_uq",
),
sa.UniqueConstraint("scope", "scope_id", "im_identity_id", name="human_input_im_bindings_scope_identity_uq"),
sa.CheckConstraint(
"scope <> 'organization' OR scope_id = integration_id",
name="organization_scope_owner",
),
comment="Current organization binding or workspace override.",
)
op.create_index(
"hiimb_integration_contact_provider_scope_idx",
"human_input_im_bindings",
["integration_id", "contact_id", "provider", "scope", "scope_id"],
)
op.create_index(
"hiimb_identity_scope_idx",
"human_input_im_bindings",
["im_identity_id", "scope", "scope_id"],
)
op.create_table(
"human_input_im_sync_runs",
sa.Column(
"integration_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_im_integrations.id owner.",
),
sa.Column(
"integration_config_version",
sa.Integer(),
nullable=False,
comment="Captured integration configuration revision.",
),
sa.Column("provider", sa.String(length=20), nullable=False, comment="Captured provider discriminator."),
sa.Column("status", sa.String(length=20), nullable=False, comment="Synchronization lifecycle state."),
sa.Column("added_count", sa.Integer(), nullable=False, comment="Newly matched and bound entries."),
sa.Column("not_matched_count", sa.Integer(), nullable=False, comment="Unmatched entries."),
sa.Column("failed_count", sa.Integer(), nullable=False, comment="Failed entries."),
sa.Column(
"removed_count",
sa.Integer(),
nullable=False,
comment="Removed binding facts, including one unbound-identity fact when applicable.",
),
sa.Column("skipped_count", sa.Integer(), nullable=False, comment="Intentionally skipped entries."),
sa.Column(
"started_by_account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical accounts.id for the trigger actor.",
),
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Worker start timestamp."),
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
sa.Column("error_code", sa.String(length=100), nullable=True, comment="Machine-readable terminal error."),
sa.Column("error_message", models.types.LongText(), nullable=True, comment="Operator-safe terminal error."),
*_default_fields("human_input_im_sync_runs"),
sa.CheckConstraint("integration_config_version > 0", name="captured_version_positive"),
sa.CheckConstraint(
"added_count >= 0 AND not_matched_count >= 0 AND failed_count >= 0 AND removed_count >= 0 "
"AND skipped_count >= 0",
name="result_counts_nonnegative",
),
comment="Manual IM directory synchronization lifecycle and counts.",
)
op.create_index(
"hiimsr_integration_created_idx",
"human_input_im_sync_runs",
["integration_id", "created_at", "id"],
)
op.create_index(
"hiimsr_integration_status_created_idx",
"human_input_im_sync_runs",
["integration_id", "status", "created_at"],
)
op.create_table(
"human_input_im_sync_results",
sa.Column(
"integration_id",
models.types.StringUUID(),
nullable=False,
comment="Denormalized logical human_input_im_integrations.id.",
),
sa.Column(
"sync_run_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_im_sync_runs.id.",
),
sa.Column("result_type", sa.String(length=20), nullable=False, comment="Stable result bucket."),
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Observed provider user ID."),
sa.Column("display_name", sa.String(length=255), nullable=True, comment="Observed provider display name."),
sa.Column("email", sa.String(length=320), nullable=True, comment="Observed provider email."),
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized matching email."),
sa.Column(
"contact_id", models.types.StringUUID(), nullable=True, comment="Historical logical Contact identity."
),
sa.Column(
"im_identity_id",
models.types.StringUUID(),
nullable=True,
comment="Historical logical IM identity.",
),
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical logical IM binding."),
sa.Column("removal_reason", sa.String(length=32), nullable=True, comment="Stable removal reason."),
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Machine-readable diagnostic reason."),
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic."),
sa.Column(
"directory_entry_payload",
models.types.LongText(),
nullable=True,
comment="Immutable provider entry JSON observed by this run.",
),
sa.Column(
"contact_snapshot",
models.types.LongText(),
nullable=True,
comment="Immutable Contact display snapshot JSON.",
),
sa.Column(
"identity_snapshot",
models.types.LongText(),
nullable=True,
comment="Immutable removed identity snapshot JSON.",
),
*_default_fields("human_input_im_sync_results"),
comment="Append-only per-entry, removed-binding, and diagnostic IM synchronization outcomes.",
)
op.create_index(
"hiimsres_run_type_created_idx",
"human_input_im_sync_results",
["sync_run_id", "result_type", "created_at", "id"],
)
op.create_index(
"hiimsres_integration_contact_created_idx",
"human_input_im_sync_results",
["integration_id", "contact_id", "created_at"],
)
op.create_index(
"hiimsres_integration_identity_created_idx",
"human_input_im_sync_results",
["integration_id", "im_identity_id", "created_at"],
)
def downgrade() -> None:
op.drop_table("human_input_im_sync_results")
op.drop_table("human_input_im_sync_runs")
op.drop_table("human_input_im_bindings")
op.drop_table("human_input_im_identities")
op.drop_table("human_input_im_integrations")
@@ -0,0 +1,290 @@
"""add human input v2 form core
Revision ID: 8a1c4e7f9b2d
Revises: 6d9f2b4c5e7a
Create Date: 2026-07-25 12:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "8a1c4e7f9b2d"
down_revision = "6d9f2b4c5e7a"
branch_labels = None
depends_on = None
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
return (
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
)
def upgrade() -> None:
op.create_table(
"human_input_email_providers",
sa.Column("provider", sa.String(length=20), nullable=False, comment="Configured email provider discriminator."),
sa.Column("sender_email", sa.String(length=320), nullable=False, comment="Configured sender email address."),
sa.Column(
"encrypted_credentials",
models.types.LongText(),
nullable=False,
comment="Encrypted Resend credential Pydantic model serialized as JSON text.",
),
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("sender_name", sa.String(length=255), nullable=False, comment="Optional sender display name."),
sa.Column(
"configured_by_account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to accounts.id for the latest configuration write.",
),
*_default_fields("human_input_email_providers"),
sa.UniqueConstraint("tenant_id", name="human_input_email_providers_tenant_uq"),
comment="Workspace-level Human Input email delivery configuration.",
)
op.create_table(
"human_input_v2_forms",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
sa.Column(
"form_definition",
models.types.LongText(),
nullable=False,
comment="Frozen Human Input v2 form definition serialized as JSON text.",
),
sa.Column("rendered_content", models.types.LongText(), nullable=False, comment="Frozen rendered content."),
sa.Column("node_timeout_at", sa.DateTime(), nullable=False, comment="Frozen node-level timeout timestamp."),
sa.Column("global_expires_at", sa.DateTime(), nullable=False, comment="Frozen global expiration timestamp."),
sa.Column("form_kind", sa.String(length=20), nullable=False, comment="Human Input v2 form ownership kind."),
sa.Column("status", sa.String(length=20), nullable=False, comment="Current form lifecycle state."),
sa.Column(
"workflow_pause_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to workflow_pauses.id.",
),
sa.Column(
"node_execution_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to workflow_node_executions.id.",
),
*_default_fields("human_input_v2_forms"),
sa.UniqueConstraint("workflow_pause_id", name="hiv2_forms_workflow_pause_uq"),
sa.UniqueConstraint("node_execution_id", name="hiv2_forms_node_execution_uq"),
sa.CheckConstraint(
"form_kind <> 'runtime' OR (workflow_pause_id IS NOT NULL AND node_execution_id IS NOT NULL)",
name="runtime_owner",
),
comment="Independent Human Input v2 form roots bound only to shared workflow pause infrastructure.",
)
op.create_index(
"hiv2_forms_tenant_status_node_timeout_idx",
"human_input_v2_forms",
["tenant_id", "status", "node_timeout_at"],
)
op.create_index(
"hiv2_forms_tenant_status_global_expiry_idx",
"human_input_v2_forms",
["tenant_id", "status", "global_expires_at"],
)
op.create_table(
"human_input_v2_form_approver_grants",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column(
"form_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_forms.id.",
),
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="Approval subject discriminator."),
sa.Column("subject_key", sa.String(length=255), nullable=False, comment="Portable subject deduplication key."),
sa.Column(
"matched_sources",
models.types.LongText(),
nullable=False,
comment="Immutable ordered recipient source snapshots serialized as JSON text.",
),
sa.Column(
"subject_snapshot",
models.types.LongText(),
nullable=False,
comment="Immutable display-only subject snapshot serialized as JSON text.",
),
sa.Column("contact_id", models.types.StringUUID(), nullable=True, comment="Logical human_input_contacts.id."),
sa.Column("end_user_id", models.types.StringUUID(), nullable=True, comment="Logical end_users.id."),
sa.Column("normalized_email", sa.String(length=320), nullable=True, comment="Normalized Email subject."),
*_default_fields("human_input_v2_form_approver_grants"),
sa.UniqueConstraint("form_id", "subject_key", name="hiv2_form_grants_form_subject_uq"),
sa.CheckConstraint(
"(subject_type = 'contact' AND contact_id IS NOT NULL AND end_user_id IS NULL "
"AND normalized_email IS NULL) OR "
"(subject_type = 'end_user' AND contact_id IS NULL AND end_user_id IS NOT NULL "
"AND normalized_email IS NULL) OR "
"(subject_type = 'email_address' AND contact_id IS NULL AND end_user_id IS NULL "
"AND normalized_email IS NOT NULL)",
name="subject_identity",
),
comment="Frozen Human Input v2 form approval grants resolved from runtime recipients.",
)
op.create_index(
"hiv2_form_grants_form_contact_idx",
"human_input_v2_form_approver_grants",
["form_id", "contact_id"],
)
op.create_index(
"hiv2_form_grants_form_end_user_idx",
"human_input_v2_form_approver_grants",
["form_id", "end_user_id"],
)
op.create_index(
"hiv2_form_grants_form_email_idx",
"human_input_v2_form_approver_grants",
["form_id", "normalized_email"],
)
op.create_table(
"human_input_v2_form_delivery_endpoints",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
sa.Column(
"approver_grant_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_v2_form_approver_grants.id.",
),
sa.Column("channel", sa.String(length=20), nullable=False, comment="Delivery or interaction channel."),
sa.Column("address_hash", sa.String(length=64), nullable=False, comment="Canonical endpoint SHA-256."),
sa.Column("email_address", sa.String(length=320), nullable=True, comment="Frozen Email endpoint address."),
sa.Column("integration_id", models.types.StringUUID(), nullable=True, comment="Logical IM integration id."),
sa.Column("provider", sa.String(length=20), nullable=True, comment="Frozen IM provider."),
sa.Column("provider_user_id", sa.String(length=255), nullable=True, comment="Frozen provider user id."),
sa.Column("provider_tenant_id", sa.String(length=255), nullable=True, comment="Frozen provider tenant id."),
sa.Column("im_identity_id", models.types.StringUUID(), nullable=True, comment="Historical IM identity id."),
sa.Column("im_binding_id", models.types.StringUUID(), nullable=True, comment="Historical IM binding id."),
sa.Column("access_token_hash", sa.String(length=64), nullable=True, comment="Hashed endpoint capability."),
*_default_fields("human_input_v2_form_delivery_endpoints"),
sa.UniqueConstraint(
"form_id",
"approver_grant_id",
"channel",
"address_hash",
name="hiv2_form_endpoints_grant_channel_address_uq",
),
sa.UniqueConstraint("access_token_hash", name="hiv2_form_endpoints_token_uq"),
comment="Immutable notification and interaction endpoints for Human Input v2 approver grants.",
)
op.create_index(
"hiv2_form_endpoints_identity_form_idx",
"human_input_v2_form_delivery_endpoints",
["im_identity_id", "form_id"],
)
op.create_table(
"human_input_v2_form_delivery_attempts",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
sa.Column(
"endpoint_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_v2_form_delivery_endpoints.id.",
),
sa.Column("attempt_number", sa.Integer(), nullable=False, comment="One-based endpoint retry sequence."),
sa.Column("status", sa.String(length=20), nullable=False, comment="Delivery attempt lifecycle."),
sa.Column("scheduled_at", sa.DateTime(), nullable=False, comment="Eligibility timestamp."),
sa.Column("started_at", sa.DateTime(), nullable=True, comment="Provider delivery start timestamp."),
sa.Column("finished_at", sa.DateTime(), nullable=True, comment="Terminal timestamp."),
sa.Column("provider_message_id", sa.String(length=255), nullable=True, comment="Provider message id."),
sa.Column("failure_code", sa.String(length=100), nullable=True, comment="Failure code."),
sa.Column("failure_reason", models.types.LongText(), nullable=True, comment="Failure diagnostic."),
sa.Column("provider_response", models.types.LongText(), nullable=True, comment="Provider response JSON."),
*_default_fields("human_input_v2_form_delivery_attempts"),
sa.UniqueConstraint("endpoint_id", "attempt_number", name="hiv2_form_attempts_endpoint_number_uq"),
comment="Append-oriented delivery attempts for Human Input v2 form endpoints.",
)
op.create_index(
"hiv2_form_attempts_form_status_created_idx",
"human_input_v2_form_delivery_attempts",
["form_id", "status", "created_at", "id"],
)
op.create_index(
"hiv2_form_attempts_status_scheduled_idx",
"human_input_v2_form_delivery_attempts",
["status", "scheduled_at", "id"],
)
op.create_table(
"human_input_v2_form_upload_tokens",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
sa.Column(
"endpoint_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_v2_form_delivery_endpoints.id.",
),
sa.Column("upload_token_hash", sa.String(length=64), nullable=False, comment="Hashed upload capability."),
*_default_fields("human_input_v2_form_upload_tokens"),
sa.UniqueConstraint("upload_token_hash", name="hiv2_form_upload_tokens_hash_uq"),
comment="Hashed endpoint-scoped upload capabilities for Human Input v2 forms.",
)
op.create_index(
"hiv2_form_upload_tokens_form_endpoint_idx",
"human_input_v2_form_upload_tokens",
["form_id", "endpoint_id"],
)
op.create_table(
"human_input_v2_form_upload_files",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column("app_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to apps.id."),
sa.Column("form_id", models.types.StringUUID(), nullable=False, comment="Logical human_input_v2_forms.id."),
sa.Column(
"endpoint_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_v2_form_delivery_endpoints.id.",
),
sa.Column("upload_file_id", models.types.StringUUID(), nullable=False, comment="Logical upload_files.id."),
sa.Column(
"upload_token_id",
models.types.StringUUID(),
nullable=False,
comment="Logical human_input_v2_form_upload_tokens.id.",
),
*_default_fields("human_input_v2_form_upload_files"),
sa.UniqueConstraint("upload_file_id", name="hiv2_form_upload_files_file_uq"),
comment="Durable Human Input v2 form, endpoint, upload-token, and file associations.",
)
op.create_index(
"hiv2_form_upload_files_form_endpoint_idx",
"human_input_v2_form_upload_files",
["form_id", "endpoint_id"],
)
op.create_index(
"hiv2_form_upload_files_token_idx",
"human_input_v2_form_upload_files",
["upload_token_id"],
)
def downgrade() -> None:
op.drop_table("human_input_v2_form_upload_files")
op.drop_table("human_input_v2_form_upload_tokens")
op.drop_table("human_input_v2_form_delivery_attempts")
op.drop_table("human_input_v2_form_delivery_endpoints")
op.drop_table("human_input_v2_form_approver_grants")
op.drop_table("human_input_v2_forms")
op.drop_table("human_input_email_providers")
@@ -0,0 +1,105 @@
"""add human input v2 otp proof session
Revision ID: 9c2e5f7a1b3d
Revises: 8a1c4e7f9b2d
Create Date: 2026-07-25 13:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "9c2e5f7a1b3d"
down_revision = "8a1c4e7f9b2d"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
"human_input_v2_form_otp_challenges",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column(
"form_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_forms.id.",
),
sa.Column(
"approver_grant_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
),
sa.Column("subject_type", sa.String(length=20), nullable=False, comment="OTP proof subject discriminator."),
sa.Column(
"challenge_token_hash",
sa.String(length=64),
nullable=False,
comment="SHA-256 hash of the ephemeral challenge token.",
),
sa.Column(
"code_hash",
sa.String(length=255),
nullable=False,
comment="Slow password hash of the one-time verification code.",
),
sa.Column(
"code_hash_algorithm",
sa.String(length=50),
nullable=False,
comment="Verifier algorithm discriminator for code_hash.",
),
sa.Column("email_hash", sa.String(length=64), nullable=False, comment="SHA-256 of the normalized Email."),
sa.Column("email", sa.String(length=320), nullable=False, comment="Normalized destination Email."),
sa.Column("status", sa.String(length=20), nullable=False, comment="Current proof-session usability."),
sa.Column("expires_at", sa.DateTime(), nullable=False, comment="Challenge expiration timestamp."),
sa.Column("resend_after", sa.DateTime(), nullable=False, comment="Earliest replacement timestamp."),
sa.Column(
"contact_id",
models.types.StringUUID(),
nullable=True,
comment="Logical human_input_contacts.id captured for a Contact incarnation.",
),
sa.Column("send_count", sa.Integer(), nullable=False, comment="One-based send count for the grant scope."),
sa.Column("attempt_count", sa.Integer(), nullable=False, comment="Consumed verification attempts."),
sa.Column("verified_at", sa.DateTime(), nullable=True, comment="Successful verification timestamp."),
sa.Column("invalidated_at", sa.DateTime(), nullable=True, comment="Replacement or stale-identity timestamp."),
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name="human_input_v2_form_otp_challenges_pkey"),
sa.UniqueConstraint("challenge_token_hash", name="hiv2_form_otp_challenges_token_uq"),
sa.CheckConstraint(
"(subject_type = 'contact' AND contact_id IS NOT NULL) OR "
"(subject_type = 'email_address' AND contact_id IS NULL)",
name="hiv2_form_otp_challenges_subject_identity_ck",
),
sa.CheckConstraint(
"send_count >= 1 AND send_count <= 5",
name="hiv2_form_otp_challenges_send_count_ck",
),
sa.CheckConstraint(
"attempt_count >= 0 AND attempt_count <= 5",
name="hiv2_form_otp_challenges_attempt_count_ck",
),
sa.CheckConstraint(
"(status = 'verified' AND verified_at IS NOT NULL AND invalidated_at IS NULL) OR "
"(status = 'invalidated' AND verified_at IS NULL AND invalidated_at IS NOT NULL) OR "
"(status IN ('pending', 'expired') AND verified_at IS NULL AND invalidated_at IS NULL)",
name="hiv2_form_otp_challenges_terminal_timestamps_ck",
),
comment="Hashed OTP proof sessions for Email-based Human Input v2 approval.",
)
op.create_index(
"hiv2_form_otp_scope_created_idx",
"human_input_v2_form_otp_challenges",
["tenant_id", "form_id", "approver_grant_id", "created_at", "id"],
)
def downgrade() -> None:
op.drop_table("human_input_v2_form_otp_challenges")
@@ -0,0 +1,183 @@
"""add human input v2 submission runtime
Revision ID: ad4f6b8c2e1d
Revises: 9c2e5f7a1b3d
Create Date: 2026-07-25 14:00:00.000000
"""
import sqlalchemy as sa
from alembic import op
import models
# revision identifiers, used by Alembic.
revision = "ad4f6b8c2e1d"
down_revision = "9c2e5f7a1b3d"
branch_labels = None
depends_on = None
def _default_fields(table_name: str) -> tuple[sa.Column, sa.Column, sa.Column, sa.PrimaryKeyConstraint]:
return (
sa.Column("id", models.types.StringUUID(), nullable=False),
sa.Column("created_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.Column("updated_at", sa.DateTime(), server_default=sa.func.current_timestamp(), nullable=False),
sa.PrimaryKeyConstraint("id", name=f"{table_name}_pkey"),
)
def upgrade() -> None:
op.create_table(
"human_input_v2_form_audit_events",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column(
"form_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_forms.id.",
),
sa.Column("event_type", sa.String(length=64), nullable=False, comment="Stable append-only event name."),
sa.Column("occurred_at", sa.DateTime(), nullable=False, comment="Business timestamp for the audited fact."),
sa.Column(
"approver_grant_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
),
sa.Column(
"endpoint_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
),
sa.Column("channel", sa.String(length=20), nullable=True, comment="Originating interaction channel."),
sa.Column("reason_code", sa.String(length=100), nullable=True, comment="Stable rejection reason code."),
sa.Column("reason_message", models.types.LongText(), nullable=True, comment="Operator-safe diagnostic detail."),
sa.Column(
"authorization_proof",
models.types.LongText(),
nullable=True,
comment="Secret-free verified proof serialized as structured JSON text.",
),
sa.Column(
"event_payload",
models.types.LongText(),
nullable=True,
comment="Immutable event-specific structured JSON text.",
),
*_default_fields("human_input_v2_form_audit_events"),
sa.CheckConstraint(
"event_type <> 'submission_authorized' OR "
"(approver_grant_id IS NOT NULL AND authorization_proof IS NOT NULL)",
name="hiv2_form_audit_authorized_proof_ck",
),
sa.CheckConstraint(
"event_type <> 'submission_rejected' OR reason_code IS NOT NULL",
name="hiv2_form_audit_rejection_reason_ck",
),
comment="Append-only Human Input v2 audit facts for proof sessions and submission authorization.",
)
op.create_index(
"hiv2_form_audit_form_occurred_idx",
"human_input_v2_form_audit_events",
["form_id", "occurred_at", "id"],
)
op.create_index(
"hiv2_form_audit_tenant_occurred_idx",
"human_input_v2_form_audit_events",
["tenant_id", "occurred_at", "id"],
)
op.create_table(
"human_input_v2_form_submissions",
sa.Column("tenant_id", models.types.StringUUID(), nullable=False, comment="Logical foreign key to tenants.id."),
sa.Column(
"form_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_forms.id.",
),
sa.Column(
"approver_grant_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to human_input_v2_form_approver_grants.id.",
),
sa.Column("actor_type", sa.String(length=20), nullable=False, comment="Submission actor discriminator."),
sa.Column(
"authorization_audit_event_id",
models.types.StringUUID(),
nullable=False,
comment="Logical foreign key to the authorized human_input_v2_form_audit_events.id.",
),
sa.Column(
"selected_action_id",
sa.String(length=200),
nullable=False,
comment="Selected action from the frozen form definition.",
),
sa.Column(
"input_snapshot",
models.types.LongText(),
nullable=False,
comment="Unvalidated request.inputs object serialized as structured JSON text.",
),
sa.Column(
"canonical_values",
models.types.LongText(),
nullable=False,
comment="Validated runtime values serialized as structured JSON text.",
),
sa.Column("submitted_at", sa.DateTime(), nullable=False, comment="Winning commit business timestamp."),
sa.Column(
"actor_account_id",
models.types.StringUUID(),
nullable=True,
comment="Logical accounts.id for an Account actor.",
),
sa.Column(
"actor_end_user_id",
models.types.StringUUID(),
nullable=True,
comment="Logical end_users.id for an EndUser actor.",
),
sa.Column(
"actor_normalized_email",
sa.String(length=320),
nullable=True,
comment="Normalized EmailAddress actor identity.",
),
sa.Column(
"endpoint_id",
models.types.StringUUID(),
nullable=True,
comment="Logical foreign key to human_input_v2_form_delivery_endpoints.id.",
),
*_default_fields("human_input_v2_form_submissions"),
sa.UniqueConstraint("form_id", name="hiv2_form_submissions_form_uq"),
sa.UniqueConstraint(
"authorization_audit_event_id",
name="hiv2_submission_authorization_audit_event_uq",
),
sa.CheckConstraint(
"(actor_type = 'account' AND actor_account_id IS NOT NULL AND actor_end_user_id IS NULL "
"AND actor_normalized_email IS NULL) OR "
"(actor_type = 'end_user' AND actor_account_id IS NULL AND actor_end_user_id IS NOT NULL "
"AND actor_normalized_email IS NULL) OR "
"(actor_type = 'email_address' AND actor_account_id IS NULL AND actor_end_user_id IS NULL "
"AND actor_normalized_email IS NOT NULL)",
name="hiv2_form_submissions_actor_identity_ck",
),
comment="Immutable first successful Human Input v2 submission and business actor.",
)
op.create_index(
"hiv2_form_submissions_tenant_submitted_idx",
"human_input_v2_form_submissions",
["tenant_id", "submitted_at", "id"],
)
def downgrade() -> None:
op.drop_table("human_input_v2_form_submissions")
op.drop_table("human_input_v2_form_audit_events")
+86
View File
@@ -66,6 +66,50 @@ from .enums import (
)
from .execution_extra_content import ExecutionExtraContent, HumanInputContent
from .human_input import HumanInputForm, HumanInputFormUploadFile, HumanInputFormUploadToken
from .human_input_v2 import (
AccountSessionAuthorizationProof,
DingTalkIMIntegrationEncryptedCredentials,
EmailOTPAuthorizationProof,
FeishuIMIntegrationEncryptedCredentials,
FormApproverGrantMatchedSources,
FormApproverGrantSubjectSnapshot,
FormAuditEventPayload,
FormAuthorizationProof,
FormCanonicalValues,
FormDeliveryProviderResponse,
FormInputSnapshot,
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputEmailProvider,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
HumanInputPlatformContactWorkspaceEntry,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormAuditEvent,
HumanInputV2FormDefinition,
HumanInputV2FormDeliveryAttempt,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormOTPChallenge,
HumanInputV2FormSubmission,
HumanInputV2FormUploadFile,
HumanInputV2FormUploadToken,
IMIdentityAuthorizationProof,
IMIdentityRawPayload,
IMIntegrationEncryptedCredentials,
IMSyncContactSnapshot,
IMSyncDirectoryEntryPayload,
IMSyncIdentitySnapshot,
LarkIMIntegrationEncryptedCredentials,
MSTeamsIMIntegrationEncryptedCredentials,
ResendEmailProviderEncryptedCredentials,
SlackIMIntegrationEncryptedCredentials,
TrustedEndUserAuthorizationProof,
WeComIMIntegrationEncryptedCredentials,
)
from .model import (
AccountTrialAppRecord,
ApiRequest,
@@ -155,6 +199,7 @@ __all__ = [
"APIBasedExtensionPoint",
"Account",
"AccountIntegrate",
"AccountSessionAuthorizationProof",
"AccountStatus",
"AccountStepByStepTourState",
"AccountTrialAppRecord",
@@ -211,22 +256,59 @@ __all__ = [
"DatasourceOauthParamConfig",
"DatasourceProvider",
"DifySetup",
"DingTalkIMIntegrationEncryptedCredentials",
"Document",
"DocumentSegment",
"EmailOTPAuthorizationProof",
"Embedding",
"EndUser",
"ExecutionExtraContent",
"ExporleBanner",
"ExternalKnowledgeApis",
"ExternalKnowledgeBindings",
"FeishuIMIntegrationEncryptedCredentials",
"FormApproverGrantMatchedSources",
"FormApproverGrantSubjectSnapshot",
"FormAuditEventPayload",
"FormAuthorizationProof",
"FormCanonicalValues",
"FormDeliveryProviderResponse",
"FormInputSnapshot",
"HumanInputContact",
"HumanInputContactIdentitySource",
"HumanInputContent",
"HumanInputEmailProvider",
"HumanInputForm",
"HumanInputFormUploadFile",
"HumanInputFormUploadToken",
"HumanInputIMBinding",
"HumanInputIMIdentity",
"HumanInputIMIntegration",
"HumanInputIMSyncResult",
"HumanInputIMSyncRun",
"HumanInputPlatformContactWorkspaceEntry",
"HumanInputV2Form",
"HumanInputV2FormApproverGrant",
"HumanInputV2FormAuditEvent",
"HumanInputV2FormDefinition",
"HumanInputV2FormDeliveryAttempt",
"HumanInputV2FormDeliveryEndpoint",
"HumanInputV2FormOTPChallenge",
"HumanInputV2FormSubmission",
"HumanInputV2FormUploadFile",
"HumanInputV2FormUploadToken",
"IMIdentityAuthorizationProof",
"IMIdentityRawPayload",
"IMIntegrationEncryptedCredentials",
"IMSyncContactSnapshot",
"IMSyncDirectoryEntryPayload",
"IMSyncIdentitySnapshot",
"IconType",
"InstalledApp",
"InvitationCode",
"LarkIMIntegrationEncryptedCredentials",
"LoadBalancingModelConfig",
"MSTeamsIMIntegrationEncryptedCredentials",
"Message",
"MessageAgentThought",
"MessageAnnotation",
@@ -244,8 +326,10 @@ __all__ = [
"ProviderQuotaType",
"ProviderType",
"RecommendedApp",
"ResendEmailProviderEncryptedCredentials",
"SavedMessage",
"Site",
"SlackIMIntegrationEncryptedCredentials",
"SnippetType",
"Tag",
"TagBinding",
@@ -266,7 +350,9 @@ __all__ = [
"TriggerOAuthSystemClient",
"TriggerOAuthTenantClient",
"TriggerSubscription",
"TrustedEndUserAuthorizationProof",
"UploadFile",
"WeComIMIntegrationEncryptedCredentials",
"Whitelist",
"Workflow",
"WorkflowAgentBindingType",
+1 -1
View File
@@ -2,7 +2,7 @@ from flask_sqlalchemy import SQLAlchemy
from sqlalchemy import MetaData
POSTGRES_INDEXES_NAMING_CONVENTION = {
"ix": "%(column_0_label)s_idx",
"ix": "%(table_name)s_%(column_0_N_name)s_idx",
"uq": "%(table_name)s_%(column_0_name)s_key",
"ck": "%(table_name)s_%(constraint_name)s_check",
"fk": "%(table_name)s_%(column_0_name)s_fkey",
+25 -2
View File
@@ -3,10 +3,10 @@ from enum import StrEnum
from typing import Annotated, Literal, Self, final
import sqlalchemy as sa
from pydantic import BaseModel, Field
from pydantic import BaseModel, Field, model_validator
from sqlalchemy.orm import Mapped, mapped_column, relationship
from core.workflow.human_input_adapter import DeliveryMethodType
from core.workflow.human_input_adapter import DeliveryMethodType, InstantMessageProvider, InstantMessageRecipientType
from core.workflow.nodes.human_input.enums import HumanInputFormKind, HumanInputFormStatus
from libs.helper import generate_string
@@ -138,6 +138,7 @@ class ApprovalChannel(StrEnum):
"""Where a paused human input form can be approved, surfaced to API callers."""
EMAIL = "email"
IM = "im"
WEB_APP = "web_app"
CONSOLE = "console"
@@ -146,6 +147,7 @@ class RecipientType(StrEnum):
# Second value = the approval channel this recipient maps to (surfaced in `approval_channels`).
EMAIL_MEMBER = "email_member", ApprovalChannel.EMAIL
EMAIL_EXTERNAL = "email_external", ApprovalChannel.EMAIL
INSTANT_MESSAGE = "instant_message", ApprovalChannel.IM
# STANDALONE_WEB_APP is used by the standalone web app.
#
# It's not used while running workflows / chatflows containing HumanInput
@@ -185,6 +187,26 @@ class EmailExternalRecipientPayload(BaseModel):
email: str
@final
class InstantMessageRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.INSTANT_MESSAGE] = RecipientType.INSTANT_MESSAGE
provider: InstantMessageProvider
recipient_kind: InstantMessageRecipientType
channel_id: str | None = None
user_id: str | None = None
@model_validator(mode="after")
def validate_target(self) -> Self:
match self.recipient_kind:
case InstantMessageRecipientType.CHANNEL:
if self.channel_id is None or self.user_id is not None:
raise ValueError("channel recipients must include channel_id and must not include user_id")
case InstantMessageRecipientType.USER:
if self.user_id is None or self.channel_id is not None:
raise ValueError("user recipients must include user_id and must not include channel_id")
return self
@final
class StandaloneWebAppRecipientPayload(BaseModel):
TYPE: Literal[RecipientType.STANDALONE_WEB_APP] = RecipientType.STANDALONE_WEB_APP
@@ -211,6 +233,7 @@ class ConsoleDeliveryPayload(BaseModel):
RecipientPayload = Annotated[
EmailMemberRecipientPayload
| EmailExternalRecipientPayload
| InstantMessageRecipientPayload
| StandaloneWebAppRecipientPayload
| ConsoleRecipientPayload
| BackstageRecipientPayload,
File diff suppressed because it is too large Load Diff
+132 -3
View File
@@ -1,15 +1,16 @@
import enum
import json
import uuid
from typing import Any, cast, override
from typing import Any, cast, overload, override
import sqlalchemy as sa
from pydantic import BaseModel
from pydantic import BaseModel, TypeAdapter
from sqlalchemy import CHAR, TEXT, VARCHAR, LargeBinary, TypeDecorator
from sqlalchemy.dialects.mysql import LONGBLOB, LONGTEXT
from sqlalchemy.dialects.postgresql import BYTEA, JSONB, UUID
from sqlalchemy.engine.interfaces import Dialect
from sqlalchemy.sql.type_api import TypeEngine
from typing_extensions import deprecated
from configs import dify_config
@@ -69,8 +70,24 @@ class LongText(TypeDecorator[str | None]):
return value
@deprecated(
"JSONModelColumn is a legacy coercing TEXT-backed type. "
"Use FrozenPydanticModelColumn for new model-only persistence."
)
class JSONModelColumn[T: BaseModel](TypeDecorator[T | None]):
"""Store a Pydantic model as dialect-adjusted LongText JSON."""
"""Store coercible Pydantic model input as dialect-adjusted LongText JSON.
New model-only columns should use ``FrozenPydanticModelColumn``. Existing
uses must not be replaced mechanically because they may depend on:
- historical ``dict`` or JSON string writes;
- non-strict validation and coercion;
- Pydantic models that are not frozen;
- compatibility with dirty or otherwise non-canonical stored data.
Audit both callers and persisted values before migrating an existing
column.
"""
impl = TEXT
cache_ok = True
@@ -171,6 +188,118 @@ class AdjustedJSON(TypeDecorator[dict | list | None]):
return value
class FrozenPydanticModelColumn[T: BaseModel](TypeDecorator[T]):
"""Persist a frozen Pydantic model as JSON in a dialect-adjusted text column.
Binding serializes the accepted model directly with
``model_dump_json(warnings="error")``. Loading validates the stored JSON in
strict mode and lets Pydantic validation errors propagate to the caller.
Pass a concrete model class for concrete models. For discriminated unions,
pass a ``TypeAdapter`` together with every allowed concrete model class.
The backing type is ``TEXT`` on PostgreSQL and SQLite and ``LONGTEXT`` on
MySQL. This is intentional: ``model_dump_json`` already returns JSON text,
while passing that string through SQLAlchemy's ``JSON`` type would encode it
again and persist a JSON string scalar instead of the model's JSON object.
SQLAlchemy does not track in-place changes made inside a Pydantic model, so
this type only accepts models configured with ``frozen=True``. Persisted
values must be updated by constructing a new model and assigning it to the
ORM attribute as a whole, for example ``record.payload = PayloadModel(...)``.
Otherwise the attribute may not be marked dirty and the change may not be
persisted.
Pydantic freezing is shallow: nested mutable containers such as ``dict`` and
``list`` can still be changed in place. Callers must treat nested values as
immutable too instead of mutating paths such as
``record.payload.root["key"]``. Supporting nested in-place mutation would
require SQLAlchemy's Mutable extension or explicit deep change tracking,
which this type does not provide.
"""
impl = TEXT
cache_ok = True
_model_type: type[T] | None
_adapter: TypeAdapter[T] | None
model_types: tuple[type[BaseModel], ...]
@overload
def __init__(
self,
model_type: type[T],
/,
) -> None: ...
@overload
def __init__(
self,
adapter: TypeAdapter[T],
/,
*,
model_types: tuple[type[BaseModel], ...],
) -> None: ...
def __init__(
self,
schema: type[T] | TypeAdapter[T],
/,
*,
model_types: tuple[type[BaseModel], ...] | None = None,
) -> None:
if isinstance(schema, TypeAdapter):
if model_types is None:
raise TypeError("model_types is required when schema is a TypeAdapter")
if not model_types:
raise ValueError("model_types must not be empty")
self._model_type = None
self._adapter = schema
self.model_types = model_types
else:
if model_types is not None:
raise TypeError("model_types must not be provided for a concrete model")
self._model_type = schema
self._adapter = None
self.model_types = (schema,)
for allowed_model_type in self.model_types:
if not isinstance(allowed_model_type, type) or not issubclass(allowed_model_type, BaseModel):
raise TypeError("model_types must contain only Pydantic BaseModel classes")
model_name = f"{allowed_model_type.__module__}.{allowed_model_type.__qualname__}"
if allowed_model_type.model_config.get("frozen") is not True:
raise TypeError(f"{model_name} must configure frozen=True")
if allowed_model_type.model_config.get("strict") is not True:
raise TypeError(f"{model_name} must configure strict=True")
super().__init__()
@override
def load_dialect_impl(self, dialect: Dialect) -> TypeEngine[Any]:
if dialect.name == "mysql":
return dialect.type_descriptor(LONGTEXT())
return dialect.type_descriptor(TEXT())
@override
def process_bind_param(self, value: object | None, dialect: Dialect) -> str | None:
del dialect
if value is None:
return None
if not isinstance(value, BaseModel) or not isinstance(value, self.model_types):
allowed_model_names = ", ".join(model_type.__name__ for model_type in self.model_types)
raise TypeError(f"value must be one of these Pydantic models: {allowed_model_names}")
return value.model_dump_json(warnings="error")
@override
def process_result_value(self, value: str | bytes | bytearray | None, dialect: Dialect) -> T | None:
del dialect
if value is None:
return None
if self._model_type is not None:
return self._model_type.model_validate_json(value, strict=True)
if self._adapter is None:
raise RuntimeError("Pydantic JSON validator is not configured")
return self._adapter.validate_json(value, strict=True)
class EnumText[T: enum.StrEnum](TypeDecorator[T | None]):
impl = VARCHAR
cache_ok = True
@@ -0,0 +1 @@
"""Persistence adapters for Human Input v2 bounded contexts."""
@@ -0,0 +1,15 @@
"""SQLAlchemy persistence adapter for Human Input v2 OTP proof sessions."""
from .repository import (
OTPChallengeAuditFact,
OTPChallengeAuditWriter,
OTPPersistenceError,
SQLAlchemyOTPChallengeRepository,
)
__all__ = [
"OTPChallengeAuditFact",
"OTPChallengeAuditWriter",
"OTPPersistenceError",
"SQLAlchemyOTPChallengeRepository",
]
@@ -0,0 +1,179 @@
"""Explicit mapping between OTP proof-session values and ORM records.
Hash material crosses only this persistence boundary. Public challenge and proof
serialization deliberately omit every code and token digest.
"""
from __future__ import annotations
from datetime import UTC, datetime
from hashlib import sha256
from core.human_input_v2.approval import (
ContactOTPSubject,
EmailAddressOTPSubject,
EmailOTPSubject,
FormRef,
OTPChallenge,
OTPCodeHash,
VerifiedEmailOTPProof,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputOTPChallengeStatus
from core.human_input_v2.shared import (
ApproverGrantId,
ContactId,
FormId,
NormalizedEmail,
OTPChallengeId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import EmailOTPAuthorizationProof, HumanInputV2FormOTPChallenge
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def _email_hash(normalized_email: NormalizedEmail) -> str:
return sha256(str(normalized_email).encode()).hexdigest()
def challenge_to_record(challenge: OTPChallenge) -> HumanInputV2FormOTPChallenge:
"""Map one detached aggregate into a record without plaintext secrets."""
contact_id: str | None = None
if isinstance(challenge.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(challenge.subject.contact_id)
else:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
record = HumanInputV2FormOTPChallenge(
tenant_id=str(challenge.ref.form_ref.workspace_id),
form_id=str(challenge.ref.form_ref.form_id),
approver_grant_id=str(challenge.ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
challenge_token_hash=challenge.challenge_token_hash,
code_hash=challenge.code_hash.encoded_value,
code_hash_algorithm=challenge.code_hash.algorithm,
email_hash=_email_hash(challenge.normalized_email),
email=str(challenge.normalized_email),
status=challenge.status,
expires_at=challenge.expires_at.value,
resend_after=challenge.resend_after.value,
send_count=challenge.send_count,
attempt_count=challenge.attempt_count,
verified_at=challenge.verified_at.value if challenge.verified_at is not None else None,
invalidated_at=challenge.invalidated_at.value if challenge.invalidated_at is not None else None,
)
record.id = str(challenge.ref.challenge_id)
record.created_at = challenge.created_at.value
record.updated_at = challenge.updated_at.value
return record
def challenge_from_record(record: HumanInputV2FormOTPChallenge) -> OTPChallenge:
"""Rebuild one aggregate while rejecting malformed or secret-unsafe rows."""
try:
normalized_email = NormalizedEmail(record.email)
except ValueError as error:
raise ValueError("OTP challenge record has an invalid email") from error
if record.email_hash != _email_hash(normalized_email):
raise ValueError("OTP challenge record email hash does not match its normalized email")
try:
code_hash = OTPCodeHash(record.code_hash, record.code_hash_algorithm)
except ValueError as error:
raise ValueError("OTP challenge record has invalid code hash metadata") from error
subject: EmailOTPSubject
if record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if record.contact_id is None:
raise ValueError("contact OTP challenge record is missing contact_id")
subject = ContactOTPSubject(ContactId(record.contact_id))
elif record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record.contact_id is not None:
raise ValueError("email-address OTP challenge record must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("OTP challenge record has an unsupported subject type")
if not isinstance(record.status, HumanInputOTPChallengeStatus):
raise ValueError("OTP challenge record has an unsupported status")
challenge_ref = (
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
.grant(ApproverGrantId(record.approver_grant_id))
.challenge(OTPChallengeId(record.id))
)
return OTPChallenge(
ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
challenge_token_hash=record.challenge_token_hash,
code_hash=code_hash,
status=record.status,
expires_at=_timestamp(record.expires_at),
resend_after=_timestamp(record.resend_after),
send_count=record.send_count,
attempt_count=record.attempt_count,
verified_at=_timestamp(record.verified_at) if record.verified_at is not None else None,
invalidated_at=_timestamp(record.invalidated_at) if record.invalidated_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def proof_to_record_value(proof: VerifiedEmailOTPProof) -> EmailOTPAuthorizationProof:
"""Serialize one verified proof without code, token, or hash material."""
contact_id: str | None = None
if isinstance(proof.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(proof.subject.contact_id)
else:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
return EmailOTPAuthorizationProof(
otp_challenge_id=str(proof.challenge_ref.challenge_id),
workspace_id=str(proof.challenge_ref.form_ref.workspace_id),
form_id=str(proof.challenge_ref.form_ref.form_id),
approver_grant_id=str(proof.challenge_ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
verified_email=str(proof.normalized_email),
verified_at=proof.verified_at.value,
)
def proof_from_record_value(
record_value: EmailOTPAuthorizationProof,
*,
workspace_id: WorkspaceId,
) -> VerifiedEmailOTPProof:
"""Rebuild one proof value after validating its captured subject shape."""
if record_value.workspace_id != str(workspace_id):
raise ValueError("OTP proof owner does not match the requested workspace")
normalized_email = NormalizedEmail(record_value.verified_email)
subject: EmailOTPSubject
if record_value.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if record_value.contact_id is None:
raise ValueError("contact OTP proof is missing contact_id")
subject = ContactOTPSubject(ContactId(record_value.contact_id))
elif record_value.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record_value.contact_id is not None:
raise ValueError("email-address OTP proof must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("OTP proof has an unsupported subject type")
challenge_ref = (
FormRef(workspace_id, FormId(record_value.form_id))
.grant(ApproverGrantId(record_value.approver_grant_id))
.challenge(OTPChallengeId(record_value.otp_challenge_id))
)
return VerifiedEmailOTPProof(
challenge_ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
verified_at=_timestamp(record_value.verified_at),
)
@@ -0,0 +1,361 @@
"""Grant-locked SQLAlchemy adapter for Email OTP proof sessions.
Every write locks the stable approver-grant row with its complete owner chain.
Replacement hashing completes before tracked rows are mutated; invalidation,
replacement insertion, and an injected audit append then share one short
transaction. The Submission Runtime persistence layer owns the concrete shared
form-audit writer and table; this module owns only the transaction-scoped port.
An elapsed pending challenge is committed as expired without hashing, audit, or
replacement side effects. The adapter never loads or writes Form lifecycle fields.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import Protocol
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import (
ApproverGrantRef,
Clock,
ContactOTPSubject,
CurrentEmailOTPIdentity,
EmailAddressOTPSubject,
OTPChallenge,
OTPChallengeRef,
OTPChallengeRejectionReason,
OTPCodeHasher,
OTPReplacementDecision,
OTPVerificationDecision,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType
from core.human_input_v2.shared import ContactId, NormalizedEmail, OTPChallengeId, UtcTimestamp
from models.human_input_v2 import (
HumanInputContact,
HumanInputV2FormApproverGrant,
HumanInputV2FormOTPChallenge,
)
from .mappers import challenge_from_record, challenge_to_record
class OTPPersistenceError(RuntimeError):
"""An OTP write failed before its complete transaction could commit."""
@dataclass(frozen=True, slots=True)
class OTPChallengeAuditFact:
"""Secret-free issuance fact appended in the replacement transaction."""
audit_event_id: str
challenge_ref: OTPChallengeRef
previous_challenge_id: OTPChallengeId | None
send_count: int
occurred_at: UtcTimestamp
class OTPChallengeAuditWriter(Protocol):
"""Append one audit fact through the caller-owned SQLAlchemy transaction.
The concrete shared audit mapping belongs to Submission Runtime persistence.
"""
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
"""Add the fact without committing, flushing, or retaining the session."""
...
class SQLAlchemyOTPChallengeRepository:
"""Operation-oriented OTP adapter serialized by one grant row lock."""
_session_maker: sessionmaker[Session]
_clock: Clock
_code_hasher: OTPCodeHasher
_audit_writer: OTPChallengeAuditWriter
def __init__(
self,
session_maker: sessionmaker[Session],
*,
clock: Clock,
code_hasher: OTPCodeHasher,
audit_writer: OTPChallengeAuditWriter,
) -> None:
self._session_maker = session_maker
self._clock = clock
self._code_hasher = code_hasher
self._audit_writer = audit_writer
def issue_initial(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPChallenge:
"""Issue the first challenge for a grant scope and append its audit fact."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, grant_ref)
if self._load_latest_record(session, grant_ref) is not None:
raise ValueError("OTP challenge already exists for the grant scope")
current_identity = self._current_identity(session, grant_record, grant_ref)
if current_identity.subject is None or current_identity.normalized_email is None:
raise ValueError("grant scope has no current Email identity")
challenge = OTPChallenge.issue(
challenge_ref=grant_ref.challenge(challenge_id),
subject=current_identity.subject,
normalized_email=current_identity.normalized_email,
challenge_token_hash=challenge_token_hash,
plaintext_code=plaintext_code,
send_count=1,
clock=self._clock,
code_hasher=self._code_hasher,
)
session.add(challenge_to_record(challenge))
self._append_audit(
session,
challenge=challenge,
previous_challenge_id=None,
audit_event_id=audit_event_id,
)
session.flush()
return challenge
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to issue initial OTP challenge") from error
def replace_current(
self,
grant_ref: ApproverGrantRef,
*,
challenge_id: OTPChallengeId,
audit_event_id: str,
challenge_token_hash: str,
plaintext_code: str,
) -> OTPReplacementDecision:
"""Atomically expire or replace the latest challenge after policy checks."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, grant_ref)
current_record = self._load_latest_record(session, grant_ref)
if current_record is None:
raise ValueError("current OTP challenge does not exist for the grant scope")
current = challenge_from_record(current_record)
current_identity = self._current_identity(session, grant_record, grant_ref)
if not self._identity_matches(current, current_identity):
invalidated = current.invalidate(clock=self._clock)
self._apply_state(current_record, invalidated)
session.flush()
return OTPReplacementDecision(
invalidated,
None,
OTPChallengeRejectionReason.STALE_IDENTITY,
)
decision = current.replace(
challenge_ref=grant_ref.challenge(challenge_id),
challenge_token_hash=challenge_token_hash,
plaintext_code=plaintext_code,
clock=self._clock,
code_hasher=self._code_hasher,
)
if decision.replacement is None:
if decision.previous != current:
self._apply_state(current_record, decision.previous)
session.flush()
return decision
self._apply_state(current_record, decision.previous)
session.add(challenge_to_record(decision.replacement))
self._append_audit(
session,
challenge=decision.replacement,
previous_challenge_id=current.ref.challenge_id,
audit_event_id=audit_event_id,
)
session.flush()
return decision
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to replace current OTP challenge") from error
def verify(self, challenge_ref: OTPChallengeRef, *, plaintext_code: str) -> OTPVerificationDecision:
"""Verify one current challenge under its grant lock and persist counters."""
try:
with self._session_maker() as session, session.begin():
grant_record = self._lock_grant(session, challenge_ref.grant_ref)
record = self._load_record(session, challenge_ref)
if record is None:
raise ValueError("OTP challenge does not exist in the requested owner scope")
challenge = challenge_from_record(record)
current_identity = self._current_identity(session, grant_record, challenge_ref.grant_ref)
if not self._identity_matches(challenge, current_identity):
invalidated = challenge.invalidate(clock=self._clock)
self._apply_state(record, invalidated)
session.flush()
return OTPVerificationDecision(
invalidated,
None,
OTPChallengeRejectionReason.STALE_IDENTITY,
)
decision = challenge.verify(
plaintext_code=plaintext_code,
clock=self._clock,
code_hasher=self._code_hasher,
)
self._apply_state(record, decision.challenge)
session.flush()
return decision
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to verify OTP challenge") from error
def invalidate_current(self, grant_ref: ApproverGrantRef) -> OTPChallenge | None:
"""Invalidate the latest challenge in one grant scope without Form mutation."""
try:
with self._session_maker() as session, session.begin():
self._lock_grant(session, grant_ref)
record = self._load_latest_record(session, grant_ref)
if record is None:
return None
invalidated = challenge_from_record(record).invalidate(clock=self._clock)
self._apply_state(record, invalidated)
session.flush()
return invalidated
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to invalidate current OTP challenge") from error
def load(self, challenge_ref: OTPChallengeRef) -> OTPChallenge | None:
"""Load one challenge only when every owner predicate matches."""
try:
with self._session_maker() as session, session.begin():
record = self._load_record(session, challenge_ref)
return challenge_from_record(record) if record is not None else None
except SQLAlchemyError as error:
raise OTPPersistenceError("failed to load OTP challenge") from error
@staticmethod
def _locked_grant_statement(grant_ref: ApproverGrantRef) -> sa.Select[tuple[HumanInputV2FormApproverGrant]]:
return (
select(HumanInputV2FormApproverGrant)
.where(
HumanInputV2FormApproverGrant.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputV2FormApproverGrant.form_id == str(grant_ref.form_ref.form_id),
HumanInputV2FormApproverGrant.id == str(grant_ref.grant_id),
)
.with_for_update()
)
def _lock_grant(self, session: Session, grant_ref: ApproverGrantRef) -> HumanInputV2FormApproverGrant:
grant_record = session.scalar(self._locked_grant_statement(grant_ref))
if grant_record is None:
raise ValueError("approver grant scope does not exist")
return grant_record
@staticmethod
def _load_latest_record(
session: Session,
grant_ref: ApproverGrantRef,
) -> HumanInputV2FormOTPChallenge | None:
return session.scalar(
select(HumanInputV2FormOTPChallenge)
.where(
HumanInputV2FormOTPChallenge.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputV2FormOTPChallenge.form_id == str(grant_ref.form_ref.form_id),
HumanInputV2FormOTPChallenge.approver_grant_id == str(grant_ref.grant_id),
)
.order_by(HumanInputV2FormOTPChallenge.created_at.desc(), HumanInputV2FormOTPChallenge.id.desc())
.limit(1)
)
@staticmethod
def _load_record(session: Session, challenge_ref: OTPChallengeRef) -> HumanInputV2FormOTPChallenge | None:
return session.scalar(
select(HumanInputV2FormOTPChallenge).where(
HumanInputV2FormOTPChallenge.tenant_id == str(challenge_ref.form_ref.workspace_id),
HumanInputV2FormOTPChallenge.form_id == str(challenge_ref.form_ref.form_id),
HumanInputV2FormOTPChallenge.approver_grant_id == str(challenge_ref.grant_ref.grant_id),
HumanInputV2FormOTPChallenge.id == str(challenge_ref.challenge_id),
)
)
@staticmethod
def _current_identity(
session: Session,
grant_record: HumanInputV2FormApproverGrant,
grant_ref: ApproverGrantRef,
) -> CurrentEmailOTPIdentity:
if grant_record.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if grant_record.contact_id is None:
raise ValueError("contact grant is missing contact_id")
contact = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == grant_record.contact_id,
sa.or_(
HumanInputContact.tenant_id == str(grant_ref.form_ref.workspace_id),
HumanInputContact.tenant_id.is_(None),
),
)
)
if contact is None or contact.normalized_email is None:
return CurrentEmailOTPIdentity(grant_ref, None, None)
return CurrentEmailOTPIdentity(
grant_ref,
ContactOTPSubject(contact_id=ContactId(contact.id)),
NormalizedEmail(contact.normalized_email),
)
if grant_record.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if grant_record.normalized_email is None:
raise ValueError("email-address grant is missing normalized_email")
normalized_email = NormalizedEmail(grant_record.normalized_email)
return CurrentEmailOTPIdentity(
grant_ref,
EmailAddressOTPSubject(normalized_email),
normalized_email,
)
raise ValueError("approver grant subject does not support Email OTP")
@staticmethod
def _identity_matches(challenge: OTPChallenge, current_identity: CurrentEmailOTPIdentity) -> bool:
return (
current_identity.subject is not None
and current_identity.normalized_email is not None
and challenge.subject == current_identity.subject
and challenge.normalized_email == current_identity.normalized_email
)
@staticmethod
def _apply_state(record: HumanInputV2FormOTPChallenge, challenge: OTPChallenge) -> None:
record.status = challenge.status
record.send_count = challenge.send_count
record.attempt_count = challenge.attempt_count
record.verified_at = challenge.verified_at.value if challenge.verified_at is not None else None
record.invalidated_at = challenge.invalidated_at.value if challenge.invalidated_at is not None else None
record.updated_at = challenge.updated_at.value
def _append_audit(
self,
session: Session,
*,
challenge: OTPChallenge,
previous_challenge_id: OTPChallengeId | None,
audit_event_id: str,
) -> None:
self._audit_writer.append(
session,
OTPChallengeAuditFact(
audit_event_id=audit_event_id,
challenge_ref=challenge.ref,
previous_challenge_id=previous_challenge_id,
send_count=challenge.send_count,
occurred_at=challenge.created_at,
),
)
@@ -0,0 +1,9 @@
"""SQLAlchemy mapping and transactions for the Contact Directory aggregate.
This package may depend on domain and ORM modules. It hides records, sessions,
owner predicates, locking, and rollback from domain and application callers.
"""
from .repository import SQLAlchemyContactDirectoryRepository
__all__ = ["SQLAlchemyContactDirectoryRepository"]
@@ -0,0 +1,124 @@
"""Explicit mapping between Contact Directory values and persistence records."""
from datetime import UTC, datetime
from core.human_input_v2.contact_directory import (
Contact,
ContactIdentitySource,
ContactOwner,
ExternalContactOwner,
OrganizationAccountOwner,
PlatformWorkspaceEntry,
WorkspaceMemberOwner,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
NormalizedEmail,
PlatformEntryId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputPlatformContactWorkspaceEntry,
)
def _timestamp(value: datetime) -> UtcTimestamp:
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def contact_from_record(record: HumanInputContact) -> Contact:
"""Map one persistence record into an infrastructure-free Contact."""
identity_source = ContactIdentitySource(record.identity_source.value)
owner: ContactOwner
match identity_source:
case ContactIdentitySource.ORGANIZATION_ACCOUNT:
if record.account_id is None:
raise ValueError("organization account record is missing account_id")
owner = OrganizationAccountOwner(AccountId(record.account_id))
case ContactIdentitySource.WORKSPACE_MEMBER:
if record.tenant_id is None or record.account_id is None:
raise ValueError("workspace member record is missing owner fields")
owner = WorkspaceMemberOwner(WorkspaceId(record.tenant_id), AccountId(record.account_id))
case ContactIdentitySource.EXTERNAL:
if record.tenant_id is None:
raise ValueError("external contact record is missing tenant_id")
owner = ExternalContactOwner(WorkspaceId(record.tenant_id))
return Contact(
id=ContactId(record.id),
identity_source=identity_source,
owner=owner,
name=record.name,
normalized_name=record.normalized_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
avatar_file_id=record.avatar_file_id,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def contact_to_record(contact: Contact) -> HumanInputContact:
"""Map one Contact into a detached persistence record."""
tenant_id: str | None
account_id: str | None
match contact.owner:
case OrganizationAccountOwner(account_id=owner_account_id):
tenant_id = None
account_id = str(owner_account_id)
case WorkspaceMemberOwner(workspace_id=workspace_id, account_id=owner_account_id):
tenant_id = str(workspace_id)
account_id = str(owner_account_id)
case ExternalContactOwner(workspace_id=workspace_id):
tenant_id = str(workspace_id)
account_id = None
record = HumanInputContact(
name=contact.name,
normalized_name=contact.normalized_name,
identity_source=HumanInputContactIdentitySource(contact.identity_source.value),
tenant_id=tenant_id,
account_id=account_id,
email=contact.email,
normalized_email=str(contact.normalized_email) if contact.normalized_email is not None else None,
avatar_file_id=contact.avatar_file_id,
)
record.id = str(contact.id)
record.created_at = contact.created_at.value
record.updated_at = contact.updated_at.value
return record
def platform_entry_from_record(record: HumanInputPlatformContactWorkspaceEntry) -> PlatformWorkspaceEntry:
"""Map one Platform allow-list record into a domain fact."""
return PlatformWorkspaceEntry(
id=PlatformEntryId(record.id),
workspace_id=WorkspaceId(record.tenant_id),
contact_id=ContactId(record.contact_id),
added_by_account_id=AccountId(record.added_by_account_id),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def platform_entry_to_record(entry: PlatformWorkspaceEntry) -> HumanInputPlatformContactWorkspaceEntry:
"""Map one Platform allow-list fact into a detached persistence record."""
record = HumanInputPlatformContactWorkspaceEntry(
tenant_id=str(entry.workspace_id),
contact_id=str(entry.contact_id),
added_by_account_id=str(entry.added_by_account_id),
)
record.id = str(entry.id)
record.created_at = entry.created_at.value
record.updated_at = entry.updated_at.value
return record
@@ -0,0 +1,419 @@
"""SQLAlchemy Contact Directory adapter with aggregate-scoped transactions.
Every consuming query includes the complete deployment/workspace owner
predicate. EE Organization writes require the stable ``DifySetup`` row;
External writes share that lock when the deployment owner exists. ORM instances
never cross this boundary.
"""
from __future__ import annotations
from datetime import UTC, datetime
import sqlalchemy as sa
from sqlalchemy import or_, select
from sqlalchemy.dialects.mysql import insert as mysql_insert
from sqlalchemy.dialects.postgresql import insert as postgresql_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactDirectoryPolicy,
ContactDirectorySnapshot,
ContactRejectionCode,
ExternalContactOwner,
OrganizationAccountOwner,
PlatformWorkspaceEntry,
WorkspaceMemberOwner,
)
from core.human_input_v2.shared import AccountId, ContactId, PlatformEntryId, UtcTimestamp, WorkspaceId
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus, TenantAccountJoin
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputPlatformContactWorkspaceEntry,
)
from models.model import DifySetup
from .mappers import contact_from_record, contact_to_record, platform_entry_to_record
class SQLAlchemyContactDirectoryRepository:
"""Transactional adapter for coherent Contact Directory operations."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def load_snapshot(self, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
"""Load one coherent contacts, membership, allow-list, and Account view."""
try:
with self._session_maker() as session, session.begin():
self._configure_snapshot_transaction(session)
return self._load_snapshot(session, workspace_id)
except ContactDirectoryError:
raise
except SQLAlchemyError as error:
raise self._persistence_error() from error
def save_organization_contact(self, contact: Contact) -> Contact:
"""Persist one deployment-owned Organization Contact after a serialized identity claim."""
if not isinstance(contact.owner, OrganizationAccountOwner):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
return self._save_account_backed_contact(contact, workspace_owner=None)
def save_workspace_member_contact(self, contact: Contact) -> Contact:
"""Persist one Contact only while its owning workspace membership exists."""
if not isinstance(contact.owner, WorkspaceMemberOwner):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
return self._save_account_backed_contact(contact, workspace_owner=contact.owner)
def _save_account_backed_contact(
self,
contact: Contact,
*,
workspace_owner: WorkspaceMemberOwner | None,
) -> Contact:
"""Persist an already source-validated Account-backed Contact."""
try:
with self._session_maker() as session, session.begin():
if workspace_owner is None:
self._lock_deployment_owner(session, require_setup_row=True)
else:
self._ensure_workspace_membership(session, workspace_owner)
self._ensure_account_available(session, contact)
self._ensure_identity_available(session, contact)
record = self._find_owned_record(session, contact)
if record is None:
record = contact_to_record(contact)
session.add(record)
else:
if not self._record_has_same_identity(record, contact):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
self._copy_mutable_values(record, contact)
session.flush()
return contact_from_record(record)
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def admit_external(self, workspace_id: WorkspaceId, *, name: str, email: str) -> Contact:
"""Atomically validate and create a new External Contact.
External and Organization Email claims share the deployment lock when
deployment-wide Organization semantics exist. The lock is acquired
before the first Contact read so a waiter observes the transaction that
won the claim instead of retaining an older snapshot. SaaS deployments
without a setup row retain tenant-scoped External identity semantics.
"""
try:
with self._session_maker() as session, session.begin():
self._lock_deployment_owner(session, require_setup_row=False)
snapshot = self._load_snapshot(session, workspace_id)
contact = ContactDirectoryPolicy.admit_external(
snapshot,
contact_id=ContactId(str(uuidv7())),
name=name,
email=email,
now=UtcTimestamp(datetime.now(UTC)),
)
self._ensure_identity_available(session, contact)
record = contact_to_record(contact)
session.add(record)
session.flush()
return contact_from_record(record)
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def set_platform_availability(
self,
workspace_id: WorkspaceId,
contact_id: ContactId,
*,
added_by_account_id: AccountId,
enabled: bool,
) -> None:
"""Idempotently mutate one workspace allow-list entry in a transaction."""
try:
with self._session_maker() as session, session.begin():
contact_record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.tenant_id == str(workspace_id),
),
)
)
if contact_record is None:
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
if (
contact_record.identity_source is not HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT
or contact_record.tenant_id is not None
):
raise self._domain_error(ContactRejectionCode.INVALID_OWNER)
if enabled:
now = UtcTimestamp(datetime.now(UTC))
self._insert_platform_entry_idempotently(
session,
PlatformWorkspaceEntry(
id=PlatformEntryId(str(uuidv7())),
workspace_id=workspace_id,
contact_id=contact_id,
added_by_account_id=added_by_account_id,
created_at=now,
updated_at=now,
),
)
else:
session.execute(
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id),
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact_id),
)
)
session.flush()
except ContactDirectoryError:
raise
except IntegrityError as error:
raise self._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY) from error
except SQLAlchemyError as error:
raise self._persistence_error() from error
def hard_delete_external(self, workspace_id: WorkspaceId, contact_id: ContactId) -> None:
"""Hard-delete an External Contact only within its owning workspace."""
try:
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
HumanInputContact.tenant_id == str(workspace_id),
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
)
)
if record is None:
raise self._domain_error(ContactRejectionCode.CONTACT_NOT_FOUND)
session.delete(record)
except ContactDirectoryError:
raise
except SQLAlchemyError as error:
raise self._persistence_error() from error
def _load_snapshot(self, session: Session, workspace_id: WorkspaceId) -> ContactDirectorySnapshot:
contact_records = session.scalars(
select(HumanInputContact)
.options(
selectinload(
HumanInputContact.platform_workspace_entries.and_(
HumanInputPlatformContactWorkspaceEntry.tenant_id == str(workspace_id)
)
)
)
.where(
or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.tenant_id == str(workspace_id),
)
)
.order_by(HumanInputContact.id)
).all()
contacts = tuple(contact_from_record(record) for record in contact_records)
member_account_ids = frozenset(
AccountId(account_id)
for account_id in session.scalars(
select(TenantAccountJoin.account_id).where(TenantAccountJoin.tenant_id == str(workspace_id))
).all()
)
contact_account_ids = {contact.account_id for contact in contacts if contact.account_id is not None}
active_account_ids = frozenset(
AccountId(account_id)
for account_id in session.scalars(
select(Account.id).where(
Account.id.in_([str(account_id) for account_id in contact_account_ids]),
Account.status == AccountStatus.ACTIVE,
)
).all()
)
platform_contact_ids = frozenset(
ContactId(record.id) for record in contact_records if record.platform_workspace_entries
)
return ContactDirectorySnapshot(
workspace_id=workspace_id,
contacts=contacts,
member_account_ids=member_account_ids,
platform_contact_ids=platform_contact_ids,
unavailable_account_ids=frozenset(contact_account_ids - active_account_ids),
)
@staticmethod
def _lock_deployment_owner(session: Session, *, require_setup_row: bool) -> None:
"""Lock the deployment identity owner when its semantics apply.
Organization writes require this owner. External writes use it when
present, while SaaS deployments without a deployment Organization keep
their existing tenant-local admission boundary.
"""
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
if setup_version is None and require_setup_row:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.SETUP_ROW_MISSING)
@staticmethod
def _configure_snapshot_transaction(session: Session) -> None:
"""Use one MVCC snapshot for all facts loaded by the directory query."""
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
@staticmethod
def _ensure_workspace_membership(session: Session, owner: WorkspaceMemberOwner) -> None:
membership_id = session.scalar(
select(TenantAccountJoin.id)
.where(
TenantAccountJoin.tenant_id == str(owner.workspace_id),
TenantAccountJoin.account_id == str(owner.account_id),
)
.with_for_update()
)
if membership_id is None:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.INVALID_OWNER)
@staticmethod
def _ensure_account_available(session: Session, contact: Contact) -> None:
account_id = contact.account_id
if account_id is None:
return
status = session.scalar(select(Account.status).where(Account.id == str(account_id)))
if status is not AccountStatus.ACTIVE:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.ACCOUNT_UNAVAILABLE)
@staticmethod
def _ensure_identity_available(session: Session, contact: Contact) -> None:
identity_predicates: list[sa.ColumnElement[bool]] = []
if contact.account_id is not None:
identity_predicates.append(
sa.and_(
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
HumanInputContact.account_id == str(contact.account_id),
)
)
if contact.normalized_email is not None:
identity_predicates.append(
sa.and_(
SQLAlchemyContactDirectoryRepository._email_identity_scope_predicate(contact),
HumanInputContact.normalized_email == str(contact.normalized_email),
)
)
if not identity_predicates:
return
conflict_id = session.scalar(
select(HumanInputContact.id).where(
HumanInputContact.id != str(contact.id),
or_(*identity_predicates),
)
)
if conflict_id is not None:
raise SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.CONFLICTING_IDENTITY)
@staticmethod
def _email_identity_scope_predicate(contact: Contact) -> sa.ColumnElement[bool]:
if isinstance(contact.owner, OrganizationAccountOwner):
return or_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.identity_source == HumanInputContactIdentitySource.EXTERNAL,
)
if isinstance(contact.owner, ExternalContactOwner):
return or_(
HumanInputContact.tenant_id == str(contact.owner.workspace_id),
sa.and_(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.identity_source == HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT,
),
)
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
@staticmethod
def _insert_platform_entry_idempotently(session: Session, entry: PlatformWorkspaceEntry) -> None:
record = platform_entry_to_record(entry)
values = {
"id": record.id,
"tenant_id": record.tenant_id,
"contact_id": record.contact_id,
"added_by_account_id": record.added_by_account_id,
"created_at": record.created_at,
"updated_at": record.updated_at,
}
dialect_name = session.get_bind().dialect.name
if dialect_name == "postgresql":
postgresql_statement = postgresql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(postgresql_statement.on_conflict_do_nothing(constraint="hipcwe_tenant_contact_uq"))
return
if dialect_name == "mysql":
mysql_statement = mysql_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(mysql_statement.on_duplicate_key_update(contact_id=mysql_statement.inserted.contact_id))
return
if dialect_name == "sqlite":
sqlite_statement = sqlite_insert(HumanInputPlatformContactWorkspaceEntry).values(**values)
session.execute(sqlite_statement.on_conflict_do_nothing(index_elements=["tenant_id", "contact_id"]))
return
raise SQLAlchemyContactDirectoryRepository._persistence_error()
@staticmethod
def _find_owned_record(session: Session, contact: Contact) -> HumanInputContact | None:
return session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact.id),
SQLAlchemyContactDirectoryRepository._owner_predicate(contact),
)
)
@staticmethod
def _owner_predicate(contact: Contact) -> sa.ColumnElement[bool]:
if isinstance(contact.owner, OrganizationAccountOwner):
return HumanInputContact.tenant_id.is_(None)
return HumanInputContact.tenant_id == str(contact.owner.workspace_id)
@staticmethod
def _record_has_same_identity(record: HumanInputContact, contact: Contact) -> bool:
expected_source = HumanInputContactIdentitySource(contact.identity_source.value)
expected_account_id = str(contact.account_id) if contact.account_id is not None else None
return record.identity_source is expected_source and record.account_id == expected_account_id
@staticmethod
def _copy_mutable_values(record: HumanInputContact, contact: Contact) -> None:
record.name = contact.name
record.normalized_name = contact.normalized_name
record.email = contact.email
record.normalized_email = str(contact.normalized_email) if contact.normalized_email is not None else None
record.avatar_file_id = contact.avatar_file_id
record.updated_at = contact.updated_at.value
@staticmethod
def _domain_error(code: ContactRejectionCode) -> ContactDirectoryError:
from core.human_input_v2.contact_directory import ContactRejection
return ContactDirectoryError(ContactRejection(code))
@staticmethod
def _persistence_error() -> ContactDirectoryError:
return SQLAlchemyContactDirectoryRepository._domain_error(ContactRejectionCode.PERSISTENCE_FAILURE)
@@ -0,0 +1,5 @@
"""SQLAlchemy persistence adapter for the Human Input v2 form aggregate."""
from .repository import FormPersistenceError, SQLAlchemyFormRepository
__all__ = ["FormPersistenceError", "SQLAlchemyFormRepository"]
@@ -0,0 +1,567 @@
"""Explicit mapping between Form Core domain values and ORM records.
Every structured JSON value is validated at this boundary. ORM records never
escape into the form domain, and mutable primitive containers are created only
for immediate Pydantic serialization.
"""
from __future__ import annotations
from datetime import UTC, datetime
from typing import assert_never
from pydantic import JsonValue, TypeAdapter
from core.human_input_v2.approval import (
ApprovalSubject,
ApproverGrant,
CanonicalSubjectKey,
ConsoleEndpointConfiguration,
ContactApprovalSubject,
DeliveryAttempt,
DeliveryEndpoint,
DeliveryEndpointConfiguration,
EmailAddressApprovalSubject,
EmailEndpointConfiguration,
EmailProviderConfiguration,
EndpointAccessCapability,
EndUserApprovalSubject,
FormRef,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONObject,
HumanInputForm,
IMEndpointConfiguration,
MatchedRecipientSource,
SubjectSnapshot,
UploadCapability,
UploadFileAssociation,
WebEndpointConfiguration,
)
from core.human_input_v2.entities import HumanInputApproverGrantSubjectType, HumanInputDeliveryChannel
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
ContactId,
DeliveryAttemptId,
DeliveryEndpointId,
EmailProviderId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
)
from core.workflow.nodes.human_input.entities import FormInputConfig, UserActionConfig
from models.base import DefaultFieldsDCMixin
from models.human_input_v2 import (
FormApproverGrantMatchedSource,
FormApproverGrantMatchedSources,
FormApproverGrantSubjectSnapshot,
FormDeliveryProviderResponse,
HumanInputEmailProvider,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDefinition,
HumanInputV2FormDeliveryAttempt,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormUploadFile,
HumanInputV2FormUploadToken,
ResendEmailProviderEncryptedCredentials,
)
_FORM_INPUT_ADAPTER: TypeAdapter[FormInputConfig] = TypeAdapter(FormInputConfig)
_JSON_OBJECT_ADAPTER: TypeAdapter[dict[str, JsonValue]] = TypeAdapter(dict[str, JsonValue])
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def _set_record_identity(
record: DefaultFieldsDCMixin,
*,
record_id: str,
created_at: UtcTimestamp,
updated_at: UtcTimestamp,
) -> None:
# SQLAlchemy's mapped dataclass mixin owns these fields on every record type.
record.id = record_id
record.created_at = created_at.value
record.updated_at = updated_at.value
def _definition_to_record_value(definition: FrozenFormDefinition) -> HumanInputV2FormDefinition:
inputs = tuple(_FORM_INPUT_ADAPTER.validate_python(item.to_mapping()) for item in definition.inputs)
actions = tuple(
UserActionConfig.model_validate(
{"id": action.id, "title": action.title, "button_style": action.button_style},
)
for action in definition.actions
)
default_values = _JSON_OBJECT_ADAPTER.validate_python(definition.default_values.to_mapping())
return HumanInputV2FormDefinition(
form_content=definition.form_content,
inputs=inputs,
user_actions=actions,
default_values=default_values,
node_title=definition.node_title,
display_in_ui=definition.display_in_ui,
)
def _definition_from_record_value(definition: HumanInputV2FormDefinition) -> FrozenFormDefinition:
return FrozenFormDefinition(
form_content=definition.form_content,
inputs=tuple(
FrozenJSONObject.from_mapping(_JSON_OBJECT_ADAPTER.validate_python(item.model_dump(mode="json")))
for item in definition.inputs
),
actions=tuple(
FrozenFormAction(
id=action.id,
title=action.title,
button_style=action.button_style.value,
)
for action in definition.user_actions
),
default_values=FrozenJSONObject.from_mapping(definition.default_values),
node_title=definition.node_title,
display_in_ui=definition.display_in_ui,
)
def form_to_record(form: HumanInputForm) -> HumanInputV2Form:
"""Map one form root into a detached record without child side effects."""
record = HumanInputV2Form(
tenant_id=str(form.ref.workspace_id),
app_id=str(form.app_id),
form_definition=_definition_to_record_value(form.definition),
rendered_content=form.rendered_content,
node_timeout_at=form.node_timeout_at.value,
global_expires_at=form.global_expires_at.value,
form_kind=form.kind,
status=form.status,
workflow_pause_id=form.workflow_pause_id,
node_execution_id=form.node_execution_id,
)
_set_record_identity(
record,
record_id=str(form.ref.form_id),
created_at=form.created_at,
updated_at=form.updated_at,
)
return record
def form_from_record(
record: HumanInputV2Form,
grant_records: tuple[HumanInputV2FormApproverGrant, ...],
) -> HumanInputForm:
"""Rebuild one lifecycle-ready aggregate from an explicitly loaded grant graph."""
form_ref = FormRef(WorkspaceId(record.tenant_id), FormId(record.id))
grants = tuple(grant_from_record(grant_record) for grant_record in grant_records)
if any(grant.ref.form_ref != form_ref for grant in grants):
raise ValueError("loaded grant does not belong to the form record")
return HumanInputForm(
ref=form_ref,
app_id=AppId(record.app_id),
definition=_definition_from_record_value(record.form_definition),
rendered_content=record.rendered_content,
node_timeout_at=_timestamp(record.node_timeout_at),
global_expires_at=_timestamp(record.global_expires_at),
kind=record.form_kind,
status=record.status,
workflow_pause_id=record.workflow_pause_id,
node_execution_id=record.node_execution_id,
grants=grants,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def grant_to_record(grant: ApproverGrant) -> HumanInputV2FormApproverGrant:
"""Map one historical grant into a detached persistence record."""
contact_id: str | None = None
end_user_id: str | None = None
normalized_email: str | None = None
match grant.subject:
case ContactApprovalSubject(contact_id=subject_contact_id):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(subject_contact_id)
case EndUserApprovalSubject(end_user_id=subject_end_user_id):
subject_type = HumanInputApproverGrantSubjectType.END_USER
end_user_id = str(subject_end_user_id)
case EmailAddressApprovalSubject(normalized_email=subject_email):
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
normalized_email = str(subject_email)
case _:
assert_never(grant.subject)
record = HumanInputV2FormApproverGrant(
tenant_id=str(grant.ref.form_ref.workspace_id),
form_id=str(grant.ref.form_ref.form_id),
subject_type=subject_type,
subject_key=grant.subject_key.value,
matched_sources=FormApproverGrantMatchedSources(
sources=tuple(
FormApproverGrantMatchedSource(
kind=source.kind,
position=source.position,
reference=source.reference,
)
for source in grant.matched_sources
)
),
subject_snapshot=FormApproverGrantSubjectSnapshot(
display_name=grant.subject_snapshot.display_name,
email=grant.subject_snapshot.email,
),
contact_id=contact_id,
end_user_id=end_user_id,
normalized_email=normalized_email,
)
_set_record_identity(record, record_id=str(grant.id), created_at=grant.created_at, updated_at=grant.updated_at)
return record
def grant_from_record(record: HumanInputV2FormApproverGrant) -> ApproverGrant:
"""Map one validated grant record into a historical domain snapshot."""
subject: ApprovalSubject
match record.subject_type:
case HumanInputApproverGrantSubjectType.CONTACT:
if record.contact_id is None:
raise ValueError("contact grant record is missing contact_id")
subject = ContactApprovalSubject(ContactId(record.contact_id))
case HumanInputApproverGrantSubjectType.END_USER:
if record.end_user_id is None:
raise ValueError("end-user grant record is missing end_user_id")
subject = EndUserApprovalSubject(EndUserId(record.end_user_id))
case HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if record.normalized_email is None:
raise ValueError("email grant record is missing normalized_email")
subject = EmailAddressApprovalSubject(NormalizedEmail(record.normalized_email))
return ApproverGrant(
ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)).grant(ApproverGrantId(record.id)),
subject=subject,
subject_key=CanonicalSubjectKey(record.subject_key),
matched_sources=tuple(
MatchedRecipientSource(source.kind, source.position, source.reference)
for source in record.matched_sources.sources
),
subject_snapshot=SubjectSnapshot(
display_name=record.subject_snapshot.display_name,
email=record.subject_snapshot.email,
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def endpoint_to_record(endpoint: DeliveryEndpoint) -> HumanInputV2FormDeliveryEndpoint:
"""Map one discriminated endpoint configuration without raw dictionaries."""
email_address: str | None = None
integration_id: str | None = None
provider = None
provider_tenant_id: str | None = None
provider_user_id: str | None = None
im_identity_id: str | None = None
im_binding_id: str | None = None
match endpoint.configuration:
case EmailEndpointConfiguration(email_address=address):
email_address = str(address)
case IMEndpointConfiguration(
integration_id=integration,
provider=im_provider,
provider_tenant_id=tenant_identity,
identity_id=identity,
binding_id=binding,
provider_user_id=user_identity,
):
integration_id = str(integration)
provider = im_provider
provider_tenant_id = tenant_identity
provider_user_id = user_identity
im_identity_id = str(identity)
im_binding_id = str(binding) if binding is not None else None
case WebEndpointConfiguration() | ConsoleEndpointConfiguration():
pass
case _:
assert_never(endpoint.configuration)
record = HumanInputV2FormDeliveryEndpoint(
tenant_id=str(endpoint.ref.form_ref.workspace_id),
form_id=str(endpoint.ref.form_ref.form_id),
approver_grant_id=str(endpoint.ref.grant_ref.grant_id),
channel=endpoint.channel,
address_hash=endpoint.address_hash,
email_address=email_address,
integration_id=integration_id,
provider=provider,
provider_tenant_id=provider_tenant_id,
provider_user_id=provider_user_id,
im_identity_id=im_identity_id,
im_binding_id=im_binding_id,
access_token_hash=endpoint.access_capability.token_hash if endpoint.access_capability is not None else None,
)
_set_record_identity(
record, record_id=str(endpoint.id), created_at=endpoint.created_at, updated_at=endpoint.updated_at
)
return record
def endpoint_from_record(record: HumanInputV2FormDeliveryEndpoint) -> DeliveryEndpoint:
"""Reject malformed discriminated endpoint records instead of leaking nulls."""
endpoint_ref = (
FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id))
.grant(ApproverGrantId(record.approver_grant_id))
.endpoint(DeliveryEndpointId(record.id))
)
configuration: DeliveryEndpointConfiguration
match record.channel:
case HumanInputDeliveryChannel.EMAIL:
if record.email_address is None:
raise ValueError("email endpoint record is missing email_address")
if (
record.integration_id is not None
or record.provider is not None
or record.provider_tenant_id is not None
or record.provider_user_id is not None
or record.im_identity_id is not None
or record.im_binding_id is not None
):
raise ValueError("email endpoint record contains invalid channel configuration")
configuration = EmailEndpointConfiguration(NormalizedEmail(record.email_address))
case HumanInputDeliveryChannel.IM:
if record.email_address is not None:
raise ValueError("IM endpoint record contains invalid channel configuration")
if (
record.integration_id is None
or record.provider is None
or record.provider_tenant_id is None
or record.im_identity_id is None
or record.provider_user_id is None
):
raise ValueError("IM endpoint record is missing provider configuration")
configuration = IMEndpointConfiguration(
integration_id=IntegrationId(record.integration_id),
provider=record.provider,
provider_tenant_id=record.provider_tenant_id,
identity_id=IMIdentityId(record.im_identity_id),
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
provider_user_id=record.provider_user_id,
)
case HumanInputDeliveryChannel.WEB | HumanInputDeliveryChannel.CONSOLE:
if (
record.email_address is not None
or record.integration_id is not None
or record.provider is not None
or record.provider_tenant_id is not None
or record.provider_user_id is not None
or record.im_identity_id is not None
or record.im_binding_id is not None
):
raise ValueError(f"{record.channel.value} endpoint record contains invalid channel configuration")
configuration = (
WebEndpointConfiguration()
if record.channel is HumanInputDeliveryChannel.WEB
else ConsoleEndpointConfiguration()
)
case _:
raise ValueError(f"unsupported delivery channel: {record.channel!r}")
capability = (
EndpointAccessCapability(endpoint_ref, record.access_token_hash)
if record.access_token_hash is not None
else None
)
return DeliveryEndpoint(
ref=endpoint_ref,
configuration=configuration,
address_hash=record.address_hash,
access_capability=capability,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def delivery_attempt_to_record(attempt: DeliveryAttempt) -> HumanInputV2FormDeliveryAttempt:
response = (
FormDeliveryProviderResponse(attempt.provider_response.to_mapping())
if attempt.provider_response is not None
else None
)
record = HumanInputV2FormDeliveryAttempt(
tenant_id=str(attempt.endpoint_ref.form_ref.workspace_id),
form_id=str(attempt.endpoint_ref.form_ref.form_id),
endpoint_id=str(attempt.endpoint_ref.endpoint_id),
attempt_number=attempt.attempt_number,
status=attempt.status,
scheduled_at=attempt.scheduled_at.value,
started_at=attempt.started_at.value if attempt.started_at is not None else None,
finished_at=attempt.finished_at.value if attempt.finished_at is not None else None,
provider_message_id=attempt.provider_message_id,
failure_code=attempt.failure_code,
failure_reason=attempt.failure_reason,
provider_response=response,
)
_set_record_identity(
record, record_id=str(attempt.id), created_at=attempt.created_at, updated_at=attempt.updated_at
)
return record
def delivery_attempt_from_record(
record: HumanInputV2FormDeliveryAttempt,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> DeliveryAttempt:
endpoint = endpoint_from_record(endpoint_record)
if (
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
):
raise ValueError("delivery attempt owner does not match its endpoint record")
return DeliveryAttempt(
id=DeliveryAttemptId(record.id),
endpoint_ref=endpoint.ref,
attempt_number=record.attempt_number,
status=record.status,
scheduled_at=_timestamp(record.scheduled_at),
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
provider_message_id=record.provider_message_id,
failure_code=record.failure_code,
failure_reason=record.failure_reason,
provider_response=(
FrozenJSONObject.from_mapping(record.provider_response.root)
if record.provider_response is not None
else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def email_provider_to_record(provider: EmailProviderConfiguration) -> HumanInputEmailProvider:
credentials = ResendEmailProviderEncryptedCredentials.model_validate(
provider.encrypted_credentials.to_mapping(),
)
record = HumanInputEmailProvider(
provider=provider.provider,
sender_email=str(provider.sender_email),
encrypted_credentials=credentials,
tenant_id=str(provider.workspace_id),
sender_name=provider.sender_name,
configured_by_account_id=(
str(provider.configured_by_account_id) if provider.configured_by_account_id is not None else None
),
)
_set_record_identity(
record, record_id=str(provider.id), created_at=provider.created_at, updated_at=provider.updated_at
)
return record
def email_provider_from_record(record: HumanInputEmailProvider) -> EmailProviderConfiguration:
return EmailProviderConfiguration(
id=EmailProviderId(record.id),
workspace_id=WorkspaceId(record.tenant_id),
provider=record.provider,
sender_email=NormalizedEmail(record.sender_email),
sender_name=record.sender_name,
encrypted_credentials=FrozenJSONObject.from_mapping(
_JSON_OBJECT_ADAPTER.validate_python(record.encrypted_credentials.model_dump(mode="json"))
),
configured_by_account_id=(
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def upload_capability_to_record(capability: UploadCapability) -> HumanInputV2FormUploadToken:
record = HumanInputV2FormUploadToken(
tenant_id=str(capability.endpoint_ref.form_ref.workspace_id),
app_id=str(capability.app_id),
form_id=str(capability.endpoint_ref.form_ref.form_id),
endpoint_id=str(capability.endpoint_ref.endpoint_id),
upload_token_hash=capability.token_hash,
)
_set_record_identity(
record, record_id=str(capability.id), created_at=capability.created_at, updated_at=capability.updated_at
)
return record
def upload_capability_from_record(
record: HumanInputV2FormUploadToken,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> UploadCapability:
endpoint = endpoint_from_record(endpoint_record)
if (
endpoint.ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or endpoint.ref.form_ref.form_id != FormId(record.form_id)
or endpoint.id != DeliveryEndpointId(record.endpoint_id)
):
raise ValueError("upload capability owner does not match its endpoint record")
return UploadCapability(
id=UploadCapabilityId(record.id),
endpoint_ref=endpoint.ref,
app_id=AppId(record.app_id),
token_hash=record.upload_token_hash,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def upload_file_to_record(association: UploadFileAssociation) -> HumanInputV2FormUploadFile:
endpoint_ref = association.capability_ref.endpoint_ref
record = HumanInputV2FormUploadFile(
tenant_id=str(endpoint_ref.form_ref.workspace_id),
app_id=str(association.capability_ref.app_id),
form_id=str(endpoint_ref.form_ref.form_id),
endpoint_id=str(endpoint_ref.endpoint_id),
upload_file_id=association.upload_file_id,
upload_token_id=str(association.capability_ref.capability_id),
)
_set_record_identity(
record,
record_id=str(association.id),
created_at=association.created_at,
updated_at=association.updated_at,
)
return record
def upload_file_from_record(
record: HumanInputV2FormUploadFile,
capability_record: HumanInputV2FormUploadToken,
endpoint_record: HumanInputV2FormDeliveryEndpoint,
) -> UploadFileAssociation:
capability = upload_capability_from_record(capability_record, endpoint_record)
if (
capability.id != UploadCapabilityId(record.upload_token_id)
or capability.endpoint_ref.form_ref.workspace_id != WorkspaceId(record.tenant_id)
or capability.endpoint_ref.form_ref.form_id != FormId(record.form_id)
or capability.endpoint_ref.endpoint_id != DeliveryEndpointId(record.endpoint_id)
or capability.app_id != AppId(record.app_id)
):
raise ValueError("uploaded file owner does not match its capability record")
return UploadFileAssociation(
id=UploadFileAssociationId(record.id),
capability_ref=capability.ref,
upload_file_id=record.upload_file_id,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
@@ -0,0 +1,280 @@
"""SQLAlchemy Human Input v2 Form adapter.
The public operations own their transaction and eager-loading shape. Every
query carries the complete workspace/form/child owner predicates because domain
references are navigation values, not authorization tokens. ORM instances never
cross this boundary.
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import SQLAlchemyError
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.approval import (
DeliveryAttempt,
DeliveryEndpointRef,
FormCreation,
FormDefinitionProjection,
FormDeliveryProjection,
FormRef,
HumanInputForm,
UploadCapability,
UploadFileAssociation,
)
from core.human_input_v2.shared import WorkspaceId
from models.human_input_v2 import (
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormUploadToken,
)
from models.model import UploadFile
from .mappers import (
delivery_attempt_from_record,
delivery_attempt_to_record,
endpoint_from_record,
endpoint_to_record,
form_from_record,
form_to_record,
grant_from_record,
grant_to_record,
upload_capability_from_record,
upload_capability_to_record,
upload_file_from_record,
upload_file_to_record,
)
class FormPersistenceError(RuntimeError):
"""A Form Core persistence operation could not preserve its contract."""
class SQLAlchemyFormRepository:
"""Transactional, operation-oriented adapter for one form aggregate boundary."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def create_form(self, creation: FormCreation) -> HumanInputForm:
"""Persist the complete form/grant/endpoint snapshot in one transaction."""
try:
with self._session_maker() as session, session.begin():
session.add(form_to_record(creation.form))
session.add_all(grant_to_record(grant) for grant in creation.form.grants)
session.add_all(endpoint_to_record(endpoint) for endpoint in creation.endpoints)
session.flush()
return creation.form
except SQLAlchemyError as error:
raise FormPersistenceError("failed to create Human Input form snapshot") from error
def load_for_lifecycle(self, form_ref: FormRef) -> HumanInputForm | None:
"""Load one form and its grant membership in a fixed two-query shape."""
try:
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputV2Form)
.options(selectinload(HumanInputV2Form.grants))
.where(
HumanInputV2Form.tenant_id == str(form_ref.workspace_id),
HumanInputV2Form.id == str(form_ref.form_id),
)
)
if record is None:
return None
return form_from_record(record, tuple(record.grants))
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input form lifecycle state") from error
def load_delivery_projection(self, endpoint_ref: DeliveryEndpointRef) -> FormDeliveryProjection | None:
"""Load exactly one endpoint, grant, and form for delivery."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(self._endpoint_graph_statement(endpoint_ref)).one_or_none()
if row is None:
return None
endpoint_record, grant_record, form_record = row
form = form_from_record(form_record, (grant_record,))
return FormDeliveryProjection(
form_ref=form.ref,
grant=grant_from_record(grant_record),
endpoint=endpoint_from_record(endpoint_record),
definition=form.definition,
rendered_content=form.rendered_content,
)
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input delivery projection") from error
def load_definition_by_endpoint_token(
self,
*,
workspace_id: WorkspaceId,
token_hash: str,
) -> FormDefinitionProjection | None:
"""Resolve a token to a read model without returning grant authority."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(
self._endpoint_graph_base().where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(workspace_id),
HumanInputV2FormDeliveryEndpoint.access_token_hash == token_hash,
)
).one_or_none()
if row is None:
return None
endpoint_record, grant_record, form_record = row
form = form_from_record(form_record, (grant_record,))
return FormDefinitionProjection(
form_ref=form.ref,
endpoint_ref=endpoint_from_record(endpoint_record).ref,
definition=form.definition,
rendered_content=form.rendered_content,
status=form.status,
node_timeout_at=form.node_timeout_at,
global_expires_at=form.global_expires_at,
)
except SQLAlchemyError as error:
raise FormPersistenceError("failed to load Human Input token projection") from error
def append_delivery_attempt(self, attempt: DeliveryAttempt) -> DeliveryAttempt:
"""Append one attempt after validating its full endpoint owner chain."""
try:
with self._session_maker() as session, session.begin():
endpoint_record = self._load_endpoint_record(session, attempt.endpoint_ref)
if endpoint_record is None:
raise ValueError("delivery attempt endpoint scope does not exist")
record = delivery_attempt_to_record(attempt)
session.add(record)
session.flush()
return delivery_attempt_from_record(record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to append Human Input delivery attempt") from error
def create_upload_capability(self, capability: UploadCapability) -> UploadCapability:
"""Persist one capability only when endpoint and app ownership match."""
try:
with self._session_maker() as session, session.begin():
row = session.execute(self._endpoint_graph_statement(capability.endpoint_ref)).one_or_none()
if row is None:
raise ValueError("upload capability endpoint scope does not exist")
endpoint_record, _grant_record, form_record = row
if form_record.app_id != str(capability.app_id):
raise ValueError("upload capability app scope does not match the form")
record = upload_capability_to_record(capability)
session.add(record)
session.flush()
return upload_capability_from_record(record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to create Human Input upload capability") from error
def associate_upload_file(self, association: UploadFileAssociation) -> UploadFileAssociation:
"""Persist a file only after resolving its token and UploadFile workspace owners."""
capability_ref = association.capability_ref
endpoint_ref = capability_ref.endpoint_ref
try:
with self._session_maker() as session, session.begin():
row = session.execute(
select(HumanInputV2FormUploadToken, HumanInputV2FormDeliveryEndpoint)
.join(
HumanInputV2FormDeliveryEndpoint,
sa.and_(
HumanInputV2FormDeliveryEndpoint.id == HumanInputV2FormUploadToken.endpoint_id,
HumanInputV2FormDeliveryEndpoint.tenant_id == HumanInputV2FormUploadToken.tenant_id,
HumanInputV2FormDeliveryEndpoint.form_id == HumanInputV2FormUploadToken.form_id,
),
)
.join(
HumanInputV2Form,
sa.and_(
HumanInputV2Form.id == HumanInputV2FormUploadToken.form_id,
HumanInputV2Form.tenant_id == HumanInputV2FormUploadToken.tenant_id,
HumanInputV2Form.app_id == HumanInputV2FormUploadToken.app_id,
),
)
.where(
HumanInputV2FormUploadToken.id == str(capability_ref.capability_id),
HumanInputV2FormUploadToken.tenant_id == str(endpoint_ref.form_ref.workspace_id),
HumanInputV2FormUploadToken.app_id == str(capability_ref.app_id),
HumanInputV2FormUploadToken.form_id == str(endpoint_ref.form_ref.form_id),
HumanInputV2FormUploadToken.endpoint_id == str(endpoint_ref.endpoint_id),
)
).one_or_none()
if row is None:
raise ValueError("upload file capability scope does not match")
capability_record, endpoint_record = row
upload_file_id = session.scalar(
select(UploadFile.id).where(
UploadFile.id == association.upload_file_id,
UploadFile.tenant_id == str(endpoint_ref.form_ref.workspace_id),
)
)
if upload_file_id is None:
raise ValueError("upload file workspace scope does not exist")
record = upload_file_to_record(association)
session.add(record)
session.flush()
return upload_file_from_record(record, capability_record, endpoint_record)
except ValueError:
raise
except SQLAlchemyError as error:
raise FormPersistenceError("failed to associate Human Input uploaded file") from error
@staticmethod
def _endpoint_graph_base() -> sa.Select[
tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]
]:
return (
select(HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form)
.join(
HumanInputV2FormApproverGrant,
sa.and_(
HumanInputV2FormApproverGrant.id == HumanInputV2FormDeliveryEndpoint.approver_grant_id,
HumanInputV2FormApproverGrant.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
HumanInputV2FormApproverGrant.form_id == HumanInputV2FormDeliveryEndpoint.form_id,
),
)
.join(
HumanInputV2Form,
sa.and_(
HumanInputV2Form.id == HumanInputV2FormDeliveryEndpoint.form_id,
HumanInputV2Form.tenant_id == HumanInputV2FormDeliveryEndpoint.tenant_id,
),
)
)
@classmethod
def _endpoint_graph_statement(
cls,
endpoint_ref: DeliveryEndpointRef,
) -> sa.Select[tuple[HumanInputV2FormDeliveryEndpoint, HumanInputV2FormApproverGrant, HumanInputV2Form]]:
return cls._endpoint_graph_base().where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(endpoint_ref.form_ref.workspace_id),
HumanInputV2FormDeliveryEndpoint.form_id == str(endpoint_ref.form_ref.form_id),
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(endpoint_ref.grant_ref.grant_id),
HumanInputV2FormDeliveryEndpoint.id == str(endpoint_ref.endpoint_id),
)
@classmethod
def _load_endpoint_record(
cls,
session: Session,
endpoint_ref: DeliveryEndpointRef,
) -> HumanInputV2FormDeliveryEndpoint | None:
row = session.execute(cls._endpoint_graph_statement(endpoint_ref)).one_or_none()
return row[0] if row is not None else None
@@ -0,0 +1,5 @@
"""SQLAlchemy persistence adapter for the IM Control Plane domain."""
from .repository import SQLAlchemyIMControlPlaneRepository
__all__ = ["SQLAlchemyIMControlPlaneRepository"]
@@ -0,0 +1,338 @@
"""Explicit bidirectional mappings for IM domain values and ORM records."""
from datetime import UTC, datetime
from pydantic import TypeAdapter
from core.human_input_v2.entities import IMBindingScope, IMSyncRemovalReason
from core.human_input_v2.im_integration import (
EncryptedCredentials,
IMBinding,
IMIdentity,
IMIntegration,
IMSyncRun,
IntegrationRevisionToken,
OpaqueProviderPayload,
ProviderTenantIdentity,
SyncContactSnapshot,
SyncIdentitySnapshot,
SyncResultFact,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
IMIdentityRawPayload,
IMIntegrationEncryptedCredentials,
IMSyncContactSnapshot,
IMSyncDirectoryEntryPayload,
IMSyncIdentitySnapshot,
)
_CREDENTIAL_ADAPTER: TypeAdapter[IMIntegrationEncryptedCredentials] = TypeAdapter(IMIntegrationEncryptedCredentials)
def _timestamp(value: datetime) -> UtcTimestamp:
"""Interpret database-naive timestamps as UTC, matching Dify persistence."""
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def integration_from_record(record: HumanInputIMIntegration) -> IMIntegration:
"""Map one Integration record into its CAS aggregate."""
if record.provider_tenant_id is None:
raise ValueError("integration record is missing provider_tenant_id")
credential_values = record.encrypted_credentials.model_dump(mode="json", exclude_none=True)
credential_values.pop("provider", None)
return IMIntegration(
id=IntegrationId(record.id),
workspace_id=WorkspaceId(record.tenant_id) if record.tenant_id is not None else None,
provider_tenant=ProviderTenantIdentity(record.provider, record.provider_tenant_id),
encrypted_credentials=EncryptedCredentials.from_mapping(credential_values),
configured_by_account_id=(
AccountId(record.configured_by_account_id) if record.configured_by_account_id is not None else None
),
callback_url=record.callback_url,
config_version=record.config_version,
status=record.status,
safe_status_reason=record.safe_status_reason,
last_checked_at=_timestamp(record.last_checked_at) if record.last_checked_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def integration_to_record(integration: IMIntegration) -> HumanInputIMIntegration:
"""Map one Integration aggregate into a detached persistence record."""
credential_values = integration.encrypted_credentials.to_mapping()
credential_values["provider"] = integration.provider_tenant.provider.value
credentials = _CREDENTIAL_ADAPTER.validate_python(credential_values)
record = HumanInputIMIntegration(
provider=integration.provider_tenant.provider,
encrypted_credentials=credentials,
tenant_id=str(integration.workspace_id) if integration.workspace_id is not None else None,
provider_tenant_id=integration.provider_tenant.provider_tenant_id,
status=integration.status,
config_version=integration.config_version,
configured_by_account_id=(
str(integration.configured_by_account_id) if integration.configured_by_account_id is not None else None
),
callback_url=integration.callback_url,
safe_status_reason=integration.safe_status_reason,
last_checked_at=integration.last_checked_at.value if integration.last_checked_at is not None else None,
)
record.id = str(integration.id)
record.created_at = integration.created_at.value
record.updated_at = integration.updated_at.value
return record
def identity_from_record(record: HumanInputIMIdentity) -> IMIdentity:
"""Map one current provider identity record into a domain value."""
return IMIdentity(
id=IMIdentityId(record.id),
integration_id=IntegrationId(record.integration_id),
provider=record.provider,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
normalized_name=record.normalized_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
raw_payload=OpaqueProviderPayload.from_mapping(record.raw_payload.root),
last_seen_sync_run_id=(
IMSyncRunId(record.last_seen_sync_run_id) if record.last_seen_sync_run_id is not None else None
),
last_seen_at=_timestamp(record.last_seen_at) if record.last_seen_at is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def identity_to_record(identity: IMIdentity) -> HumanInputIMIdentity:
"""Map one current provider identity into a detached record."""
record = HumanInputIMIdentity(
integration_id=str(identity.integration_id),
provider=identity.provider,
provider_user_id=identity.provider_user_id,
display_name=identity.display_name,
normalized_name=identity.normalized_name,
email=identity.email,
normalized_email=str(identity.normalized_email) if identity.normalized_email is not None else None,
raw_payload=IMIdentityRawPayload(identity.raw_payload.to_mapping()),
last_seen_sync_run_id=(
str(identity.last_seen_sync_run_id) if identity.last_seen_sync_run_id is not None else None
),
last_seen_at=identity.last_seen_at.value if identity.last_seen_at is not None else None,
)
record.id = str(identity.id)
record.created_at = identity.created_at.value
record.updated_at = identity.updated_at.value
return record
def binding_from_record(record: HumanInputIMBinding) -> IMBinding:
"""Map one current binding record into a domain value."""
return IMBinding(
id=IMBindingId(record.id),
integration_id=IntegrationId(record.integration_id),
scope=IMBindingScope(record.scope),
scope_id=record.scope_id,
contact_id=ContactId(record.contact_id),
identity_id=IMIdentityId(record.im_identity_id),
provider=record.provider,
bound_by_account_id=AccountId(record.bound_by_account_id) if record.bound_by_account_id is not None else None,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def binding_to_record(binding: IMBinding) -> HumanInputIMBinding:
"""Map one current binding into a detached record."""
record = HumanInputIMBinding(
integration_id=str(binding.integration_id),
scope=binding.scope,
scope_id=binding.scope_id,
contact_id=str(binding.contact_id),
im_identity_id=str(binding.identity_id),
provider=binding.provider,
bound_by_account_id=str(binding.bound_by_account_id) if binding.bound_by_account_id is not None else None,
)
record.id = str(binding.id)
record.created_at = binding.created_at.value
record.updated_at = binding.updated_at.value
return record
def sync_run_from_record(record: HumanInputIMSyncRun) -> IMSyncRun:
"""Map one sync run record into its independent aggregate."""
return IMSyncRun(
id=IMSyncRunId(record.id),
integration_revision=IntegrationRevisionToken(
IntegrationId(record.integration_id), record.integration_config_version
),
provider=record.provider,
status=record.status,
added_count=record.added_count,
not_matched_count=record.not_matched_count,
failed_count=record.failed_count,
removed_count=record.removed_count,
skipped_count=record.skipped_count,
started_by_account_id=(
AccountId(record.started_by_account_id) if record.started_by_account_id is not None else None
),
started_at=_timestamp(record.started_at) if record.started_at is not None else None,
finished_at=_timestamp(record.finished_at) if record.finished_at is not None else None,
error_code=record.error_code,
error_message=record.error_message,
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def sync_run_to_record(run: IMSyncRun) -> HumanInputIMSyncRun:
"""Map one sync aggregate into a detached record."""
record = HumanInputIMSyncRun(
integration_id=str(run.integration_revision.integration_id),
integration_config_version=run.integration_revision.config_version,
provider=run.provider,
status=run.status,
added_count=run.added_count,
not_matched_count=run.not_matched_count,
failed_count=run.failed_count,
removed_count=run.removed_count,
skipped_count=run.skipped_count,
started_by_account_id=str(run.started_by_account_id) if run.started_by_account_id is not None else None,
started_at=run.started_at.value if run.started_at is not None else None,
finished_at=run.finished_at.value if run.finished_at is not None else None,
error_code=run.error_code,
error_message=run.error_message,
)
record.id = str(run.id)
record.created_at = run.created_at.value
record.updated_at = run.updated_at.value
return record
def sync_result_from_record(record: HumanInputIMSyncResult) -> SyncResultFact:
"""Map one append-only result record into an immutable domain fact."""
contact_snapshot = record.contact_snapshot
identity_snapshot = record.identity_snapshot
return SyncResultFact(
id=IMSyncResultId(record.id),
integration_id=IntegrationId(record.integration_id),
sync_run_id=IMSyncRunId(record.sync_run_id),
result_type=record.result_type,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
normalized_email=NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None,
contact_id=ContactId(record.contact_id) if record.contact_id is not None else None,
identity_id=IMIdentityId(record.im_identity_id) if record.im_identity_id is not None else None,
binding_id=IMBindingId(record.im_binding_id) if record.im_binding_id is not None else None,
removal_reason=IMSyncRemovalReason(record.removal_reason) if record.removal_reason is not None else None,
reason_code=record.reason_code,
reason_message=record.reason_message,
directory_entry_payload=(
OpaqueProviderPayload.from_mapping(record.directory_entry_payload.root)
if record.directory_entry_payload is not None
else None
),
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_snapshot.contact_id),
name=contact_snapshot.name,
email=contact_snapshot.email,
avatar_file_id=contact_snapshot.avatar_file_id,
)
if contact_snapshot is not None
else None
),
identity_snapshot=(
SyncIdentitySnapshot(
identity_id=IMIdentityId(identity_snapshot.identity_id),
provider=identity_snapshot.provider,
provider_user_id=identity_snapshot.provider_user_id,
display_name=identity_snapshot.display_name,
email=identity_snapshot.email,
)
if identity_snapshot is not None
else None
),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def sync_result_to_record(result: SyncResultFact) -> HumanInputIMSyncResult:
"""Map one immutable sync result fact into a detached record."""
contact_snapshot = result.contact_snapshot
identity_snapshot = result.identity_snapshot
record = HumanInputIMSyncResult(
integration_id=str(result.integration_id),
sync_run_id=str(result.sync_run_id),
result_type=result.result_type,
provider_user_id=result.provider_user_id,
display_name=result.display_name,
email=result.email,
normalized_email=str(result.normalized_email) if result.normalized_email is not None else None,
contact_id=str(result.contact_id) if result.contact_id is not None else None,
im_identity_id=str(result.identity_id) if result.identity_id is not None else None,
im_binding_id=str(result.binding_id) if result.binding_id is not None else None,
removal_reason=result.removal_reason,
reason_code=result.reason_code,
reason_message=result.reason_message,
directory_entry_payload=(
IMSyncDirectoryEntryPayload(result.directory_entry_payload.to_mapping())
if result.directory_entry_payload is not None
else None
),
contact_snapshot=(
IMSyncContactSnapshot(
contact_id=str(contact_snapshot.contact_id),
name=contact_snapshot.name,
email=contact_snapshot.email,
avatar_file_id=contact_snapshot.avatar_file_id,
)
if contact_snapshot is not None
else None
),
identity_snapshot=(
IMSyncIdentitySnapshot(
identity_id=str(identity_snapshot.identity_id),
provider=identity_snapshot.provider,
provider_user_id=identity_snapshot.provider_user_id,
display_name=identity_snapshot.display_name,
email=identity_snapshot.email,
)
if identity_snapshot is not None
else None
),
)
record.id = str(result.id)
record.created_at = result.created_at.value
record.updated_at = result.updated_at.value
return record
@@ -0,0 +1,689 @@
"""SQLAlchemy IM Control Plane adapter.
Configuration transitions, active-run creation, and reconciliation apply each
own their complete transaction. Integration locks serialize single-active-run
decisions. Deployment-wide creation additionally locks the stable ``DifySetup``
owner because nullable uniqueness cannot enforce the EE singleton. ORM records
never cross this boundary, and aggregate relationships are loaded explicitly
because their model relationships use ``lazy="raise"``.
"""
from __future__ import annotations
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.orm import Session, selectinload, sessionmaker
from core.human_input_v2.contact_directory import ContactSnapshot
from core.human_input_v2.entities import (
IMBindingScope,
IMProvider,
IMSyncRemovalReason,
IMSyncResultType,
IMSyncRunStatus,
)
from core.human_input_v2.im_integration import (
ActiveRunDecision,
ActiveRunDecisionKind,
ApplyReconciliationResult,
ApplyReconciliationStatus,
BindingResolutionKind,
BindingResolutionResult,
ConfigurationTransition,
ConfigurationTransitionKind,
EffectiveBindingResolver,
IMBinding,
IMIdentity,
IMIntegration,
IMIntegrationState,
IMSyncRun,
IntegrationDeletion,
IntegrationRevisionToken,
MatchKind,
ReconciliationAction,
ReconciliationPlan,
ReconciliationSnapshot,
StaleRevision,
SyncContactSnapshot,
SyncIdentitySnapshot,
SyncResultFact,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncResultId,
IMSyncRunId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
)
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus
from models.human_input_v2 import (
HumanInputContact,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
)
from models.model import DifySetup
from repositories.human_input_v2.contact_directory.mappers import contact_from_record
from .mappers import (
binding_from_record,
binding_to_record,
identity_from_record,
identity_to_record,
integration_from_record,
integration_to_record,
sync_result_from_record,
sync_result_to_record,
sync_run_from_record,
sync_run_to_record,
)
class SQLAlchemyIMControlPlaneRepository:
"""Transactional adapter for configuration, sync, and binding invariants."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
def create_integration(self, integration: IMIntegration) -> IMIntegration:
"""Create the first configuration after serializing its owner scope."""
with self._session_maker() as session, session.begin():
if integration.workspace_id is None:
self._lock_deployment_owner(session)
existing_deployment_integration_id = session.scalar(
select(HumanInputIMIntegration.id).where(HumanInputIMIntegration.tenant_id.is_(None)).limit(1)
)
if existing_deployment_integration_id is not None:
raise ValueError("deployment-wide IM integration already exists")
record = integration_to_record(integration)
session.add(record)
session.flush()
return integration_from_record(record)
def compare_and_swap_configuration(self, transition: ConfigurationTransition) -> IMIntegration | StaleRevision:
"""Apply a complete-token rotation or replacement in one transaction."""
with self._session_maker() as session, session.begin():
current = session.scalar(self._locked_integration_statement(transition.expected_revision))
if current is None:
return StaleRevision(
transition.expected_revision,
self._current_revision(session, transition.expected_revision.integration_id),
)
if transition.kind is ConfigurationTransitionKind.CREDENTIAL_ROTATION:
self._copy_integration_values(current, transition.integration)
session.flush()
return integration_from_record(current)
session.execute(
sa.delete(HumanInputIMBinding).where(
HumanInputIMBinding.integration_id == str(transition.expected_revision.integration_id)
)
)
session.execute(
sa.delete(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == str(transition.expected_revision.integration_id)
)
)
session.delete(current)
session.flush()
replacement = integration_to_record(transition.integration)
session.add(replacement)
session.flush()
return integration_from_record(replacement)
def compare_and_swap_delete(self, deletion: IntegrationDeletion | StaleRevision) -> None | StaleRevision:
"""Delete current configuration and current children under complete CAS."""
if isinstance(deletion, StaleRevision):
return deletion
with self._session_maker() as session, session.begin():
current = session.scalar(self._locked_integration_statement(deletion.expected_revision))
if current is None:
return StaleRevision(
deletion.expected_revision,
self._current_revision(session, deletion.expected_revision.integration_id),
)
integration_id = str(deletion.expected_revision.integration_id)
session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == integration_id))
session.execute(
sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id == integration_id)
)
session.delete(current)
return None
def create_or_get_active_run(
self,
integration_revision: IntegrationRevisionToken,
*,
sync_run_id: IMSyncRunId,
started_by_account_id: AccountId | None,
now: UtcTimestamp,
) -> ActiveRunDecision:
"""Serialize trigger decisions by locking the owning Integration row."""
with self._session_maker() as session, session.begin():
integration = session.scalar(self._locked_integration_statement(integration_revision))
if integration is None:
return ActiveRunDecision(
kind=ActiveRunDecisionKind.STALE_REVISION,
run=None,
stale_revision=StaleRevision(
integration_revision,
self._current_revision(session, integration_revision.integration_id),
),
)
existing = session.scalar(
select(HumanInputIMSyncRun)
.where(
HumanInputIMSyncRun.integration_id == str(integration_revision.integration_id),
HumanInputIMSyncRun.status.in_((IMSyncRunStatus.QUEUED, IMSyncRunStatus.RUNNING)),
)
.order_by(HumanInputIMSyncRun.created_at, HumanInputIMSyncRun.id)
.limit(1)
)
if existing is not None:
return ActiveRunDecision(ActiveRunDecisionKind.EXISTING_ACTIVE, sync_run_from_record(existing))
run = IMSyncRun.create(
sync_run_id=sync_run_id,
integration_revision=integration_revision,
provider=integration.provider,
started_by_account_id=started_by_account_id,
now=now,
)
record = sync_run_to_record(run)
session.add(record)
session.flush()
return ActiveRunDecision(ActiveRunDecisionKind.CREATED, sync_run_from_record(record))
def load_reconciliation_snapshot(self, sync_run_id: IMSyncRunId) -> ReconciliationSnapshot:
"""Load current identities, bindings, and owner-scoped Contact facts."""
with self._session_maker() as session, session.begin():
run = session.get_one(HumanInputIMSyncRun, str(sync_run_id))
integration = session.get_one(HumanInputIMIntegration, run.integration_id)
identity_records = session.scalars(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == run.integration_id,
HumanInputIMIdentity.provider == run.provider,
)
).all()
binding_records = session.scalars(
select(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == run.integration_id)
).all()
contact_statement = select(HumanInputContact)
if integration.tenant_id is not None:
contact_statement = contact_statement.where(
sa.or_(
HumanInputContact.tenant_id == integration.tenant_id,
HumanInputContact.tenant_id.is_(None),
)
)
contact_records = session.scalars(contact_statement).all()
account_ids = {record.account_id for record in contact_records if record.account_id is not None}
unavailable_account_ids = set(
session.scalars(
select(Account.id).where(
Account.id.in_(account_ids),
Account.status != AccountStatus.ACTIVE,
)
).all()
if account_ids
else ()
)
contacts = tuple(contact_from_record(record) for record in contact_records)
return ReconciliationSnapshot(
identities=tuple(identity_from_record(record) for record in identity_records),
bindings=tuple(binding_from_record(record) for record in binding_records),
contacts=tuple(
ContactSnapshot(
contact,
contact.account_id is None or str(contact.account_id) not in unavailable_account_ids,
)
for contact in contacts
),
)
def apply_reconciliation(self, plan: ReconciliationPlan, *, now: UtcTimestamp) -> ApplyReconciliationResult:
"""Idempotently apply one plan using the persisted run capture as authority."""
with self._session_maker() as session, session.begin():
run_record = session.scalar(
select(HumanInputIMSyncRun).where(HumanInputIMSyncRun.id == str(plan.sync_run_id)).with_for_update()
)
if run_record is None:
raise ValueError("sync run not found")
captured_revision = IntegrationRevisionToken(
IntegrationId(run_record.integration_id),
run_record.integration_config_version,
)
if plan.integration_revision != captured_revision:
raise ValueError("sync run revision does not match plan")
if plan.provider is not run_record.provider:
raise ValueError("sync run provider does not match plan")
existing_results = self._load_result_records(session, plan.sync_run_id)
if run_record.status in (IMSyncRunStatus.SUCCEEDED, IMSyncRunStatus.FAILED):
return ApplyReconciliationResult(
ApplyReconciliationStatus.ALREADY_APPLIED,
sync_run_from_record(run_record),
tuple(sync_result_from_record(record) for record in existing_results),
)
integration_record = session.scalar(self._locked_integration_statement(captured_revision))
if integration_record is None or integration_record.provider is not run_record.provider:
stale_result = self._stale_result(plan, now)
self._append_result_record(session, stale_result)
run_record.status = IMSyncRunStatus.FAILED
run_record.failed_count = 1
run_record.started_at = run_record.started_at or now.value
run_record.finished_at = now.value
run_record.error_code = "stale_integration_revision"
run_record.error_message = "Integration configuration changed before reconciliation apply."
run_record.updated_at = now.value
session.flush()
return ApplyReconciliationResult(
ApplyReconciliationStatus.STALE_REVISION,
sync_run_from_record(run_record),
(stale_result,),
)
results: list[SyncResultFact] = []
for action in plan.actions:
result = self._apply_action(session, plan, action, now)
self._append_result_record(session, result)
results.append(result)
for identity_id in plan.removed_identity_ids:
removal_results = self._remove_identity(session, plan, identity_id, now)
for removal_result in removal_results:
self._append_result_record(session, removal_result)
results.append(removal_result)
run_record.status = IMSyncRunStatus.SUCCEEDED
run_record.added_count = sum(result.result_type is IMSyncResultType.ADDED for result in results)
run_record.not_matched_count = sum(result.result_type is IMSyncResultType.NOT_MATCHED for result in results)
run_record.failed_count = sum(result.result_type is IMSyncResultType.FAILED for result in results)
run_record.removed_count = sum(result.result_type is IMSyncResultType.REMOVED for result in results)
run_record.skipped_count = sum(result.result_type is IMSyncResultType.SKIPPED for result in results)
run_record.started_at = run_record.started_at or now.value
run_record.finished_at = now.value
run_record.updated_at = now.value
session.flush()
return ApplyReconciliationResult(
ApplyReconciliationStatus.APPLIED,
sync_run_from_record(run_record),
tuple(results),
)
def load_integration_state(self, integration_id: IntegrationId) -> IMIntegrationState:
"""Eagerly load and map an Integration with all modeled child relationships."""
with self._session_maker() as session, session.begin():
record = session.scalar(
select(HumanInputIMIntegration)
.where(HumanInputIMIntegration.id == str(integration_id))
.options(
selectinload(HumanInputIMIntegration.identities).selectinload(HumanInputIMIdentity.bindings),
selectinload(HumanInputIMIntegration.sync_runs).selectinload(HumanInputIMSyncRun.results),
)
)
if record is None:
raise ValueError("integration not found")
identity_records = tuple(record.identities)
run_records = tuple(record.sync_runs)
return IMIntegrationState(
integration=integration_from_record(record),
identities=tuple(identity_from_record(item) for item in identity_records),
bindings=tuple(
binding_from_record(binding) for identity in identity_records for binding in identity.bindings
),
sync_runs=tuple(sync_run_from_record(item) for item in run_records),
sync_results=tuple(sync_result_from_record(result) for run in run_records for result in run.results),
)
def resolve_effective_binding(
self,
*,
integration_id: IntegrationId,
provider: IMProvider,
workspace_id: WorkspaceId,
contact_id: ContactId,
) -> BindingResolutionResult:
"""Validate Integration ownership before loading consumer-safe binding facts."""
with self._session_maker() as session, session.begin():
integration_record = session.scalar(
select(HumanInputIMIntegration).where(
HumanInputIMIntegration.id == str(integration_id),
sa.or_(
HumanInputIMIntegration.tenant_id == str(workspace_id),
HumanInputIMIntegration.tenant_id.is_(None),
),
)
)
if integration_record is None or integration_record.provider is not provider:
return BindingResolutionResult(BindingResolutionKind.INVALID_BINDING, None)
integration = integration_from_record(integration_record)
contact_record = session.scalar(
select(HumanInputContact).where(
HumanInputContact.id == str(contact_id),
sa.or_(HumanInputContact.tenant_id == str(workspace_id), HumanInputContact.tenant_id.is_(None)),
)
)
if contact_record is None:
return BindingResolutionResult(BindingResolutionKind.NOT_AVAILABLE, None)
contact = ContactSnapshot(contact_from_record(contact_record), True)
identities = tuple(
identity_from_record(record)
for record in session.scalars(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == str(integration_id),
HumanInputIMIdentity.provider == provider,
)
).all()
)
bindings = tuple(
binding_from_record(record)
for record in session.scalars(
select(HumanInputIMBinding).where(
HumanInputIMBinding.integration_id == str(integration_id),
HumanInputIMBinding.provider == provider,
)
).all()
)
return EffectiveBindingResolver.resolve(
integration_revision=integration.revision,
provider_tenant=integration.provider_tenant,
workspace_id=workspace_id,
contact=contact,
identities=identities,
bindings=bindings,
)
def append_sync_results(self, results: tuple[SyncResultFact, ...]) -> None:
"""Append diagnostic facts in their own explicit transaction."""
with self._session_maker() as session, session.begin():
for result in results:
self._append_result_record(session, result)
@staticmethod
def _locked_integration_statement(
revision: IntegrationRevisionToken,
) -> sa.Select[tuple[HumanInputIMIntegration]]:
"""Build the complete CAS predicate and row lock used by write paths."""
return (
select(HumanInputIMIntegration)
.where(
HumanInputIMIntegration.id == str(revision.integration_id),
HumanInputIMIntegration.config_version == revision.config_version,
)
.with_for_update()
)
@staticmethod
def _current_revision(session: Session, integration_id: IntegrationId) -> IntegrationRevisionToken | None:
record = session.get(HumanInputIMIntegration, str(integration_id))
if record is None:
return None
return IntegrationRevisionToken(IntegrationId(record.id), record.config_version)
@staticmethod
def _lock_deployment_owner(session: Session) -> None:
"""Serialize EE singleton decisions on the deployment's stable owner."""
setup_version = session.scalars(select(DifySetup.version).with_for_update()).one_or_none()
if setup_version is None:
raise ValueError("deployment setup row is required for deployment-wide IM integration")
@staticmethod
def _copy_integration_values(record: HumanInputIMIntegration, integration: IMIntegration) -> None:
mapped = integration_to_record(integration)
record.provider = mapped.provider
record.encrypted_credentials = mapped.encrypted_credentials
record.tenant_id = mapped.tenant_id
record.provider_tenant_id = mapped.provider_tenant_id
record.status = mapped.status
record.config_version = mapped.config_version
record.configured_by_account_id = mapped.configured_by_account_id
record.callback_url = mapped.callback_url
record.safe_status_reason = mapped.safe_status_reason
record.last_checked_at = mapped.last_checked_at
record.updated_at = mapped.updated_at
@staticmethod
def _load_result_records(session: Session, sync_run_id: IMSyncRunId) -> list[HumanInputIMSyncResult]:
return list(
session.scalars(
select(HumanInputIMSyncResult)
.where(HumanInputIMSyncResult.sync_run_id == str(sync_run_id))
.order_by(HumanInputIMSyncResult.created_at, HumanInputIMSyncResult.id)
).all()
)
def _apply_action(
self,
session: Session,
plan: ReconciliationPlan,
action: ReconciliationAction,
now: UtcTimestamp,
) -> SyncResultFact:
identity_record: HumanInputIMIdentity | None = None
binding_record: HumanInputIMBinding | None = None
contact_record: HumanInputContact | None = None
if action.identity_id is not None:
identity_record = session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.id == str(action.identity_id),
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
)
)
if identity_record is None:
raise ValueError("matched identity no longer exists")
self._copy_entry_to_identity(identity_record, action, plan, now)
if action.binding_id is not None:
binding_record = session.get(HumanInputIMBinding, str(action.binding_id))
elif action.match_kind is MatchKind.NORMALIZED_EMAIL and action.contact_id is not None:
identity = IMIdentity.create(
identity_id=IMIdentityId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
provider=plan.provider,
provider_user_id=action.entry.provider_user_id,
display_name=action.entry.display_name,
email=action.entry.email,
raw_payload=action.entry.raw_payload.to_mapping(),
last_seen_sync_run_id=plan.sync_run_id,
last_seen_at=now,
now=now,
)
identity_record = identity_to_record(identity)
session.add(identity_record)
binding = IMBinding.create(
binding_id=IMBindingId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
scope=IMBindingScope.ORGANIZATION,
scope_id=str(plan.integration_revision.integration_id),
contact_id=action.contact_id,
identity_id=identity.id,
provider=plan.provider,
bound_by_account_id=None,
now=now,
)
binding_record = binding_to_record(binding)
session.add(binding_record)
if action.contact_id is not None:
contact_record = session.get(HumanInputContact, str(action.contact_id))
result_type = (
IMSyncResultType.NOT_MATCHED
if action.match_kind is MatchKind.UNMATCHED
else IMSyncResultType.ADDED
if action.match_kind is MatchKind.NORMALIZED_EMAIL
else IMSyncResultType.SKIPPED
)
return SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=result_type,
provider_user_id=action.entry.provider_user_id,
display_name=action.entry.display_name,
email=action.entry.email,
normalized_email=action.entry.normalized_email,
contact_id=action.contact_id,
identity_id=IMIdentityId(identity_record.id) if identity_record is not None else None,
binding_id=IMBindingId(binding_record.id) if binding_record is not None else None,
removal_reason=None,
reason_code="existing_provider_identity" if result_type is IMSyncResultType.SKIPPED else None,
reason_message=None,
directory_entry_payload=action.entry.raw_payload,
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_record.id),
name=contact_record.name,
email=contact_record.email,
avatar_file_id=contact_record.avatar_file_id,
)
if contact_record is not None
else None
),
identity_snapshot=None,
created_at=now,
updated_at=now,
)
@staticmethod
def _copy_entry_to_identity(
record: HumanInputIMIdentity,
action: ReconciliationAction,
plan: ReconciliationPlan,
now: UtcTimestamp,
) -> None:
record.provider_user_id = action.entry.provider_user_id
record.display_name = action.entry.display_name
record.normalized_name = action.entry.display_name.casefold() if action.entry.display_name else None
record.email = action.entry.email
record.normalized_email = str(action.entry.normalized_email) if action.entry.normalized_email else None
from models.human_input_v2 import IMIdentityRawPayload
record.raw_payload = IMIdentityRawPayload(action.entry.raw_payload.to_mapping())
record.last_seen_sync_run_id = str(plan.sync_run_id)
record.last_seen_at = now.value
record.updated_at = now.value
def _remove_identity(
self,
session: Session,
plan: ReconciliationPlan,
identity_id: IMIdentityId,
now: UtcTimestamp,
) -> tuple[SyncResultFact, ...]:
record = session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.id == str(identity_id),
HumanInputIMIdentity.integration_id == str(plan.integration_revision.integration_id),
)
)
if record is None:
return ()
binding_records = tuple(
session.scalars(
select(HumanInputIMBinding)
.where(
HumanInputIMBinding.integration_id == str(plan.integration_revision.integration_id),
HumanInputIMBinding.im_identity_id == record.id,
)
.order_by(HumanInputIMBinding.created_at, HumanInputIMBinding.id)
).all()
)
bindings_for_results: tuple[HumanInputIMBinding | None, ...] = binding_records or (None,)
results: list[SyncResultFact] = []
for binding in bindings_for_results:
contact_record = session.get(HumanInputContact, binding.contact_id) if binding is not None else None
results.append(
SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=IMSyncResultType.REMOVED,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
normalized_email=(
NormalizedEmail(record.normalized_email) if record.normalized_email is not None else None
),
contact_id=ContactId(binding.contact_id) if binding is not None else None,
identity_id=identity_id,
binding_id=IMBindingId(binding.id) if binding is not None else None,
removal_reason=IMSyncRemovalReason.NOT_PRESENT_IN_DIRECTORY,
reason_code="not_present_in_directory",
reason_message=None,
directory_entry_payload=None,
contact_snapshot=(
SyncContactSnapshot(
contact_id=ContactId(contact_record.id),
name=contact_record.name,
email=contact_record.email,
avatar_file_id=contact_record.avatar_file_id,
)
if contact_record is not None
else None
),
identity_snapshot=SyncIdentitySnapshot(
identity_id=identity_id,
provider=record.provider,
provider_user_id=record.provider_user_id,
display_name=record.display_name,
email=record.email,
),
created_at=now,
updated_at=now,
)
)
for binding in binding_records:
session.delete(binding)
session.delete(record)
return tuple(results)
@staticmethod
def _stale_result(plan: ReconciliationPlan, now: UtcTimestamp) -> SyncResultFact:
return SyncResultFact(
id=IMSyncResultId(str(uuidv7())),
integration_id=plan.integration_revision.integration_id,
sync_run_id=plan.sync_run_id,
result_type=IMSyncResultType.FAILED,
provider_user_id=None,
display_name=None,
email=None,
normalized_email=None,
contact_id=None,
identity_id=None,
binding_id=None,
removal_reason=None,
reason_code="stale_integration_revision",
reason_message="Integration configuration changed before reconciliation apply.",
directory_entry_payload=None,
contact_snapshot=None,
identity_snapshot=None,
created_at=now,
updated_at=now,
)
@staticmethod
def _append_result_record(session: Session, result: SyncResultFact) -> None:
session.add(sync_result_to_record(result))
@@ -0,0 +1,31 @@
"""SQLAlchemy persistence for Human Input v2 submission and shared audit facts."""
from .mappers import (
audit_event_from_record,
audit_event_to_record,
proof_from_record_value,
proof_to_record_value,
submission_from_record,
submission_to_record,
)
from .otp_audit_writer import SQLAlchemyOTPChallengeAuditWriter
from .repository import (
SQLAlchemySubmissionRepository,
SQLAlchemySubmissionTransaction,
SubmissionPersistenceError,
SubmissionScopeNotFoundError,
)
__all__ = [
"SQLAlchemyOTPChallengeAuditWriter",
"SQLAlchemySubmissionRepository",
"SQLAlchemySubmissionTransaction",
"SubmissionPersistenceError",
"SubmissionScopeNotFoundError",
"audit_event_from_record",
"audit_event_to_record",
"proof_from_record_value",
"proof_to_record_value",
"submission_from_record",
"submission_to_record",
]
@@ -0,0 +1,312 @@
"""Explicit mappings for verified proof, actor, submission, and audit records."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from typing import assert_never
from core.human_input_v2.approval import (
AccountSubmissionActor,
ContactOTPSubject,
EmailAddressOTPSubject,
EmailAddressSubmissionActor,
EmailOTPSubject,
EndUserSubmissionActor,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FormRef,
FormSubmission,
FrozenJSONObject,
SubmissionActor,
VerifiedAccountSessionProof,
VerifiedEmailOTPProof,
VerifiedIMIdentityProof,
VerifiedSubmissionProof,
VerifiedTrustedEndUserProof,
)
from core.human_input_v2.entities import (
HumanInputApproverGrantSubjectType,
HumanInputSubmissionActorType,
)
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
AuditEventId,
ContactId,
DeliveryEndpointId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
OTPChallengeId,
SubmissionId,
UtcTimestamp,
WorkspaceId,
)
from models.human_input_v2 import (
AccountSessionAuthorizationProof,
EmailOTPAuthorizationProof,
FormAuditEventPayload,
FormAuthorizationProof,
FormCanonicalValues,
FormInputSnapshot,
HumanInputV2FormAuditEvent,
HumanInputV2FormSubmission,
IMIdentityAuthorizationProof,
TrustedEndUserAuthorizationProof,
)
def _timestamp(value: datetime) -> UtcTimestamp:
return UtcTimestamp(value.replace(tzinfo=UTC) if value.tzinfo is None else value)
def proof_to_record_value(proof: VerifiedSubmissionProof) -> FormAuthorizationProof:
"""Serialize verified evidence without introducing reusable credentials."""
match proof:
case VerifiedAccountSessionProof(account_id=account_id):
return AccountSessionAuthorizationProof(account_id=str(account_id))
case VerifiedTrustedEndUserProof(end_user_id=end_user_id, app_id=app_id):
return TrustedEndUserAuthorizationProof(app_id=str(app_id), end_user_id=str(end_user_id))
case VerifiedEmailOTPProof() as email_proof:
subject_type = HumanInputApproverGrantSubjectType.EMAIL_ADDRESS
contact_id: str | None = None
if isinstance(email_proof.subject, ContactOTPSubject):
subject_type = HumanInputApproverGrantSubjectType.CONTACT
contact_id = str(email_proof.subject.contact_id)
return EmailOTPAuthorizationProof(
otp_challenge_id=str(email_proof.challenge_ref.challenge_id),
workspace_id=str(email_proof.challenge_ref.form_ref.workspace_id),
form_id=str(email_proof.challenge_ref.form_ref.form_id),
approver_grant_id=str(email_proof.challenge_ref.grant_ref.grant_id),
subject_type=subject_type,
contact_id=contact_id,
verified_email=str(email_proof.normalized_email),
verified_at=email_proof.verified_at.value,
)
case VerifiedIMIdentityProof() as im_proof:
return IMIdentityAuthorizationProof(
integration_id=str(im_proof.integration_id),
im_identity_id=str(im_proof.identity_id),
im_binding_id=str(im_proof.binding_id) if im_proof.binding_id is not None else None,
provider=im_proof.provider,
provider_tenant_id=im_proof.provider_tenant_id,
provider_user_id=im_proof.provider_user_id,
)
assert_never(proof)
def proof_from_record_value(
record_value: FormAuthorizationProof,
*,
workspace_id: WorkspaceId,
) -> VerifiedSubmissionProof:
"""Rebuild verified evidence using the owner scope from its audit record."""
match record_value:
case AccountSessionAuthorizationProof(account_id=account_id):
return VerifiedAccountSessionProof(AccountId(account_id))
case TrustedEndUserAuthorizationProof(app_id=app_id, end_user_id=end_user_id):
return VerifiedTrustedEndUserProof(EndUserId(end_user_id), AppId(app_id))
case EmailOTPAuthorizationProof() as email_proof:
if email_proof.workspace_id != str(workspace_id):
raise ValueError("authorized Email proof owner does not match the audit event")
normalized_email = NormalizedEmail(email_proof.verified_email)
subject: EmailOTPSubject
if email_proof.subject_type is HumanInputApproverGrantSubjectType.CONTACT:
if email_proof.contact_id is None:
raise ValueError("contact Email proof record is missing contact_id")
subject = ContactOTPSubject(ContactId(email_proof.contact_id))
elif email_proof.subject_type is HumanInputApproverGrantSubjectType.EMAIL_ADDRESS:
if email_proof.contact_id is not None:
raise ValueError("EmailAddress proof record must not contain contact_id")
subject = EmailAddressOTPSubject(normalized_email)
else:
raise ValueError("Email proof record has an unsupported subject type")
challenge_ref = (
FormRef(workspace_id, FormId(email_proof.form_id))
.grant(ApproverGrantId(email_proof.approver_grant_id))
.challenge(OTPChallengeId(email_proof.otp_challenge_id))
)
return VerifiedEmailOTPProof(
challenge_ref=challenge_ref,
subject=subject,
normalized_email=normalized_email,
verified_at=_timestamp(email_proof.verified_at),
)
case IMIdentityAuthorizationProof() as im_proof:
return VerifiedIMIdentityProof(
integration_id=IntegrationId(im_proof.integration_id),
identity_id=IMIdentityId(im_proof.im_identity_id),
binding_id=IMBindingId(im_proof.im_binding_id) if im_proof.im_binding_id is not None else None,
provider=im_proof.provider,
provider_tenant_id=im_proof.provider_tenant_id,
provider_user_id=im_proof.provider_user_id,
)
assert_never(record_value)
@dataclass(frozen=True, slots=True)
class SubmissionActorRecordFields:
"""Exactly one populated actor column selected by its discriminator."""
actor_type: HumanInputSubmissionActorType
account_id: str | None
end_user_id: str | None
normalized_email: str | None
def _actor_to_record_fields(actor: SubmissionActor) -> SubmissionActorRecordFields:
match actor:
case AccountSubmissionActor(account_id=account_id):
return SubmissionActorRecordFields(HumanInputSubmissionActorType.ACCOUNT, str(account_id), None, None)
case EndUserSubmissionActor(end_user_id=end_user_id):
return SubmissionActorRecordFields(HumanInputSubmissionActorType.END_USER, None, str(end_user_id), None)
case EmailAddressSubmissionActor(normalized_email=normalized_email):
return SubmissionActorRecordFields(
HumanInputSubmissionActorType.EMAIL_ADDRESS,
None,
None,
str(normalized_email),
)
assert_never(actor)
def _actor_from_record(record: HumanInputV2FormSubmission) -> SubmissionActor:
actor_columns = (
record.actor_account_id is not None,
record.actor_end_user_id is not None,
record.actor_normalized_email is not None,
)
if sum(actor_columns) != 1:
raise ValueError("submission actor columns must contain exactly one identity")
if record.actor_type is HumanInputSubmissionActorType.ACCOUNT and record.actor_account_id is not None:
return AccountSubmissionActor(AccountId(record.actor_account_id))
if record.actor_type is HumanInputSubmissionActorType.END_USER and record.actor_end_user_id is not None:
return EndUserSubmissionActor(EndUserId(record.actor_end_user_id))
if record.actor_type is HumanInputSubmissionActorType.EMAIL_ADDRESS and record.actor_normalized_email is not None:
return EmailAddressSubmissionActor(NormalizedEmail(record.actor_normalized_email))
raise ValueError("submission actor discriminator does not match its actor columns")
def submission_to_record(submission: FormSubmission) -> HumanInputV2FormSubmission:
"""Map one detached immutable winning submission to an ORM record."""
actor_fields = _actor_to_record_fields(submission.actor)
record = HumanInputV2FormSubmission(
tenant_id=str(submission.form_ref.workspace_id),
form_id=str(submission.form_ref.form_id),
approver_grant_id=str(submission.approver_grant_id),
actor_type=actor_fields.actor_type,
authorization_audit_event_id=str(submission.authorization_audit_event_id),
selected_action_id=submission.selected_action_id,
input_snapshot=FormInputSnapshot(submission.input_snapshot.to_mapping()),
canonical_values=FormCanonicalValues(submission.canonical_values.to_mapping()),
submitted_at=submission.submitted_at.value,
actor_account_id=actor_fields.account_id,
actor_end_user_id=actor_fields.end_user_id,
actor_normalized_email=actor_fields.normalized_email,
endpoint_id=str(submission.endpoint_id) if submission.endpoint_id is not None else None,
)
record.id = str(submission.id)
record.created_at = submission.created_at.value
record.updated_at = submission.updated_at.value
return record
def submission_from_record(record: HumanInputV2FormSubmission) -> FormSubmission:
"""Rebuild one submission while validating its actor and structured values."""
if not isinstance(record.input_snapshot, FormInputSnapshot) or not isinstance(
record.canonical_values, FormCanonicalValues
):
raise ValueError("submission record has malformed structured values")
return FormSubmission(
id=SubmissionId(record.id),
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
approver_grant_id=ApproverGrantId(record.approver_grant_id),
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
authorization_audit_event_id=AuditEventId(record.authorization_audit_event_id),
actor=_actor_from_record(record),
selected_action_id=record.selected_action_id,
input_snapshot=FrozenJSONObject.from_mapping(record.input_snapshot.root),
canonical_values=FrozenJSONObject.from_mapping(record.canonical_values.root),
submitted_at=_timestamp(record.submitted_at),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
def audit_event_to_record(event: FormAuthorizationAuditEvent) -> HumanInputV2FormAuditEvent:
"""Map one immutable shared audit fact to its append-only record."""
event.validate_authorization_proof_owner()
record = HumanInputV2FormAuditEvent(
tenant_id=str(event.form_ref.workspace_id),
form_id=str(event.form_ref.form_id),
event_type=event.event_type.value,
occurred_at=event.occurred_at.value,
approver_grant_id=str(event.approver_grant_id) if event.approver_grant_id is not None else None,
endpoint_id=str(event.endpoint_id) if event.endpoint_id is not None else None,
channel=event.channel,
reason_code=event.reason_code,
reason_message=event.reason_message,
authorization_proof=(
proof_to_record_value(event.authorization_proof) if event.authorization_proof is not None else None
),
event_payload=FormAuditEventPayload(event.payload.to_mapping()) if event.payload is not None else None,
)
record.id = str(event.id)
record.created_at = event.created_at.value
record.updated_at = event.updated_at.value
return record
def audit_event_from_record(record: HumanInputV2FormAuditEvent) -> FormAuthorizationAuditEvent:
"""Rebuild one audit fact while enforcing authorized/rejected semantics."""
try:
event_type = FormAuthorizationAuditEventType(record.event_type)
except ValueError as error:
raise ValueError("audit record has an unsupported event type") from error
proof = (
proof_from_record_value(record.authorization_proof, workspace_id=WorkspaceId(record.tenant_id))
if record.authorization_proof is not None
else None
)
if event_type is FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED and proof is None:
raise ValueError("authorized audit record requires verified proof")
if record.event_payload is not None and not isinstance(record.event_payload, FormAuditEventPayload):
raise ValueError("audit record has a malformed structured payload")
return FormAuthorizationAuditEvent(
id=AuditEventId(record.id),
event_type=event_type,
form_ref=FormRef(WorkspaceId(record.tenant_id), FormId(record.form_id)),
approver_grant_id=(ApproverGrantId(record.approver_grant_id) if record.approver_grant_id is not None else None),
endpoint_id=DeliveryEndpointId(record.endpoint_id) if record.endpoint_id is not None else None,
channel=record.channel,
reason_code=record.reason_code,
reason_message=record.reason_message,
authorization_proof=proof,
payload=(
FrozenJSONObject.from_mapping(record.event_payload.root) if record.event_payload is not None else None
),
occurred_at=_timestamp(record.occurred_at),
created_at=_timestamp(record.created_at),
updated_at=_timestamp(record.updated_at),
)
__all__ = [
"audit_event_from_record",
"audit_event_to_record",
"proof_from_record_value",
"proof_to_record_value",
"submission_from_record",
"submission_to_record",
]
@@ -0,0 +1,54 @@
"""Transaction-scoped OTP issuance writer for the shared form audit table.
The writer only adds a mapped record to the caller-owned Session. It never
flushes or commits, so a later OTP challenge failure rolls the audit fact back
with the preceding proof-session transaction.
"""
from sqlalchemy.orm import Session
from core.human_input_v2.approval import (
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FrozenJSONObject,
)
from core.human_input_v2.entities import HumanInputDeliveryChannel
from core.human_input_v2.shared import AuditEventId
from repositories.human_input_v2.approval import OTPChallengeAuditFact
from .mappers import audit_event_to_record
class SQLAlchemyOTPChallengeAuditWriter:
"""Map one secret-free OTP issuance fact into the caller transaction."""
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
"""Add the shared audit record without changing transaction ownership."""
event = FormAuthorizationAuditEvent(
id=AuditEventId(fact.audit_event_id),
event_type=FormAuthorizationAuditEventType.OTP_CHALLENGE_ISSUED,
form_ref=fact.challenge_ref.form_ref,
approver_grant_id=fact.challenge_ref.grant_ref.grant_id,
endpoint_id=None,
channel=HumanInputDeliveryChannel.EMAIL,
reason_code=None,
reason_message=None,
authorization_proof=None,
payload=FrozenJSONObject.from_mapping(
{
"challenge_id": str(fact.challenge_ref.challenge_id),
"previous_challenge_id": (
str(fact.previous_challenge_id) if fact.previous_challenge_id is not None else None
),
"send_count": fact.send_count,
}
),
occurred_at=fact.occurred_at,
created_at=fact.occurred_at,
updated_at=fact.occurred_at,
)
session.add(audit_event_to_record(event))
__all__ = ["SQLAlchemyOTPChallengeAuditWriter"]
@@ -0,0 +1,463 @@
"""Form-locked SQLAlchemy adapter for Human Input v2 first-success submission.
One repeatable-read transaction locks the tenant-owned Form, loads the target
grant, endpoint, and current identity facts, then keeps that immutable context
authoritative for the write set. The commit path never reloads Contact or IM
binding state. Audit insert, unique Submission insert, and Form transition share
one savepoint so a unique-form race becomes a stable loser result without
retaining its audit.
"""
from __future__ import annotations
from contextlib import contextmanager
import sqlalchemy as sa
from sqlalchemy import select
from sqlalchemy.exc import DBAPIError, IntegrityError, SQLAlchemyError
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import (
AuthorizationContext,
AuthorizedSubmissionCommit,
ContactApprovalSubject,
CurrentContactAuthorizationFacts,
CurrentEndUserAuthorizationFacts,
CurrentIMAuthorizationFacts,
EndUserApprovalSubject,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FrozenJSONObject,
RetryableSubmissionPersistenceError,
SubmissionAttemptScope,
SubmissionCommitResult,
SubmissionCommitStatus,
VerifiedIMIdentityProof,
)
from core.human_input_v2.entities import HumanInputV2FormStatus, IMBindingScope
from core.human_input_v2.shared import (
AccountId,
AppId,
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
)
from models.account import Account, AccountStatus, TenantAccountJoin
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputPlatformContactWorkspaceEntry,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDeliveryEndpoint,
)
from models.model import EndUser
from repositories.human_input_v2.form.mappers import endpoint_from_record, form_from_record, grant_from_record
from .mappers import audit_event_to_record, submission_to_record
_POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE = "40001"
class SubmissionPersistenceError(RuntimeError):
"""A submission transaction failed and rolled back its complete write set."""
class SubmissionScopeNotFoundError(ValueError):
"""The complete tenant/form/grant/endpoint owner chain does not exist."""
class SQLAlchemySubmissionRepository:
"""Create short session-bound transactions for the atomic submission use case."""
_session_maker: sessionmaker[Session]
def __init__(self, session_maker: sessionmaker[Session]) -> None:
self._session_maker = session_maker
@contextmanager
def transaction(self, scope: SubmissionAttemptScope):
"""Commit on clean exit and translate infrastructure failures after rollback."""
try:
with self._session_maker() as session, session.begin():
self._configure_snapshot_transaction(session)
yield SQLAlchemySubmissionTransaction(session, scope)
except SubmissionPersistenceError as error:
if _is_postgresql_serialization_failure(error):
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
raise
except SQLAlchemyError as error:
if _is_postgresql_serialization_failure(error):
raise RetryableSubmissionPersistenceError("submission transaction must be retried") from error
raise SubmissionPersistenceError("submission transaction failed") from error
@staticmethod
def _configure_snapshot_transaction(session: Session) -> None:
"""Establish one MVCC snapshot before the transaction's first query."""
if session.get_bind().dialect.name in {"mysql", "postgresql"}:
session.connection(execution_options={"isolation_level": "REPEATABLE READ"})
class SQLAlchemySubmissionTransaction:
"""Session-bound owner of one coherent load and at most one write outcome."""
_session: Session
_scope: SubmissionAttemptScope
_form_record: HumanInputV2Form | None
_context: AuthorizationContext | None
def __init__(self, session: Session, scope: SubmissionAttemptScope) -> None:
self._session = session
self._scope = scope
self._form_record = None
self._context = None
@staticmethod
def locked_form_statement(scope: SubmissionAttemptScope) -> sa.Select[tuple[HumanInputV2Form]]:
"""Select exactly one tenant-owned Form under the first-success row lock."""
return (
select(HumanInputV2Form)
.where(
HumanInputV2Form.tenant_id == str(scope.form_ref.workspace_id),
HumanInputV2Form.id == str(scope.form_ref.form_id),
)
.with_for_update()
)
def load_authorization_context(self, *, proof: object) -> AuthorizationContext:
"""Load all relevant current facts once through the active transaction."""
if self._context is not None:
return self._context
form_record = self._session.scalar(self.locked_form_statement(self._scope))
if form_record is None:
raise SubmissionScopeNotFoundError("form owner scope does not exist")
grant_record = self._session.scalar(
select(HumanInputV2FormApproverGrant).where(
HumanInputV2FormApproverGrant.tenant_id == str(self._scope.form_ref.workspace_id),
HumanInputV2FormApproverGrant.form_id == str(self._scope.form_ref.form_id),
HumanInputV2FormApproverGrant.id == str(self._scope.approver_grant_id),
)
)
if grant_record is None:
raise SubmissionScopeNotFoundError("approver grant owner scope does not exist")
endpoint_record = self._load_endpoint_record()
form = form_from_record(form_record, (grant_record,))
grant = grant_from_record(grant_record)
endpoint = endpoint_from_record(endpoint_record) if endpoint_record is not None else None
current_contact = self._load_current_contact(grant)
current_end_user = self._load_current_end_user(grant, form.app_id)
current_im = self._load_current_im_binding(proof, current_contact)
context = AuthorizationContext(
form=form,
grant=grant,
endpoint=endpoint,
current_contact=current_contact,
current_end_user=current_end_user,
current_im_binding=current_im,
)
self._form_record = form_record
self._context = context
return context
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None:
"""Append one scoped rejection fact without changing Form lifecycle."""
self._require_loaded_context()
if event.event_type is not FormAuthorizationAuditEventType.SUBMISSION_REJECTED:
raise ValueError("rejection append requires a submission_rejected event")
self._validate_event_owner(event)
try:
self._session.add(audit_event_to_record(event))
self._session.flush()
except SQLAlchemyError as error:
raise SubmissionPersistenceError("failed to append submission rejection audit") from error
def commit_authorized_submission_once(
self,
commit: AuthorizedSubmissionCommit,
) -> SubmissionCommitResult:
"""Atomically insert authorized audit/submission and transition the locked Form."""
context = self._require_loaded_context()
form_record = self._form_record
assert form_record is not None
self._validate_authorized_owner(commit, context)
if form_record.status is not HumanInputV2FormStatus.WAITING:
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
occurred_at = commit.authorized.transition.decided_at
channel = context.endpoint.channel if context.endpoint is not None else None
audit_event = FormAuthorizationAuditEvent(
id=commit.authorization_audit_event_id,
event_type=FormAuthorizationAuditEventType.SUBMISSION_AUTHORIZED,
form_ref=self._scope.form_ref,
approver_grant_id=self._scope.approver_grant_id,
endpoint_id=self._scope.endpoint_id,
channel=channel,
reason_code=None,
reason_message=None,
authorization_proof=commit.authorized.proof,
payload=FrozenJSONObject.from_mapping(
{"selected_action_id": commit.authorized.transition.selected_action_id}
),
occurred_at=occurred_at,
created_at=occurred_at,
updated_at=occurred_at,
)
submission = commit.to_submission(
form_ref=self._scope.form_ref,
approver_grant_id=self._scope.approver_grant_id,
endpoint_id=self._scope.endpoint_id,
submitted_at=occurred_at,
)
try:
with self._session.begin_nested():
self._session.add(audit_event_to_record(audit_event))
self._session.flush()
self._session.add(submission_to_record(submission))
self._session.flush()
form_record.status = HumanInputV2FormStatus.SUBMITTED
form_record.updated_at = occurred_at.value
self._session.flush()
except IntegrityError as error:
if self._is_form_submission_unique_conflict(error):
return SubmissionCommitResult(SubmissionCommitStatus.ALREADY_COMPLETED, None)
raise SubmissionPersistenceError("authorized submission violated a persistence invariant") from error
except SQLAlchemyError as error:
raise SubmissionPersistenceError("failed to commit authorized submission") from error
return SubmissionCommitResult(SubmissionCommitStatus.COMMITTED, submission)
def _load_endpoint_record(self) -> HumanInputV2FormDeliveryEndpoint | None:
if self._scope.endpoint_id is None:
return None
endpoint_record = self._session.scalar(
select(HumanInputV2FormDeliveryEndpoint).where(
HumanInputV2FormDeliveryEndpoint.tenant_id == str(self._scope.form_ref.workspace_id),
HumanInputV2FormDeliveryEndpoint.form_id == str(self._scope.form_ref.form_id),
HumanInputV2FormDeliveryEndpoint.approver_grant_id == str(self._scope.approver_grant_id),
HumanInputV2FormDeliveryEndpoint.id == str(self._scope.endpoint_id),
)
)
if endpoint_record is None:
raise SubmissionScopeNotFoundError("delivery endpoint owner scope does not exist")
return endpoint_record
def _load_current_contact(self, grant) -> CurrentContactAuthorizationFacts | None:
if not isinstance(grant.subject, ContactApprovalSubject):
return None
workspace_id = str(self._scope.form_ref.workspace_id)
membership_exists = sa.exists().where(
TenantAccountJoin.tenant_id == workspace_id,
TenantAccountJoin.account_id == HumanInputContact.account_id,
)
platform_exists = sa.exists().where(
HumanInputPlatformContactWorkspaceEntry.tenant_id == workspace_id,
HumanInputPlatformContactWorkspaceEntry.contact_id == HumanInputContact.id,
)
row = self._session.execute(
select(HumanInputContact, Account.status, membership_exists, platform_exists)
.outerjoin(Account, Account.id == HumanInputContact.account_id)
.where(
HumanInputContact.id == str(grant.subject.contact_id),
sa.or_(HumanInputContact.tenant_id == workspace_id, HumanInputContact.tenant_id.is_(None)),
)
).one_or_none()
if row is None:
return None
contact_record, account_status, has_membership, has_platform_entry = row
identity_source = contact_record.identity_source
if identity_source is HumanInputContactIdentitySource.EXTERNAL:
workspace_available = contact_record.tenant_id == workspace_id
elif identity_source is HumanInputContactIdentitySource.WORKSPACE_MEMBER:
workspace_available = contact_record.tenant_id == workspace_id and has_membership
else:
workspace_available = has_membership or has_platform_entry
normalized_email = (
NormalizedEmail(contact_record.normalized_email) if contact_record.normalized_email is not None else None
)
return CurrentContactAuthorizationFacts(
contact_id=ContactId(contact_record.id),
account_id=AccountId(contact_record.account_id) if contact_record.account_id is not None else None,
normalized_email=normalized_email,
account_active=contact_record.account_id is None or account_status is AccountStatus.ACTIVE,
workspace_available=bool(workspace_available),
)
def _load_current_end_user(self, grant, app_id: AppId) -> CurrentEndUserAuthorizationFacts | None:
if not isinstance(grant.subject, EndUserApprovalSubject):
return None
end_user_id = str(grant.subject.end_user_id)
workspace_id = str(self._scope.form_ref.workspace_id)
row = self._session.execute(
select(EndUser.id, EndUser.app_id).where(
EndUser.id == end_user_id,
EndUser.tenant_id == workspace_id,
EndUser.app_id == str(app_id),
)
).one_or_none()
if row is None:
return None
return CurrentEndUserAuthorizationFacts(
end_user_id=EndUserId(row.id),
app_id=AppId(row.app_id),
workspace_available=True,
)
def _load_current_im_binding(
self,
proof: object,
current_contact: CurrentContactAuthorizationFacts | None,
) -> CurrentIMAuthorizationFacts | None:
if not isinstance(proof, VerifiedIMIdentityProof) or current_contact is None:
return None
workspace_id = str(self._scope.form_ref.workspace_id)
integration = self._session.scalar(
select(HumanInputIMIntegration).where(
HumanInputIMIntegration.id == str(proof.integration_id),
HumanInputIMIntegration.provider == proof.provider,
sa.or_(HumanInputIMIntegration.tenant_id == workspace_id, HumanInputIMIntegration.tenant_id.is_(None)),
)
)
if integration is None:
return None
priority = sa.case((HumanInputIMBinding.scope == IMBindingScope.WORKSPACE, 0), else_=1)
binding_row = self._session.execute(
select(HumanInputIMBinding, HumanInputIMIdentity)
.outerjoin(
HumanInputIMIdentity,
HumanInputIMIdentity.id == HumanInputIMBinding.im_identity_id,
)
.where(
HumanInputIMBinding.integration_id == integration.id,
HumanInputIMBinding.provider == proof.provider,
HumanInputIMBinding.contact_id == str(current_contact.contact_id),
sa.or_(
sa.and_(
HumanInputIMBinding.scope == IMBindingScope.WORKSPACE,
HumanInputIMBinding.scope_id == workspace_id,
),
sa.and_(
HumanInputIMBinding.scope == IMBindingScope.ORGANIZATION,
HumanInputIMBinding.scope_id == integration.id,
),
),
)
.order_by(priority, HumanInputIMBinding.id)
.limit(1)
).one_or_none()
binding_id: IMBindingId | None = None
if binding_row is not None:
binding_record, identity_record = binding_row
if binding_record.integration_id != integration.id or binding_record.provider is not proof.provider:
return None
if (
identity_record is None
or identity_record.integration_id != integration.id
or identity_record.provider is not proof.provider
):
return None
binding_id = IMBindingId(binding_record.id)
elif current_contact.normalized_email is not None:
identity_record = self._session.scalar(
select(HumanInputIMIdentity).where(
HumanInputIMIdentity.integration_id == integration.id,
HumanInputIMIdentity.provider == proof.provider,
HumanInputIMIdentity.normalized_email == str(current_contact.normalized_email),
)
)
if identity_record is None:
return None
else:
return None
return CurrentIMAuthorizationFacts(
integration_id=IntegrationId(integration.id),
provider=integration.provider,
provider_tenant_id=integration.provider_tenant_id,
contact_id=current_contact.contact_id,
account_id=current_contact.account_id,
identity_id=IMIdentityId(identity_record.id),
binding_id=binding_id,
provider_user_id=identity_record.provider_user_id,
)
def _require_loaded_context(self) -> AuthorizationContext:
if self._context is None:
raise RuntimeError("authorization context must be loaded before persistence")
return self._context
def _validate_event_owner(self, event: FormAuthorizationAuditEvent) -> None:
if (
event.form_ref != self._scope.form_ref
or event.approver_grant_id != self._scope.approver_grant_id
or event.endpoint_id != self._scope.endpoint_id
):
raise ValueError("audit event does not match the loaded owner scope")
def _validate_authorized_owner(
self,
commit: AuthorizedSubmissionCommit,
context: AuthorizationContext,
) -> None:
transition = commit.authorized.transition
if (
transition.form_ref != self._scope.form_ref
or transition.grant_id != self._scope.approver_grant_id
or commit.authorized.endpoint_ref != (context.endpoint.ref if context.endpoint is not None else None)
):
raise ValueError("authorized submission does not match the loaded owner scope")
@staticmethod
def _is_form_submission_unique_conflict(error: IntegrityError) -> bool:
message = str(error.orig).lower()
return "hiv2_form_submissions_form_uq" in message or "human_input_v2_form_submissions.form_id" in message
def _is_postgresql_serialization_failure(error: BaseException) -> bool:
"""Inspect SQLAlchemy and public driver exception links for SQLSTATE 40001."""
pending: list[object] = [error]
visited: set[int] = set()
while pending:
current = pending.pop()
current_id = id(current)
if current_id in visited:
continue
visited.add(current_id)
if _structured_sqlstate(current) == _POSTGRESQL_SERIALIZATION_FAILURE_SQLSTATE:
return True
if isinstance(current, DBAPIError):
pending.append(current.orig)
if isinstance(current, BaseException):
if current.__cause__ is not None:
pending.append(current.__cause__)
if current.__context__ is not None:
pending.append(current.__context__)
return False
def _structured_sqlstate(error: object) -> str | None:
"""Read documented psycopg/psycopg2 SQLSTATE attributes at the driver boundary."""
for attribute_name in ("sqlstate", "pgcode"):
raw_code = getattr(error, attribute_name, None)
if isinstance(raw_code, str):
return raw_code
return None
__all__ = [
"SQLAlchemySubmissionRepository",
"SQLAlchemySubmissionTransaction",
"SubmissionPersistenceError",
"SubmissionScopeNotFoundError",
]
@@ -0,0 +1,348 @@
"""Providers for delivering human input forms through configured channels.
Dispatch has two explicit phases: load immutable DTOs while a SQLAlchemy session
is active, then perform provider I/O after the session context has closed.
Providers must not depend on live ORM instances because email and future IM
clients can block or retry independently from database transaction lifetimes.
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from dataclasses import dataclass
from typing import Protocol
from pydantic import TypeAdapter, ValidationError
from sqlalchemy import select
from sqlalchemy.orm import Session
from configs import dify_config
from core.workflow.human_input_adapter import (
DeliveryMethodType,
EmailDeliveryConfig,
EmailDeliveryMethod,
InstantMessageDeliveryMethod,
)
from extensions.ext_mail import mail
from graphon.runtime import VariablePool
from models.human_input import (
EmailExternalRecipientPayload,
EmailMemberRecipientPayload,
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
InstantMessageRecipientPayload,
RecipientPayload,
RecipientType,
)
logger = logging.getLogger(__name__)
_RECIPIENT_PAYLOAD_ADAPTER = TypeAdapter(RecipientPayload)
@dataclass(frozen=True)
class HumanInputFormDeliveryRecipientContext:
recipient_id: str
recipient_type: RecipientType
recipient_payload: str
access_token: str | None
@dataclass(frozen=True)
class HumanInputFormDeliveryContext:
form_id: str
tenant_id: str
app_id: str
workflow_run_id: str | None
rendered_content: str
delivery_id: str
delivery_method_type: DeliveryMethodType
delivery_config_id: str | None
channel_payload: str
recipients: tuple[HumanInputFormDeliveryRecipientContext, ...]
variable_pool: VariablePool | None = None
class HumanInputFormDeliveryProvider(Protocol):
"""Sends a persisted human input form through one delivery method."""
delivery_method_type: DeliveryMethodType
def send(self, *, context: HumanInputFormDeliveryContext) -> None: ...
class HumanInputFormMailClient(Protocol):
def send(self, *, to: str, subject: str, html: str) -> None: ...
class HumanInputFormDeliveryProviderRegistry:
_providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider]
def __init__(self, providers: Sequence[HumanInputFormDeliveryProvider] | None = None) -> None:
self._providers: dict[DeliveryMethodType, HumanInputFormDeliveryProvider] = {}
for provider in providers or ():
self.register(provider)
def register(self, provider: HumanInputFormDeliveryProvider) -> None:
self._providers[provider.delivery_method_type] = provider
def dispatch(self, *, context: HumanInputFormDeliveryContext, continue_on_error: bool = True) -> bool:
"""Dispatch one loaded context through its provider.
Provider calls are external I/O, so callers default to isolating send failures per context.
Loading and DTO construction stay outside this boundary and should fail through their caller.
"""
provider = self._providers.get(context.delivery_method_type)
if provider is None:
logger.warning(
"No human input form delivery provider registered, form_id=%s, delivery_id=%s, method=%s",
context.form_id,
context.delivery_id,
context.delivery_method_type,
)
return False
try:
provider.send(context=context)
except Exception:
logger.exception(
"Failed to dispatch human input form delivery, form_id=%s, delivery_id=%s, method=%s",
context.form_id,
context.delivery_id,
context.delivery_method_type,
)
if not continue_on_error:
raise
return False
return True
@classmethod
def default(cls, *, mail_client: HumanInputFormMailClient = mail) -> HumanInputFormDeliveryProviderRegistry:
return cls(
[
EmailHumanInputFormDeliveryProvider(mail_client=mail_client),
UnsupportedInstantMessageHumanInputFormDeliveryProvider(),
]
)
class HumanInputFormDeliveryDispatcher:
_registry: HumanInputFormDeliveryProviderRegistry
def __init__(self, registry: HumanInputFormDeliveryProviderRegistry | None = None) -> None:
self._registry = registry or HumanInputFormDeliveryProviderRegistry.default()
def load_form_contexts(
self,
*,
session: Session,
form: HumanInputForm,
variable_pool: VariablePool | None = None,
delivery_method_types: Sequence[DeliveryMethodType] | None = None,
) -> tuple[HumanInputFormDeliveryContext, ...]:
stmt = select(HumanInputDelivery).where(HumanInputDelivery.form_id == form.id)
if delivery_method_types:
stmt = stmt.where(HumanInputDelivery.delivery_method_type.in_(delivery_method_types))
deliveries = session.scalars(stmt).all()
contexts: list[HumanInputFormDeliveryContext] = []
for delivery in deliveries:
recipients = session.scalars(
select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id),
).all()
contexts.append(
_build_delivery_context(
form=form,
delivery=delivery,
recipients=recipients,
variable_pool=variable_pool,
)
)
return tuple(contexts)
def dispatch_contexts(
self,
contexts: Sequence[HumanInputFormDeliveryContext],
*,
continue_on_error: bool = True,
) -> None:
for context in contexts:
self._registry.dispatch(context=context, continue_on_error=continue_on_error)
def _build_delivery_context(
*,
form: HumanInputForm,
delivery: HumanInputDelivery,
recipients: Sequence[HumanInputFormRecipient],
variable_pool: VariablePool | None,
) -> HumanInputFormDeliveryContext:
return HumanInputFormDeliveryContext(
form_id=form.id,
tenant_id=form.tenant_id,
app_id=form.app_id,
workflow_run_id=form.workflow_run_id,
rendered_content=form.rendered_content,
delivery_id=delivery.id,
delivery_method_type=delivery.delivery_method_type,
delivery_config_id=delivery.delivery_config_id,
channel_payload=delivery.channel_payload,
recipients=tuple(
HumanInputFormDeliveryRecipientContext(
recipient_id=recipient.id,
recipient_type=recipient.recipient_type,
recipient_payload=recipient.recipient_payload,
access_token=recipient.access_token,
)
for recipient in recipients
),
variable_pool=variable_pool,
)
@dataclass(frozen=True)
class _EmailRecipient:
email: str
token: str
class EmailHumanInputFormDeliveryProvider:
_mail: HumanInputFormMailClient
delivery_method_type = DeliveryMethodType.EMAIL
def __init__(self, *, mail_client: HumanInputFormMailClient = mail) -> None:
self._mail = mail_client
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
try:
delivery_method = EmailDeliveryMethod.model_validate_json(context.channel_payload)
except ValidationError:
# Persisted payloads can be stale or manually corrupted; skip only this delivery.
logger.warning(
"Invalid human input email delivery payload, form_id=%s, delivery_id=%s",
context.form_id,
context.delivery_id,
exc_info=True,
)
return
recipients = self._resolve_recipients(context.recipients)
if not recipients:
return
subject = EmailDeliveryConfig.sanitize_subject(delivery_method.config.subject)
for recipient in recipients:
form_link = _build_form_link(recipient.token)
body = _render_body(
delivery_method.config.body,
form_link,
variable_pool=context.variable_pool,
)
self._mail.send(
to=recipient.email,
subject=subject,
html=body,
)
@staticmethod
def _resolve_recipients(recipients: Sequence[HumanInputFormDeliveryRecipientContext]) -> list[_EmailRecipient]:
resolved: list[_EmailRecipient] = []
for recipient in recipients:
payload = _parse_email_recipient_payload(recipient)
if payload is None:
continue
token = recipient.access_token
if not token:
continue
resolved.append(_EmailRecipient(email=payload.email, token=token))
return resolved
class UnsupportedInstantMessageHumanInputFormDeliveryProvider:
delivery_method_type = DeliveryMethodType.IM
def send(self, *, context: HumanInputFormDeliveryContext) -> None:
try:
InstantMessageDeliveryMethod.model_validate_json(context.channel_payload)
except ValidationError:
logger.warning(
"Invalid human input instant message delivery payload, form_id=%s, delivery_id=%s",
context.form_id,
context.delivery_id,
exc_info=True,
)
return
valid_recipients = 0
for recipient in context.recipients:
if _parse_instant_message_recipient_payload(recipient) is not None:
valid_recipients += 1
logger.warning(
"Human input instant message delivery is not implemented, form_id=%s, delivery_id=%s, recipients=%s",
context.form_id,
context.delivery_id,
valid_recipients,
)
def _build_form_link(token: str) -> str:
base_url = dify_config.APP_WEB_URL
return f"{base_url.rstrip('/')}/form/{token}"
def _parse_email_recipient_payload(
recipient: HumanInputFormDeliveryRecipientContext,
) -> EmailMemberRecipientPayload | EmailExternalRecipientPayload | None:
try:
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
except ValidationError:
# Recipient payloads are persisted per delivery; a bad row should not block other recipients.
logger.warning(
"Invalid human input recipient payload, recipient_id=%s",
recipient.recipient_id,
exc_info=True,
)
return None
match payload:
case EmailMemberRecipientPayload() | EmailExternalRecipientPayload():
if payload.email:
return payload
return None
case _:
return None
def _parse_instant_message_recipient_payload(
recipient: HumanInputFormDeliveryRecipientContext,
) -> InstantMessageRecipientPayload | None:
try:
payload = _RECIPIENT_PAYLOAD_ADAPTER.validate_json(recipient.recipient_payload)
except ValidationError:
logger.warning(
"Invalid human input instant message recipient payload, recipient_id=%s",
recipient.recipient_id,
exc_info=True,
)
return None
if isinstance(payload, InstantMessageRecipientPayload):
return payload
return None
def _render_body(
body_template: str,
form_link: str,
*,
variable_pool: VariablePool | None,
) -> str:
body = EmailDeliveryConfig.render_body_template(
body=body_template,
url=form_link,
variable_pool=variable_pool,
)
return EmailDeliveryConfig.render_markdown_body(body)
+1
View File
@@ -0,0 +1 @@
"""Application services for Human Input v2 use cases."""
+239
View File
@@ -0,0 +1,239 @@
"""Application orchestration for authorized Human Input v2 submissions.
The handler keeps authorization and persistence behind their deep domain and
repository interfaces. Its only application-layer policy is the post-commit
boundary: a winning runtime submission is committed before workflow resume is
requested, and a known enqueue failure never changes the persisted outcome.
Retryable transaction conflicts restart the complete use case with a fresh
snapshot; partial authorization or persistence steps are never retried alone.
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from enum import StrEnum
from typing import Protocol
from core.human_input_v2.approval import (
AuthorizedSubmissionCommit,
FormAuthorizationAuditEvent,
FormAuthorizationAuditEventType,
FormSubmission,
FrozenJSONObject,
HumanInputForm,
RetryableSubmissionPersistenceError,
SubmissionAttemptScope,
SubmissionAuthorizationRejection,
SubmissionAuthorizer,
SubmissionCommitStatus,
SubmissionRepository,
)
from core.human_input_v2.entities import HumanInputV2FormKind
from core.human_input_v2.shared import AuditEventId, FormId, SubmissionId, UtcTimestamp, WorkspaceId
logger = logging.getLogger(__name__)
_MAX_SUBMISSION_TRANSACTION_RETRIES = 1
@dataclass(frozen=True, slots=True)
class WorkflowResumeIdentity:
"""Stable workflow owner identity used by an idempotent resume adapter."""
workspace_id: WorkspaceId
form_id: FormId
workflow_pause_id: str
node_execution_id: str
def __post_init__(self) -> None:
if not self.workflow_pause_id.strip() or not self.node_execution_id.strip():
raise ValueError("workflow resume identity values must not be blank")
class WorkflowResumeEnqueueError(RuntimeError):
"""A resume adapter could not accept an idempotent enqueue request."""
class WorkflowResumePort(Protocol):
"""Enqueue workflow resume once for a stable form/workflow identity."""
def enqueue_once(self, identity: WorkflowResumeIdentity) -> None: ...
@dataclass(frozen=True, slots=True)
class SubmitFormCommand:
"""Verified proof and caller-owned record identities for one submission attempt."""
scope: SubmissionAttemptScope
proof: object
selected_action_id: str
input_snapshot: FrozenJSONObject
canonical_values: FrozenJSONObject
submission_id: SubmissionId
authorization_audit_event_id: AuditEventId
rejection_audit_event_id: AuditEventId
resume_identity: WorkflowResumeIdentity | None
now: UtcTimestamp
class SubmitFormResultStatus(StrEnum):
"""Stable application outcomes independent of transport status codes."""
SUBMITTED = "submitted"
ALREADY_COMPLETED = "already_completed"
REJECTED = "rejected"
@dataclass(frozen=True, slots=True)
class SubmitFormResult:
"""Submission outcome plus post-commit resume delivery state."""
status: SubmitFormResultStatus
submission: FormSubmission | None
rejection: SubmissionAuthorizationRejection | None
resume_enqueued: bool
class SubmitHumanInputFormHandler:
"""Authorize and persist once, retrying only complete transient transactions."""
def __init__(self, repository: SubmissionRepository, resume_port: WorkflowResumePort) -> None:
self._repository = repository
self._resume_port = resume_port
def handle(self, command: SubmitFormCommand) -> SubmitFormResult:
"""Return a stable outcome while preserving commit-before-enqueue ordering."""
identity = self._prevalidate_resume_identity(command)
transaction_result = self._handle_transaction_with_retry(command, identity)
if transaction_result.status is not SubmitFormResultStatus.SUBMITTED:
return transaction_result
try:
self._resume_port.enqueue_once(identity)
except WorkflowResumeEnqueueError:
logger.exception(
"Failed to enqueue Human Input v2 workflow resume after submission commit: "
"workspace_id=%s form_id=%s workflow_pause_id=%s node_execution_id=%s",
identity.workspace_id,
identity.form_id,
identity.workflow_pause_id,
identity.node_execution_id,
)
return SubmitFormResult(
SubmitFormResultStatus.SUBMITTED,
transaction_result.submission,
None,
False,
)
return SubmitFormResult(
SubmitFormResultStatus.SUBMITTED,
transaction_result.submission,
None,
True,
)
def _handle_transaction_with_retry(
self,
command: SubmitFormCommand,
identity: WorkflowResumeIdentity,
) -> SubmitFormResult:
for retry_count in range(_MAX_SUBMISSION_TRANSACTION_RETRIES + 1):
try:
return self._handle_transaction_once(command, identity)
except RetryableSubmissionPersistenceError:
if retry_count == _MAX_SUBMISSION_TRANSACTION_RETRIES:
raise
logger.warning(
"Retrying Human Input v2 submission after transaction serialization failure: "
"workspace_id=%s form_id=%s retry_count=%s",
identity.workspace_id,
identity.form_id,
retry_count + 1,
)
raise AssertionError("submission transaction retry loop must return or raise")
def _handle_transaction_once(
self,
command: SubmitFormCommand,
identity: WorkflowResumeIdentity,
) -> SubmitFormResult:
"""Run one complete load, authorization, Form decision, and commit attempt."""
with self._repository.transaction(command.scope) as transaction:
context = transaction.load_authorization_context(proof=command.proof)
self._validate_runtime_form_identity(context.form, identity)
decision = SubmissionAuthorizer.authorize(
context=context,
proof=command.proof,
selected_action_id=command.selected_action_id,
now=command.now,
)
if decision.rejection is not None:
rejection = decision.rejection
if rejection is SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED:
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
transaction.append_rejection_audit(
FormAuthorizationAuditEvent(
id=command.rejection_audit_event_id,
event_type=FormAuthorizationAuditEventType.SUBMISSION_REJECTED,
form_ref=command.scope.form_ref,
approver_grant_id=command.scope.approver_grant_id,
endpoint_id=command.scope.endpoint_id,
channel=context.endpoint.channel if context.endpoint is not None else None,
reason_code=rejection,
reason_message=None,
authorization_proof=None,
payload=FrozenJSONObject.from_mapping({"selected_action_id": command.selected_action_id}),
occurred_at=command.now,
created_at=command.now,
updated_at=command.now,
)
)
return SubmitFormResult(SubmitFormResultStatus.REJECTED, None, rejection, False)
authorized = decision.authorized
assert authorized is not None
commit_result = transaction.commit_authorized_submission_once(
AuthorizedSubmissionCommit(
submission_id=command.submission_id,
authorization_audit_event_id=command.authorization_audit_event_id,
authorized=authorized,
input_snapshot=command.input_snapshot,
canonical_values=command.canonical_values,
)
)
if commit_result.status is SubmissionCommitStatus.ALREADY_COMPLETED:
return SubmitFormResult(SubmitFormResultStatus.ALREADY_COMPLETED, None, None, False)
return SubmitFormResult(SubmitFormResultStatus.SUBMITTED, commit_result.submission, None, False)
@staticmethod
def _prevalidate_resume_identity(command: SubmitFormCommand) -> WorkflowResumeIdentity:
identity = command.resume_identity
if identity is None:
raise ValueError("runtime resume identity is required before submission persistence")
if (
identity.workspace_id != command.scope.form_ref.workspace_id
or identity.form_id != command.scope.form_ref.form_id
):
raise ValueError("runtime resume identity does not match the submission form owner")
return identity
@staticmethod
def _validate_runtime_form_identity(form: HumanInputForm, identity: WorkflowResumeIdentity) -> None:
if form.kind is not HumanInputV2FormKind.RUNTIME:
raise ValueError("submission handler accepts runtime forms only")
if form.workflow_pause_id != identity.workflow_pause_id or form.node_execution_id != identity.node_execution_id:
raise ValueError("runtime resume identity does not match the loaded form")
__all__ = [
"SubmitFormCommand",
"SubmitFormResult",
"SubmitFormResultStatus",
"SubmitHumanInputFormHandler",
"WorkflowResumeEnqueueError",
"WorkflowResumeIdentity",
"WorkflowResumePort",
]
+128 -122
View File
@@ -1,117 +1,41 @@
import json
"""Celery entrypoints for delivering persisted human input forms.
The generic form-delivery task must stay independent from email availability so
non-email providers can run even when mail is not configured. The legacy email
task keeps its historical mail and feature gates for callers that still need
email-only semantics. The generic task only checks email availability after
loading persisted delivery contexts and only when an email context is present.
"""
import logging
import time
from dataclasses import dataclass
from typing import Any
from collections.abc import Sequence
import click
from celery import shared_task
from sqlalchemy import select
from sqlalchemy.orm import Session, sessionmaker
from configs import dify_config
from core.app.layers.pause_state_persist_layer import WorkflowResumptionContext
from core.workflow.human_input_adapter import EmailDeliveryConfig, EmailDeliveryMethod
from core.workflow.human_input_adapter import DeliveryMethodType
from extensions.ext_database import db
from extensions.ext_mail import mail
from graphon.runtime import GraphRuntimeState, VariablePool
from models.human_input import (
DeliveryMethodType,
HumanInputDelivery,
HumanInputForm,
HumanInputFormRecipient,
RecipientType,
)
from models.human_input import HumanInputForm
from repositories.factory import DifyAPIRepositoryFactory
from services.feature_service import FeatureService
from services.human_input_form_delivery_provider import (
HumanInputFormDeliveryContext,
HumanInputFormDeliveryDispatcher,
HumanInputFormDeliveryProviderRegistry,
)
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class _EmailRecipient:
email: str
token: str
FORM_DELIVERY_METHOD_TYPES = (DeliveryMethodType.EMAIL, DeliveryMethodType.IM)
@dataclass(frozen=True)
class _EmailDeliveryJob:
form_id: str
subject: str
body: str
form_content: str
recipients: list[_EmailRecipient]
def _build_form_link(token: str) -> str:
base_url = dify_config.APP_WEB_URL
return f"{base_url.rstrip('/')}/form/{token}"
def _parse_recipient_payload(payload: str) -> tuple[str | None, RecipientType | None]:
try:
payload_dict: dict[str, Any] = json.loads(payload)
except Exception:
logger.exception("Failed to parse recipient payload")
return None, None
return payload_dict.get("email"), payload_dict.get("TYPE")
def _load_email_jobs(session: Session, form: HumanInputForm) -> list[_EmailDeliveryJob]:
deliveries = session.scalars(
select(HumanInputDelivery).where(
HumanInputDelivery.form_id == form.id,
HumanInputDelivery.delivery_method_type == DeliveryMethodType.EMAIL,
)
).all()
jobs: list[_EmailDeliveryJob] = []
for delivery in deliveries:
delivery_config = EmailDeliveryMethod.model_validate_json(delivery.channel_payload)
recipients = session.scalars(
select(HumanInputFormRecipient).where(HumanInputFormRecipient.delivery_id == delivery.id)
).all()
recipient_entities: list[_EmailRecipient] = []
for recipient in recipients:
email, recipient_type = _parse_recipient_payload(recipient.recipient_payload)
if recipient_type not in {RecipientType.EMAIL_MEMBER, RecipientType.EMAIL_EXTERNAL}:
continue
if not email:
continue
token = recipient.access_token
if not token:
continue
recipient_entities.append(_EmailRecipient(email=email, token=token))
if not recipient_entities:
continue
jobs.append(
_EmailDeliveryJob(
form_id=form.id,
subject=delivery_config.config.subject,
body=delivery_config.config.body,
form_content=form.rendered_content,
recipients=recipient_entities,
)
)
return jobs
def _render_body(
body_template: str,
form_link: str,
*,
variable_pool: VariablePool | None,
) -> str:
body = EmailDeliveryConfig.render_body_template(
body=body_template,
url=form_link,
variable_pool=variable_pool,
)
return EmailDeliveryConfig.render_markdown_body(body)
def _has_email_context(contexts: Sequence[HumanInputFormDeliveryContext]) -> bool:
return any(context.delivery_method_type == DeliveryMethodType.EMAIL for context in contexts)
def _load_variable_pool(workflow_run_id: str | None) -> VariablePool | None:
@@ -143,12 +67,62 @@ def _open_session(session_factory: sessionmaker | Session | None):
return session_factory()
@shared_task(queue="mail")
def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None):
if not mail.is_inited():
def _filter_unavailable_email_contexts(
*,
contexts: Sequence[HumanInputFormDeliveryContext],
tenant_id: str,
form_id: str,
) -> tuple[HumanInputFormDeliveryContext, ...]:
contexts_tuple = tuple(contexts)
if not _has_email_context(contexts_tuple):
return contexts_tuple
email_delivery_enabled = False
if mail.is_inited():
try:
features = FeatureService.get_features(tenant_id, exclude_vector_space=True)
email_delivery_enabled = features.human_input_email_delivery_enabled
except Exception:
logger.exception(
"Failed to check human input email delivery availability; skipping email contexts, "
"tenant=%s, form_id=%s",
tenant_id,
form_id,
)
if email_delivery_enabled:
return contexts_tuple
return tuple(context for context in contexts_tuple if context.delivery_method_type != DeliveryMethodType.EMAIL)
def _is_email_delivery_enabled(*, tenant_id: str, form_id: str) -> bool:
features = FeatureService.get_features(tenant_id, exclude_vector_space=True)
email_delivery_enabled = features.human_input_email_delivery_enabled
if not email_delivery_enabled:
logger.info(
"Human input email delivery is not available for tenant=%s, form_id=%s",
tenant_id,
form_id,
)
return email_delivery_enabled
def _load_and_dispatch_form_delivery(
*,
form_id: str,
node_title: str | None,
session_factory,
delivery_method_types: Sequence[DeliveryMethodType] | None,
require_mail_inited: bool,
require_email_feature: bool,
filter_unavailable_email: bool,
log_label: str,
) -> None:
if require_mail_inited and not mail.is_inited():
return
logger.info(click.style(f"Start human input email delivery for form {form_id}", fg="green"))
logger.info(click.style(f"Start human input {log_label} delivery for form {form_id}", fg="green"))
start_at = time.perf_counter()
try:
@@ -157,35 +131,67 @@ def dispatch_human_input_email_task(form_id: str, node_title: str | None = None,
if form is None:
logger.warning("Human input form not found, form_id=%s", form_id)
return
features = FeatureService.get_features(form.tenant_id, exclude_vector_space=True)
if not features.human_input_email_delivery_enabled:
logger.info(
"Human input email delivery is not available for tenant=%s, form_id=%s",
form.tenant_id,
form_id,
)
if require_email_feature and not _is_email_delivery_enabled(tenant_id=form.tenant_id, form_id=form_id):
return
jobs = _load_email_jobs(session, form)
variable_pool = _load_variable_pool(form.workflow_run_id)
variable_pool = _load_variable_pool(form.workflow_run_id)
registry = HumanInputFormDeliveryProviderRegistry.default(mail_client=mail)
dispatcher = HumanInputFormDeliveryDispatcher(registry=registry)
contexts = dispatcher.load_form_contexts(
session=session,
form=form,
variable_pool=variable_pool,
delivery_method_types=delivery_method_types,
)
for job in jobs:
for recipient in job.recipients:
form_link = _build_form_link(recipient.token)
body = _render_body(job.body, form_link, variable_pool=variable_pool)
subject = EmailDeliveryConfig.sanitize_subject(job.subject)
mail.send(
to=recipient.email,
subject=subject,
html=body,
)
if filter_unavailable_email:
contexts = _filter_unavailable_email_contexts(
contexts=contexts,
tenant_id=form.tenant_id,
form_id=form_id,
)
dispatcher.dispatch_contexts(contexts)
end_at = time.perf_counter()
logger.info(
click.style(
f"Human input email delivery succeeded for form {form_id}: latency: {end_at - start_at}", fg="green"
f"Human input {log_label} delivery succeeded for form {form_id}: latency: {end_at - start_at}",
fg="green",
)
)
except Exception:
logger.exception("Send human input email failed, form_id=%s", form_id)
logger.exception(
"Dispatch human input %s delivery failed, form_id=%s, node_title=%s",
log_label,
form_id,
node_title,
)
@shared_task(queue="mail")
def dispatch_human_input_form_delivery_task(form_id: str, node_title: str | None = None, session_factory=None):
_load_and_dispatch_form_delivery(
form_id=form_id,
node_title=node_title,
session_factory=session_factory,
delivery_method_types=FORM_DELIVERY_METHOD_TYPES,
require_mail_inited=False,
require_email_feature=False,
filter_unavailable_email=True,
log_label="form",
)
@shared_task(queue="mail")
def dispatch_human_input_email_task(form_id: str, node_title: str | None = None, session_factory=None):
_load_and_dispatch_form_delivery(
form_id=form_id,
node_title=node_title,
session_factory=session_factory,
delivery_method_types=(DeliveryMethodType.EMAIL,),
require_mail_inited=True,
require_email_feature=True,
filter_unavailable_email=False,
log_label="email",
)
@@ -0,0 +1,368 @@
"""PostgreSQL-only transaction and concurrency coverage for Contact Directory."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier, Event, get_ident
from time import monotonic, sleep
import pytest
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.orm import sessionmaker
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactDirectorySnapshot,
ContactRejectionCode,
)
from core.human_input_v2.shared import AccountId, ContactId, UtcTimestamp, WorkspaceId
from extensions.ext_database import db
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus, TenantAccountJoin
from models.human_input_v2 import HumanInputContact, HumanInputPlatformContactWorkspaceEntry
from repositories.human_input_v2.contact_directory.repository import SQLAlchemyContactDirectoryRepository
def test_concurrent_organization_writes_serialize_on_dify_setup(flask_req_ctx, setup_account) -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_maker.begin() as session:
session.execute(
sa.delete(HumanInputContact).where(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.account_id == setup_account.id,
)
)
barrier = Barrier(2)
def write_contact(contact_id: str) -> Contact | ContactRejectionCode:
repository = SQLAlchemyContactDirectoryRepository(session_maker)
contact = Contact.organization_account(
contact_id=ContactId(contact_id),
account_id=AccountId(setup_account.id),
name="Concurrent Account",
email="concurrent@example.com",
now=UtcTimestamp.now(),
)
barrier.wait()
try:
return repository.save_organization_contact(contact)
except ContactDirectoryError as error:
return error.code
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(write_contact, (str(uuidv7()), str(uuidv7()))))
assert sum(isinstance(result, Contact) for result in results) == 1
assert results.count(ContactRejectionCode.CONFLICTING_IDENTITY) == 1
with session_maker() as session:
count = session.scalar(
sa.select(sa.func.count(HumanInputContact.id)).where(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.account_id == setup_account.id,
)
)
assert count == 1
finally:
with session_maker.begin() as session:
session.execute(
sa.delete(HumanInputContact).where(
HumanInputContact.tenant_id.is_(None),
HumanInputContact.account_id == setup_account.id,
)
)
def test_concurrent_organization_and_external_admission_share_identity_claim(flask_req_ctx, setup_account) -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_maker() as session:
workspace_id = session.scalar(
sa.select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == setup_account.id)
)
assert workspace_id is not None
normalized_email = f"concurrent-org-external-{uuidv7()}@example.com"
organization_account_id = str(uuidv7())
organization_contact_id = ContactId(str(uuidv7()))
with session_maker.begin() as session:
organization_account = Account(
name="Concurrent Organization Account",
email=normalized_email,
status=AccountStatus.ACTIVE,
)
organization_account.id = organization_account_id
session.add(organization_account)
first_lock_acquired = Event()
external_connection_ready = Event()
release_first_lock = Event()
organization_thread_id: list[int] = []
organization_backend_pid: list[int] = []
external_backend_pid: list[int] = []
def is_deployment_lock_statement(statement: str) -> bool:
normalized_statement = " ".join(statement.lower().split())
return (
normalized_statement.startswith("select")
and "from dify_setups" in normalized_statement
and "for update" in normalized_statement
)
def pause_after_first_lock(
_connection,
_cursor,
statement,
_parameters,
_context,
_executemany,
) -> None:
if (
organization_thread_id
and get_ident() == organization_thread_id[0]
and is_deployment_lock_statement(statement)
):
first_lock_acquired.set()
assert release_first_lock.wait(timeout=10)
def save_organization_contact() -> Contact | ContactRejectionCode:
with db.engine.connect() as connection:
backend_pid = connection.scalar(sa.select(sa.func.pg_backend_pid()))
assert backend_pid is not None
organization_backend_pid.append(backend_pid)
connection.commit()
repository = SQLAlchemyContactDirectoryRepository(sessionmaker(bind=connection, expire_on_commit=False))
organization_thread_id.append(get_ident())
try:
return repository.save_organization_contact(
Contact.organization_account(
contact_id=organization_contact_id,
account_id=AccountId(organization_account_id),
name="Concurrent Organization Account",
email=normalized_email,
now=UtcTimestamp.now(),
)
)
except ContactDirectoryError as error:
return error.code
def admit_external() -> Contact | ContactRejectionCode:
with db.engine.connect() as connection:
backend_pid = connection.scalar(sa.select(sa.func.pg_backend_pid()))
assert backend_pid is not None
external_backend_pid.append(backend_pid)
connection.commit()
external_connection_ready.set()
repository = SQLAlchemyContactDirectoryRepository(sessionmaker(bind=connection, expire_on_commit=False))
try:
return repository.admit_external(
WorkspaceId(workspace_id),
name="Concurrent External",
email=normalized_email,
)
except ContactDirectoryError as error:
return error.code
def wait_until_external_is_blocked() -> None:
deadline = monotonic() + 10
with session_maker() as observer:
while monotonic() < deadline:
blocking_pids = observer.scalar(sa.select(sa.func.pg_blocking_pids(external_backend_pid[0])))
if blocking_pids is not None and organization_backend_pid[0] in blocking_pids:
return
sleep(0.01)
raise AssertionError("External admission did not block on the Organization deployment lock")
executor = ThreadPoolExecutor(max_workers=2)
event.listen(db.engine, "after_cursor_execute", pause_after_first_lock)
try:
organization_future = executor.submit(save_organization_contact)
assert first_lock_acquired.wait(timeout=10)
external_future = executor.submit(admit_external)
assert external_connection_ready.wait(timeout=10)
wait_until_external_is_blocked()
assert not external_future.done()
release_first_lock.set()
results = [organization_future.result(timeout=10), external_future.result(timeout=10)]
assert sum(isinstance(result, Contact) for result in results) == 1
assert results.count(ContactRejectionCode.CONFLICTING_IDENTITY) == 1
assert isinstance(results[0], Contact)
assert results[1] is ContactRejectionCode.CONFLICTING_IDENTITY
with session_maker() as session:
contact_count = session.scalar(
sa.select(sa.func.count(HumanInputContact.id)).where(
HumanInputContact.normalized_email == normalized_email
)
)
assert contact_count == 1
finally:
release_first_lock.set()
event.remove(db.engine, "after_cursor_execute", pause_after_first_lock)
executor.shutdown(wait=True, cancel_futures=True)
with session_maker.begin() as session:
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.normalized_email == normalized_email))
session.execute(sa.delete(Account).where(Account.id == organization_account_id))
def test_concurrent_platform_enable_is_idempotent(flask_req_ctx, setup_account) -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_maker() as session:
workspace_id = session.scalar(
sa.select(TenantAccountJoin.tenant_id).where(TenantAccountJoin.account_id == setup_account.id)
)
assert workspace_id is not None
repository = SQLAlchemyContactDirectoryRepository(session_maker)
contact = repository.save_organization_contact(
Contact.organization_account(
contact_id=ContactId(str(uuidv7())),
account_id=AccountId(setup_account.id),
name="Concurrent Platform Account",
email="concurrent-platform@example.com",
now=UtcTimestamp.now(),
)
)
barrier = Barrier(2)
def enable_platform_contact() -> ContactRejectionCode | None:
concurrent_repository = SQLAlchemyContactDirectoryRepository(session_maker)
barrier.wait()
try:
concurrent_repository.set_platform_availability(
WorkspaceId(workspace_id),
contact.id,
added_by_account_id=AccountId(setup_account.id),
enabled=True,
)
except ContactDirectoryError as error:
return error.code
return None
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(lambda _index: enable_platform_contact(), range(2)))
assert results == [None, None]
with session_maker() as session:
entry_count = session.scalar(
sa.select(sa.func.count(HumanInputPlatformContactWorkspaceEntry.id)).where(
HumanInputPlatformContactWorkspaceEntry.tenant_id == workspace_id,
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id),
)
)
assert entry_count == 1
finally:
with session_maker.begin() as session:
session.execute(
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id)
)
)
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(contact.id)))
def test_snapshot_uses_one_repeatable_read_view_across_statements(flask_req_ctx, setup_account) -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
with session_maker() as session:
membership = session.scalar(
sa.select(TenantAccountJoin).where(TenantAccountJoin.account_id == setup_account.id)
)
assert membership is not None
workspace_id = WorkspaceId(membership.tenant_id)
repository = SQLAlchemyContactDirectoryRepository(session_maker)
contact = repository.save_organization_contact(
Contact.organization_account(
contact_id=ContactId(str(uuidv7())),
account_id=AccountId(setup_account.id),
name="Snapshot Account",
email="snapshot-account@example.com",
now=UtcTimestamp.now(),
)
)
contact_query_finished = Event()
mutation_committed = Event()
loader_thread_id: list[int] = []
def pause_after_contact_query(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
normalized_statement = " ".join(statement.lower().split())
if (
loader_thread_id
and get_ident() == loader_thread_id[0]
and normalized_statement.startswith("select")
and "from human_input_contacts" in normalized_statement
):
contact_query_finished.set()
assert mutation_committed.wait(timeout=10)
def load_snapshot() -> ContactDirectorySnapshot:
loader_thread_id.append(get_ident())
return repository.load_snapshot(workspace_id)
event.listen(db.engine, "after_cursor_execute", pause_after_contact_query)
future = None
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(load_snapshot)
assert contact_query_finished.wait(timeout=10)
with session_maker.begin() as session:
session.execute(
sa.delete(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == str(workspace_id),
TenantAccountJoin.account_id == setup_account.id,
)
)
account = session.get_one(Account, setup_account.id)
account.status = AccountStatus.BANNED
entry = HumanInputPlatformContactWorkspaceEntry(
tenant_id=str(workspace_id),
contact_id=str(contact.id),
added_by_account_id=setup_account.id,
)
entry.id = str(uuidv7())
session.add(entry)
mutation_committed.set()
snapshot = future.result(timeout=10)
assert snapshot.member_account_ids == frozenset({AccountId(setup_account.id)})
assert snapshot.platform_contact_ids == frozenset()
assert snapshot.unavailable_account_ids == frozenset()
finally:
mutation_committed.set()
event.remove(db.engine, "after_cursor_execute", pause_after_contact_query)
if future is not None and not future.done():
future.result(timeout=10)
with session_maker.begin() as session:
session.execute(
sa.delete(HumanInputPlatformContactWorkspaceEntry).where(
HumanInputPlatformContactWorkspaceEntry.contact_id == str(contact.id)
)
)
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(contact.id)))
account = session.get_one(Account, setup_account.id)
account.status = AccountStatus.ACTIVE
existing_membership = session.scalar(
sa.select(TenantAccountJoin.id).where(
TenantAccountJoin.tenant_id == str(workspace_id),
TenantAccountJoin.account_id == setup_account.id,
)
)
if existing_membership is None:
restored_membership = TenantAccountJoin(
tenant_id=str(workspace_id),
account_id=setup_account.id,
role=membership.role,
current=membership.current,
invited_by=membership.invited_by,
)
restored_membership.id = membership.id
session.add(restored_membership)
@@ -0,0 +1,325 @@
"""PostgreSQL-only concurrency coverage for the IM Control Plane."""
from concurrent.futures import ThreadPoolExecutor
from threading import Barrier
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import sessionmaker
from core.human_input_v2.contact_directory import Contact
from core.human_input_v2.entities import IMProvider
from core.human_input_v2.im_integration import (
ActiveRunDecisionKind,
ApplyReconciliationStatus,
ConfigurationTransition,
EncryptedCredentials,
IMIntegration,
MatchKind,
ProviderDirectoryEntry,
ProviderTenantIdentity,
ReconciliationAction,
ReconciliationPlan,
StaleRevision,
)
from core.human_input_v2.shared import AccountId, ContactId, IMSyncRunId, IntegrationId, UtcTimestamp, WorkspaceId
from extensions.ext_database import db
from libs.uuid_utils import uuidv7
from models.human_input_v2 import (
HumanInputContact,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputIMSyncResult,
HumanInputIMSyncRun,
)
from repositories.human_input_v2.contact_directory.mappers import contact_to_record
from repositories.human_input_v2.im_integration.repository import SQLAlchemyIMControlPlaneRepository
def _require_postgresql() -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
def _integration(integration_id: str, workspace_id: str | None) -> IMIntegration:
return IMIntegration.create(
integration_id=IntegrationId(integration_id),
workspace_id=WorkspaceId(workspace_id) if workspace_id is not None else None,
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, f"provider-{workspace_id or 'deployment'}"),
encrypted_credentials=EncryptedCredentials.from_mapping(
{"app_id": "app-1", "encrypted_app_secret": "ciphertext"}
),
configured_by_account_id=AccountId(str(uuidv7())),
callback_url=None,
now=UtcTimestamp.now(),
)
def _cleanup(
session_maker,
integration_ids: tuple[str, ...],
*,
contact_ids: tuple[str, ...] = (),
) -> None:
with session_maker.begin() as session:
run_ids = session.scalars(
sa.select(HumanInputIMSyncRun.id).where(HumanInputIMSyncRun.integration_id.in_(integration_ids))
).all()
if run_ids:
session.execute(sa.delete(HumanInputIMSyncResult).where(HumanInputIMSyncResult.sync_run_id.in_(run_ids)))
session.execute(sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id.in_(integration_ids)))
session.execute(sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id.in_(integration_ids)))
session.execute(sa.delete(HumanInputIMSyncRun).where(HumanInputIMSyncRun.integration_id.in_(integration_ids)))
session.execute(sa.delete(HumanInputIMIntegration).where(HumanInputIMIntegration.id.in_(integration_ids)))
if contact_ids:
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id.in_(contact_ids)))
def test_concurrent_deployment_integration_creation_has_exactly_one_winner(flask_req_ctx, setup_account) -> None:
_require_postgresql()
assert setup_account.id
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
integration_ids = (str(uuidv7()), str(uuidv7()))
with session_maker() as session:
if session.scalar(
sa.select(sa.func.count(HumanInputIMIntegration.id)).where(HumanInputIMIntegration.tenant_id.is_(None))
):
pytest.skip("requires a deployment without an existing IM integration")
barrier = Barrier(2)
def create(integration_id: str) -> IMIntegration | str:
barrier.wait()
try:
return SQLAlchemyIMControlPlaneRepository(session_maker).create_integration(
_integration(integration_id, None)
)
except ValueError as error:
return str(error)
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(create, integration_ids))
assert sum(isinstance(result, IMIntegration) for result in results) == 1
assert results.count("deployment-wide IM integration already exists") == 1
with session_maker() as session:
count = session.scalar(
sa.select(sa.func.count(HumanInputIMIntegration.id)).where(
HumanInputIMIntegration.id.in_(integration_ids),
HumanInputIMIntegration.tenant_id.is_(None),
)
)
assert count == 1
finally:
_cleanup(session_maker, integration_ids)
def test_concurrent_configuration_cas_has_exactly_one_winner(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
integration_id = str(uuidv7())
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
barrier = Barrier(2)
def rotate(secret: str) -> IMIntegration | StaleRevision:
decision = integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=integration.provider_tenant,
encrypted_credentials=EncryptedCredentials.from_mapping(
{"app_id": "app-1", "encrypted_app_secret": secret}
),
configured_by_account_id=None,
callback_url=None,
now=UtcTimestamp.now(),
)
assert isinstance(decision, ConfigurationTransition)
barrier.wait()
return SQLAlchemyIMControlPlaneRepository(session_maker).compare_and_swap_configuration(decision)
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(rotate, ("ciphertext-1", "ciphertext-2")))
assert sum(isinstance(result, IMIntegration) for result in results) == 1
assert sum(isinstance(result, StaleRevision) for result in results) == 1
finally:
_cleanup(session_maker, (integration_id,))
def test_concurrent_sync_triggers_create_at_most_one_active_run(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
integration_id = str(uuidv7())
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
barrier = Barrier(2)
def trigger(_index: int):
barrier.wait()
return SQLAlchemyIMControlPlaneRepository(session_maker).create_or_get_active_run(
integration.revision,
sync_run_id=IMSyncRunId(str(uuidv7())),
started_by_account_id=None,
now=UtcTimestamp.now(),
)
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(trigger, range(2)))
assert [result.kind for result in results].count(ActiveRunDecisionKind.CREATED) == 1
assert [result.kind for result in results].count(ActiveRunDecisionKind.EXISTING_ACTIVE) == 1
with session_maker() as session:
count = session.scalar(
sa.select(sa.func.count(HumanInputIMSyncRun.id)).where(
HumanInputIMSyncRun.integration_id == integration_id
)
)
assert count == 1
finally:
_cleanup(session_maker, (integration_id,))
def test_concurrent_worker_retry_applies_one_sync_run_idempotently(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
integration_id = str(uuidv7())
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
run_decision = repository.create_or_get_active_run(
integration.revision,
sync_run_id=IMSyncRunId(str(uuidv7())),
started_by_account_id=None,
now=UtcTimestamp.now(),
)
assert run_decision.run is not None
contact_id = ContactId(str(uuidv7()))
contact = Contact.organization_account(
contact_id=contact_id,
account_id=AccountId(str(uuidv7())),
name="Concurrent Retry Reviewer",
email=f"concurrent-retry-{uuidv7()}@example.com",
now=UtcTimestamp.now(),
)
with session_maker.begin() as session:
session.add(contact_to_record(contact))
entry = ProviderDirectoryEntry.create(
provider_user_id=f"provider-user-{uuidv7()}",
display_name=contact.name,
email=contact.email,
raw_payload={"source": "concurrent-retry"},
)
plan = ReconciliationPlan(
sync_run_id=run_decision.run.id,
integration_revision=integration.revision,
provider=integration.provider_tenant.provider,
actions=(
ReconciliationAction(
entry=entry,
match_kind=MatchKind.NORMALIZED_EMAIL,
identity_id=None,
binding_id=None,
contact_id=contact.id,
),
),
removed_identity_ids=(),
)
barrier = Barrier(2)
def apply(_index: int):
barrier.wait()
return SQLAlchemyIMControlPlaneRepository(session_maker).apply_reconciliation(plan, now=UtcTimestamp.now())
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(apply, range(2)))
assert [result.status for result in results].count(ApplyReconciliationStatus.APPLIED) == 1
assert [result.status for result in results].count(ApplyReconciliationStatus.ALREADY_APPLIED) == 1
assert all(len(result.results) == 1 for result in results)
assert results[0].results[0].id == results[1].results[0].id
with session_maker() as session:
assert (
session.scalar(
sa.select(sa.func.count(HumanInputIMIdentity.id)).where(
HumanInputIMIdentity.integration_id == integration_id
)
)
== 1
)
assert (
session.scalar(
sa.select(sa.func.count(HumanInputIMBinding.id)).where(
HumanInputIMBinding.integration_id == integration_id
)
)
== 1
)
assert (
session.scalar(
sa.select(sa.func.count(HumanInputIMSyncResult.id)).where(
HumanInputIMSyncResult.sync_run_id == str(plan.sync_run_id)
)
)
== 1
)
finally:
_cleanup(session_maker, (integration_id,), contact_ids=(str(contact_id),))
def test_stale_reconciliation_records_diagnostic_without_current_mutation(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
integration_id = str(uuidv7())
repository = SQLAlchemyIMControlPlaneRepository(session_maker)
integration = repository.create_integration(_integration(integration_id, str(uuidv7())))
run_decision = repository.create_or_get_active_run(
integration.revision,
sync_run_id=IMSyncRunId(str(uuidv7())),
started_by_account_id=None,
now=UtcTimestamp.now(),
)
assert run_decision.run is not None
rotation = integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=integration.provider_tenant,
encrypted_credentials=EncryptedCredentials.from_mapping({"app_id": "app-1", "encrypted_app_secret": "rotated"}),
configured_by_account_id=None,
callback_url=None,
now=UtcTimestamp.now(),
)
assert isinstance(rotation, ConfigurationTransition)
repository.compare_and_swap_configuration(rotation)
plan = ReconciliationPlan(
sync_run_id=run_decision.run.id,
integration_revision=integration.revision,
provider=integration.provider_tenant.provider,
actions=(),
removed_identity_ids=(),
)
try:
result = repository.apply_reconciliation(plan, now=UtcTimestamp.now())
assert result.status is ApplyReconciliationStatus.STALE_REVISION
with session_maker() as session:
assert (
session.scalar(
sa.select(sa.func.count(HumanInputIMSyncResult.id)).where(
HumanInputIMSyncResult.sync_run_id == str(plan.sync_run_id)
)
)
== 1
)
assert (
session.scalar(
sa.select(sa.func.count(HumanInputIMIdentity.id)).where(
HumanInputIMIdentity.integration_id == integration_id
)
)
== 0
)
finally:
_cleanup(session_maker, (integration_id,))
@@ -0,0 +1,170 @@
"""PostgreSQL-only concurrency coverage for grant-scoped OTP replacement."""
from concurrent.futures import ThreadPoolExecutor
from datetime import timedelta
from threading import Barrier
import pytest
import sqlalchemy as sa
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import FormRef, OTPChallengeRejectionReason, OTPCodeHash
from core.human_input_v2.entities import (
HumanInputApproverGrantSubjectType,
HumanInputOTPChallengeStatus,
HumanInputV2FormKind,
HumanInputV2FormStatus,
)
from core.human_input_v2.shared import ApproverGrantId, FormId, OTPChallengeId, UtcTimestamp, WorkspaceId
from extensions.ext_database import db
from libs.uuid_utils import uuidv7
from models.human_input_v2 import (
FormApproverGrantMatchedSources,
FormApproverGrantSubjectSnapshot,
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormDefinition,
HumanInputV2FormOTPChallenge,
)
from repositories.human_input_v2.approval.repository import (
OTPChallengeAuditFact,
SQLAlchemyOTPChallengeRepository,
)
class _FixedClock:
def __init__(self, now: UtcTimestamp) -> None:
self._now = now
def now(self) -> UtcTimestamp:
return self._now
class _StatelessHasher:
def hash_code(self, plaintext_code: str) -> OTPCodeHash:
return OTPCodeHash(f"integration:{plaintext_code}", "test")
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool:
return code_hash.encoded_value == f"integration:{plaintext_code}"
class _TransactionOnlyAuditWriter:
"""Concurrency test seam; unit tests cover the concrete audit-row writer."""
def append(self, session: Session, fact: OTPChallengeAuditFact) -> None:
session.execute(sa.select(sa.literal(fact.audit_event_id)))
def _require_postgresql() -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
def test_concurrent_resend_leaves_exactly_one_usable_challenge(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
workspace_id = str(uuidv7())
form_id = str(uuidv7())
grant_id = str(uuidv7())
contact_id = str(uuidv7())
initial_challenge_id = str(uuidv7())
replacement_ids = (str(uuidv7()), str(uuidv7()))
issued_at = UtcTimestamp.now()
grant_ref = FormRef(WorkspaceId(workspace_id), FormId(form_id)).grant(ApproverGrantId(grant_id))
contact_email = f"otp-{uuidv7()}@example.com"
contact = HumanInputContact(
name="Concurrent OTP Reviewer",
normalized_name="concurrent otp reviewer",
identity_source=HumanInputContactIdentitySource.EXTERNAL,
tenant_id=workspace_id,
email=contact_email,
normalized_email=contact_email,
)
contact.id = contact_id
form = HumanInputV2Form(
tenant_id=workspace_id,
app_id=str(uuidv7()),
form_definition=HumanInputV2FormDefinition(form_content="Approve"),
rendered_content="Approve",
node_timeout_at=issued_at.value + timedelta(hours=1),
global_expires_at=issued_at.value + timedelta(hours=2),
form_kind=HumanInputV2FormKind.DELIVERY_TEST,
status=HumanInputV2FormStatus.WAITING,
workflow_pause_id=None,
node_execution_id=None,
)
form.id = form_id
grant = HumanInputV2FormApproverGrant(
tenant_id=workspace_id,
form_id=form_id,
subject_type=HumanInputApproverGrantSubjectType.CONTACT,
subject_key=f"contact:{contact_id}",
matched_sources=FormApproverGrantMatchedSources(),
subject_snapshot=FormApproverGrantSubjectSnapshot(email=contact.email),
contact_id=contact_id,
)
grant.id = grant_id
with session_maker.begin() as session:
session.add_all([contact, form, grant])
audit_writer = _TransactionOnlyAuditWriter()
SQLAlchemyOTPChallengeRepository(
session_maker,
clock=_FixedClock(issued_at),
code_hasher=_StatelessHasher(),
audit_writer=audit_writer,
).issue_initial(
grant_ref,
challenge_id=OTPChallengeId(initial_challenge_id),
audit_event_id=str(uuidv7()),
challenge_token_hash="a" * 64,
plaintext_code="123456",
)
resend_clock = _FixedClock(UtcTimestamp(issued_at.value + timedelta(seconds=60)))
barrier = Barrier(2)
def resend(index: int):
barrier.wait()
return SQLAlchemyOTPChallengeRepository(
session_maker,
clock=resend_clock,
code_hasher=_StatelessHasher(),
audit_writer=audit_writer,
).replace_current(
grant_ref,
challenge_id=OTPChallengeId(replacement_ids[index]),
audit_event_id=str(uuidv7()),
challenge_token_hash=("b" if index == 0 else "c") * 64,
plaintext_code="123456",
)
try:
with ThreadPoolExecutor(max_workers=2) as executor:
results = list(executor.map(resend, range(2)))
assert sum(result.replacement is not None for result in results) == 1
assert [result.rejection for result in results].count(OTPChallengeRejectionReason.RESEND_COOLDOWN) == 1
with session_maker() as session:
records = session.scalars(
sa.select(HumanInputV2FormOTPChallenge).where(
HumanInputV2FormOTPChallenge.tenant_id == workspace_id,
HumanInputV2FormOTPChallenge.form_id == form_id,
HumanInputV2FormOTPChallenge.approver_grant_id == grant_id,
)
).all()
assert len(records) == 2
assert sum(record.status is HumanInputOTPChallengeStatus.PENDING for record in records) == 1
finally:
with session_maker.begin() as session:
session.execute(
sa.delete(HumanInputV2FormOTPChallenge).where(
HumanInputV2FormOTPChallenge.approver_grant_id == grant_id
)
)
session.execute(
sa.delete(HumanInputV2FormApproverGrant).where(HumanInputV2FormApproverGrant.id == grant_id)
)
session.execute(sa.delete(HumanInputV2Form).where(HumanInputV2Form.id == form_id))
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == contact_id))
@@ -0,0 +1,682 @@
"""PostgreSQL-only first-success and authorization-snapshot coverage."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor
from contextlib import contextmanager
from dataclasses import dataclass
from datetime import timedelta
from threading import Event, Lock, get_ident
from time import monotonic, sleep
import pytest
import sqlalchemy as sa
from sqlalchemy import event
from sqlalchemy.orm import Session, sessionmaker
from core.human_input_v2.approval import (
ApproverGrant,
AuthorizationContext,
AuthorizedSubmissionCommit,
CanonicalSubjectKey,
ContactApprovalSubject,
ContactOTPSubject,
DeliveryEndpoint,
EmailEndpointConfiguration,
FormAuthorizationAuditEvent,
FormRef,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONObject,
HumanInputForm,
IMEndpointConfiguration,
SubjectSnapshot,
SubmissionAttemptScope,
SubmissionAuthorizer,
SubmissionCommitResult,
SubmissionCommitStatus,
SubmissionRepository,
SubmissionTransaction,
VerifiedEmailOTPProof,
VerifiedIMIdentityProof,
)
from core.human_input_v2.entities import (
HumanInputV2FormKind,
HumanInputV2FormStatus,
IMBindingScope,
IMIntegrationStatus,
IMProvider,
)
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
AuditEventId,
ContactId,
DeliveryEndpointId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
OTPChallengeId,
SubmissionId,
UtcTimestamp,
WorkspaceId,
)
from extensions.ext_database import db
from libs.uuid_utils import uuidv7
from models.account import Account, AccountStatus, Tenant, TenantAccountJoin, TenantAccountRole
from models.human_input_v2 import (
HumanInputContact,
HumanInputContactIdentitySource,
HumanInputIMBinding,
HumanInputIMIdentity,
HumanInputIMIntegration,
HumanInputV2Form,
HumanInputV2FormApproverGrant,
HumanInputV2FormAuditEvent,
HumanInputV2FormDeliveryEndpoint,
HumanInputV2FormSubmission,
IMIdentityRawPayload,
SlackIMIntegrationEncryptedCredentials,
)
from repositories.human_input_v2.form.mappers import endpoint_to_record, form_to_record, grant_to_record
from repositories.human_input_v2.submission.repository import SQLAlchemySubmissionRepository
from services.human_input_v2.submission import (
SubmitFormCommand,
SubmitFormResult,
SubmitFormResultStatus,
SubmitHumanInputFormHandler,
WorkflowResumeIdentity,
)
@dataclass(frozen=True, slots=True)
class _SeededScenario:
workspace_id: WorkspaceId
account_id: AccountId
contact_id: ContactId
form_ref: FormRef
grant_id: ApproverGrantId
email_endpoint_id: DeliveryEndpointId
im_endpoint_id: DeliveryEndpointId
integration_id: IntegrationId
identity_id: IMIdentityId
binding_id: IMBindingId
normalized_email: NormalizedEmail
provider_tenant_id: str
provider_user_id: str
workflow_pause_id: str
node_execution_id: str
class _RecordingResumePort:
def __init__(self) -> None:
self._lock = Lock()
self.identities: set[WorkflowResumeIdentity] = set()
def enqueue_once(self, identity: WorkflowResumeIdentity) -> None:
with self._lock:
self.identities.add(identity)
class _PausingSubmissionTransaction:
def __init__(
self,
delegate: SubmissionTransaction,
*,
context_loaded: Event,
release_transaction: Event,
) -> None:
self._delegate = delegate
self._context_loaded = context_loaded
self._release_transaction = release_transaction
def load_authorization_context(self, *, proof: object) -> AuthorizationContext:
context = self._delegate.load_authorization_context(proof=proof)
self._context_loaded.set()
if not self._release_transaction.wait(timeout=10):
raise AssertionError("winner transaction was not released")
return context
def append_rejection_audit(self, event: FormAuthorizationAuditEvent) -> None:
self._delegate.append_rejection_audit(event)
def commit_authorized_submission_once(
self,
commit: AuthorizedSubmissionCommit,
) -> SubmissionCommitResult:
return self._delegate.commit_authorized_submission_once(commit)
class _PausingSubmissionRepository:
def __init__(
self,
delegate: SubmissionRepository,
*,
context_loaded: Event,
release_transaction: Event,
) -> None:
self._delegate = delegate
self._context_loaded = context_loaded
self._release_transaction = release_transaction
@contextmanager
def transaction(self, scope: SubmissionAttemptScope):
with self._delegate.transaction(scope) as transaction:
yield _PausingSubmissionTransaction(
transaction,
context_loaded=self._context_loaded,
release_transaction=self._release_transaction,
)
class _CountingSubmissionRepository:
def __init__(self, delegate: SubmissionRepository) -> None:
self._delegate = delegate
self._lock = Lock()
self.attempt_count = 0
@contextmanager
def transaction(self, scope: SubmissionAttemptScope):
with self._lock:
self.attempt_count += 1
with self._delegate.transaction(scope) as transaction:
yield transaction
class _NamedPostgreSQLSession(Session):
"""Test-only Session that labels its PostgreSQL backend for lock observation."""
@event.listens_for(_NamedPostgreSQLSession, "after_begin")
def _set_postgresql_application_name(session: Session, _transaction, connection) -> None:
application_name = session.info.get("application_name")
if isinstance(application_name, str):
connection.execute(
sa.text("SET LOCAL application_name = :application_name"),
{"application_name": application_name},
)
def _require_postgresql() -> None:
if db.engine.dialect.name != "postgresql":
pytest.skip("requires the CI PostgreSQL integration database")
def _wait_for_postgresql_lock_wait(
session_maker: sessionmaker[Session],
*,
application_name: str,
) -> tuple[int, tuple[int, ...]]:
deadline = monotonic() + 10
while monotonic() < deadline:
with session_maker() as session:
row = session.execute(
sa.text(
"SELECT pid, pg_blocking_pids(pid) AS blocking_pids "
"FROM pg_stat_activity "
"WHERE datname = current_database() "
"AND application_name = :application_name "
"AND wait_event_type = 'Lock' "
"ORDER BY backend_start DESC LIMIT 1"
),
{"application_name": application_name},
).one_or_none()
if row is not None and row.blocking_pids:
return row.pid, tuple(row.blocking_pids)
sleep(0.05)
raise AssertionError("loser PostgreSQL backend did not enter a row-lock wait")
type _TimestampedRecord = HumanInputContact | HumanInputIMBinding | HumanInputIMIdentity | HumanInputIMIntegration
def _set_record_identity(record: _TimestampedRecord, record_id: str, now: UtcTimestamp) -> None:
record.id = record_id
record.created_at = now.value
record.updated_at = now.value
def _seed_scenario(session_maker: sessionmaker[Session]) -> _SeededScenario:
now = UtcTimestamp.now()
workspace_id = WorkspaceId(str(uuidv7()))
account_id = AccountId(str(uuidv7()))
contact_id = ContactId(str(uuidv7()))
form_ref = FormRef(workspace_id, FormId(str(uuidv7())))
grant_id = ApproverGrantId(str(uuidv7()))
email_endpoint_id = DeliveryEndpointId(str(uuidv7()))
im_endpoint_id = DeliveryEndpointId(str(uuidv7()))
integration_id = IntegrationId(str(uuidv7()))
identity_id = IMIdentityId(str(uuidv7()))
binding_id = IMBindingId(str(uuidv7()))
normalized_email = NormalizedEmail(f"submission-{uuidv7()}@example.com")
provider_tenant_id = f"provider-tenant-{uuidv7()}"
provider_user_id = f"provider-user-{uuidv7()}"
workflow_pause_id = str(uuidv7())
node_execution_id = str(uuidv7())
subject = ContactApprovalSubject(contact_id)
grant = ApproverGrant(
ref=form_ref.grant(grant_id),
subject=subject,
subject_key=CanonicalSubjectKey.for_contact(contact_id),
matched_sources=(),
subject_snapshot=SubjectSnapshot("Concurrent Reviewer", str(normalized_email)),
created_at=now,
updated_at=now,
)
form = HumanInputForm(
ref=form_ref,
app_id=AppId(str(uuidv7())),
definition=FrozenFormDefinition(
form_content="Approve",
inputs=(),
actions=(FrozenFormAction("approve", "Approve", "primary"),),
default_values=FrozenJSONObject.from_mapping({}),
node_title="Review",
display_in_ui=True,
),
rendered_content="Approve",
node_timeout_at=UtcTimestamp(now.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(now.value + timedelta(hours=2)),
kind=HumanInputV2FormKind.RUNTIME,
status=HumanInputV2FormStatus.WAITING,
workflow_pause_id=workflow_pause_id,
node_execution_id=node_execution_id,
grants=(grant,),
created_at=now,
updated_at=now,
)
email_endpoint = DeliveryEndpoint(
ref=grant.ref.endpoint(email_endpoint_id),
configuration=EmailEndpointConfiguration(normalized_email),
address_hash="a" * 64,
access_capability=None,
created_at=now,
updated_at=now,
)
im_endpoint = DeliveryEndpoint(
ref=grant.ref.endpoint(im_endpoint_id),
configuration=IMEndpointConfiguration(
integration_id=integration_id,
provider=IMProvider.SLACK,
provider_tenant_id=provider_tenant_id,
identity_id=identity_id,
binding_id=binding_id,
provider_user_id=provider_user_id,
),
address_hash="b" * 64,
access_capability=None,
created_at=now,
updated_at=now,
)
tenant = Tenant(name="Submission Runtime Concurrency")
tenant.id = str(workspace_id)
account = Account(name="Concurrent Reviewer", email=str(normalized_email), status=AccountStatus.ACTIVE)
account.id = str(account_id)
membership = TenantAccountJoin(
tenant_id=str(workspace_id),
account_id=str(account_id),
current=False,
role=TenantAccountRole.NORMAL,
)
membership.id = str(uuidv7())
contact = HumanInputContact(
name="Concurrent Reviewer",
normalized_name="concurrent reviewer",
identity_source=HumanInputContactIdentitySource.ORGANIZATION_ACCOUNT,
tenant_id=None,
account_id=str(account_id),
email=str(normalized_email),
normalized_email=str(normalized_email),
avatar_file_id=None,
)
_set_record_identity(contact, str(contact_id), now)
integration = HumanInputIMIntegration(
provider=IMProvider.SLACK,
encrypted_credentials=SlackIMIntegrationEncryptedCredentials(
client_id="client-1",
encrypted_client_secret="encrypted-client-secret",
encrypted_signing_secret="encrypted-signing-secret",
encrypted_bot_token="encrypted-bot-token",
),
tenant_id=str(workspace_id),
provider_tenant_id=provider_tenant_id,
status=IMIntegrationStatus.CONNECTED,
config_version=1,
configured_by_account_id=str(account_id),
callback_url=None,
safe_status_reason=None,
last_checked_at=now.value,
)
_set_record_identity(integration, str(integration_id), now)
identity = HumanInputIMIdentity(
integration_id=str(integration_id),
provider=IMProvider.SLACK,
provider_user_id=provider_user_id,
display_name="Concurrent Reviewer",
normalized_name="concurrent reviewer",
email=str(normalized_email),
normalized_email=str(normalized_email),
raw_payload=IMIdentityRawPayload({}),
last_seen_sync_run_id=None,
last_seen_at=now.value,
)
_set_record_identity(identity, str(identity_id), now)
binding = HumanInputIMBinding(
integration_id=str(integration_id),
scope=IMBindingScope.WORKSPACE,
scope_id=str(workspace_id),
contact_id=str(contact_id),
im_identity_id=str(identity_id),
provider=IMProvider.SLACK,
bound_by_account_id=str(account_id),
)
_set_record_identity(binding, str(binding_id), now)
with session_maker.begin() as session:
session.add_all(
[
tenant,
account,
membership,
contact,
integration,
identity,
binding,
form_to_record(form),
grant_to_record(grant),
endpoint_to_record(email_endpoint),
endpoint_to_record(im_endpoint),
]
)
return _SeededScenario(
workspace_id=workspace_id,
account_id=account_id,
contact_id=contact_id,
form_ref=form_ref,
grant_id=grant_id,
email_endpoint_id=email_endpoint_id,
im_endpoint_id=im_endpoint_id,
integration_id=integration_id,
identity_id=identity_id,
binding_id=binding_id,
normalized_email=normalized_email,
provider_tenant_id=provider_tenant_id,
provider_user_id=provider_user_id,
workflow_pause_id=workflow_pause_id,
node_execution_id=node_execution_id,
)
def _cleanup_scenario(session_maker: sessionmaker[Session], scenario: _SeededScenario) -> None:
form_id = str(scenario.form_ref.form_id)
with session_maker.begin() as session:
session.execute(sa.delete(HumanInputV2FormSubmission).where(HumanInputV2FormSubmission.form_id == form_id))
session.execute(sa.delete(HumanInputV2FormAuditEvent).where(HumanInputV2FormAuditEvent.form_id == form_id))
session.execute(
sa.delete(HumanInputV2FormDeliveryEndpoint).where(HumanInputV2FormDeliveryEndpoint.form_id == form_id)
)
session.execute(
sa.delete(HumanInputV2FormApproverGrant).where(HumanInputV2FormApproverGrant.form_id == form_id)
)
session.execute(sa.delete(HumanInputV2Form).where(HumanInputV2Form.id == form_id))
session.execute(
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.integration_id == str(scenario.integration_id))
)
session.execute(
sa.delete(HumanInputIMIdentity).where(HumanInputIMIdentity.integration_id == str(scenario.integration_id))
)
session.execute(
sa.delete(HumanInputIMIntegration).where(HumanInputIMIntegration.id == str(scenario.integration_id))
)
session.execute(sa.delete(HumanInputContact).where(HumanInputContact.id == str(scenario.contact_id)))
session.execute(
sa.delete(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == str(scenario.workspace_id),
TenantAccountJoin.account_id == str(scenario.account_id),
)
)
session.execute(sa.delete(Account).where(Account.id == str(scenario.account_id)))
session.execute(sa.delete(Tenant).where(Tenant.id == str(scenario.workspace_id)))
def _email_proof(scenario: _SeededScenario) -> VerifiedEmailOTPProof:
return VerifiedEmailOTPProof(
challenge_ref=scenario.form_ref.grant(scenario.grant_id).challenge(OTPChallengeId(str(uuidv7()))),
subject=ContactOTPSubject(scenario.contact_id),
normalized_email=scenario.normalized_email,
verified_at=UtcTimestamp.now(),
)
def _im_proof(scenario: _SeededScenario) -> VerifiedIMIdentityProof:
return VerifiedIMIdentityProof(
integration_id=scenario.integration_id,
identity_id=scenario.identity_id,
binding_id=scenario.binding_id,
provider=IMProvider.SLACK,
provider_tenant_id=scenario.provider_tenant_id,
provider_user_id=scenario.provider_user_id,
)
def _command(scenario: _SeededScenario, *, proof: object, endpoint_id: DeliveryEndpointId) -> SubmitFormCommand:
return SubmitFormCommand(
scope=SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, endpoint_id),
proof=proof,
selected_action_id="approve",
input_snapshot=FrozenJSONObject.from_mapping({"comment": "approved"}),
canonical_values=FrozenJSONObject.from_mapping({"comment": "approved"}),
submission_id=SubmissionId(str(uuidv7())),
authorization_audit_event_id=AuditEventId(str(uuidv7())),
rejection_audit_event_id=AuditEventId(str(uuidv7())),
resume_identity=WorkflowResumeIdentity(
workspace_id=scenario.form_ref.workspace_id,
form_id=scenario.form_ref.form_id,
workflow_pause_id=scenario.workflow_pause_id,
node_execution_id=scenario.node_execution_id,
),
now=UtcTimestamp.now(),
)
def test_context_load_uses_one_snapshot_across_contact_membership_and_im_queries(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
scenario = _seed_scenario(session_maker)
repository = SQLAlchemySubmissionRepository(session_maker)
scope = SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, scenario.im_endpoint_id)
proof = _im_proof(scenario)
contact_query_finished = Event()
mutation_committed = Event()
loader_thread_id: list[int] = []
def pause_after_contact_query(_connection, _cursor, statement, _parameters, _context, _executemany) -> None:
normalized_statement = " ".join(statement.lower().split())
if (
loader_thread_id
and get_ident() == loader_thread_id[0]
and normalized_statement.startswith("select")
and "from human_input_contacts" in normalized_statement
):
contact_query_finished.set()
assert mutation_committed.wait(timeout=10)
def load_context() -> AuthorizationContext:
loader_thread_id.append(get_ident())
with repository.transaction(scope) as transaction:
return transaction.load_authorization_context(proof=proof)
event.listen(db.engine, "after_cursor_execute", pause_after_contact_query)
future = None
try:
with ThreadPoolExecutor(max_workers=1) as executor:
future = executor.submit(load_context)
assert contact_query_finished.wait(timeout=10)
with session_maker.begin() as mutation_session:
mutation_session.execute(
sa.delete(TenantAccountJoin).where(
TenantAccountJoin.tenant_id == str(scenario.workspace_id),
TenantAccountJoin.account_id == str(scenario.account_id),
)
)
mutation_session.execute(
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.id == str(scenario.binding_id))
)
mutation_committed.set()
context = future.result(timeout=10)
assert context.current_contact is not None
assert context.current_contact.workspace_available is True
assert context.current_im_binding is not None
assert context.current_im_binding.binding_id == scenario.binding_id
finally:
mutation_committed.set()
event.remove(db.engine, "after_cursor_execute", pause_after_contact_query)
if future is not None and not future.done():
future.result(timeout=10)
_cleanup_scenario(session_maker, scenario)
def test_row_lock_serialization_loser_retries_and_observes_completed_form(flask_req_ctx) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
scenario = _seed_scenario(session_maker)
resume_port = _RecordingResumePort()
winner_context_loaded = Event()
release_winner = Event()
winner_repository = _PausingSubmissionRepository(
SQLAlchemySubmissionRepository(session_maker),
context_loaded=winner_context_loaded,
release_transaction=release_winner,
)
loser_application_name = f"submission-serialization-loser-{uuidv7()}"
loser_session_maker = sessionmaker(
bind=db.engine,
class_=_NamedPostgreSQLSession,
expire_on_commit=False,
info={"application_name": loser_application_name},
)
loser_repository = _CountingSubmissionRepository(SQLAlchemySubmissionRepository(loser_session_maker))
winner_command = _command(
scenario,
proof=_email_proof(scenario),
endpoint_id=scenario.email_endpoint_id,
)
loser_command = _command(
scenario,
proof=_im_proof(scenario),
endpoint_id=scenario.im_endpoint_id,
)
def submit(repository: SubmissionRepository, command: SubmitFormCommand) -> SubmitFormResult:
return SubmitHumanInputFormHandler(repository, resume_port).handle(command)
winner_future = None
loser_future = None
try:
with ThreadPoolExecutor(max_workers=2) as executor:
winner_future = executor.submit(submit, winner_repository, winner_command)
assert winner_context_loaded.wait(timeout=10)
loser_future = executor.submit(submit, loser_repository, loser_command)
try:
loser_pid, blocking_pids = _wait_for_postgresql_lock_wait(
session_maker,
application_name=loser_application_name,
)
assert loser_pid not in blocking_pids
assert blocking_pids
finally:
release_winner.set()
winner_result = winner_future.result(timeout=10)
loser_result = loser_future.result(timeout=10)
assert winner_result.status is SubmitFormResultStatus.SUBMITTED
assert winner_result.resume_enqueued is True
assert loser_result.status is SubmitFormResultStatus.ALREADY_COMPLETED
assert loser_result.resume_enqueued is False
assert loser_repository.attempt_count == 2
assert len(resume_port.identities) == 1
with session_maker() as session:
assert (
session.scalar(
sa.select(sa.func.count(HumanInputV2FormSubmission.id)).where(
HumanInputV2FormSubmission.form_id == str(scenario.form_ref.form_id)
)
)
== 1
)
assert (
session.scalar(
sa.select(sa.func.count(HumanInputV2FormAuditEvent.id)).where(
HumanInputV2FormAuditEvent.form_id == str(scenario.form_ref.form_id)
)
)
== 1
)
assert (
session.get_one(HumanInputV2Form, str(scenario.form_ref.form_id)).status
is HumanInputV2FormStatus.SUBMITTED
)
finally:
release_winner.set()
if winner_future is not None and not winner_future.done():
winner_future.result(timeout=10)
if loser_future is not None and not loser_future.done():
loser_future.result(timeout=10)
_cleanup_scenario(session_maker, scenario)
@pytest.mark.parametrize("proof_kind", ["email", "im"])
def test_loaded_context_remains_authoritative_after_identity_change(flask_req_ctx, proof_kind: str) -> None:
_require_postgresql()
session_maker = sessionmaker(bind=db.engine, expire_on_commit=False)
scenario = _seed_scenario(session_maker)
proof = _email_proof(scenario) if proof_kind == "email" else _im_proof(scenario)
endpoint_id = scenario.email_endpoint_id if proof_kind == "email" else scenario.im_endpoint_id
scope = SubmissionAttemptScope(scenario.form_ref, scenario.grant_id, endpoint_id)
try:
with SQLAlchemySubmissionRepository(session_maker).transaction(scope) as transaction:
context = transaction.load_authorization_context(proof=proof)
with session_maker.begin() as mutation_session:
if proof_kind == "email":
mutation_session.execute(
sa.update(HumanInputContact)
.where(HumanInputContact.id == str(scenario.contact_id))
.values(email="changed@example.com", normalized_email="changed@example.com")
)
else:
mutation_session.execute(
sa.delete(HumanInputIMBinding).where(HumanInputIMBinding.id == str(scenario.binding_id))
)
decision = SubmissionAuthorizer.authorize(
context=context,
proof=proof,
selected_action_id="approve",
now=UtcTimestamp.now(),
)
assert decision.authorized is not None
result = transaction.commit_authorized_submission_once(
AuthorizedSubmissionCommit(
submission_id=SubmissionId(str(uuidv7())),
authorization_audit_event_id=AuditEventId(str(uuidv7())),
authorized=decision.authorized,
input_snapshot=FrozenJSONObject.from_mapping({"comment": "approved"}),
canonical_values=FrozenJSONObject.from_mapping({"comment": "approved"}),
)
)
assert result.status is SubmissionCommitStatus.COMMITTED
with session_maker() as session:
assert (
session.scalar(
sa.select(sa.func.count(HumanInputV2FormSubmission.id)).where(
HumanInputV2FormSubmission.form_id == str(scenario.form_ref.form_id)
)
)
== 1
)
finally:
_cleanup_scenario(session_maker, scenario)
@@ -0,0 +1,116 @@
from collections.abc import Iterator
from typing import Annotated, Literal
import pytest
import sqlalchemy as sa
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter
from sqlalchemy import Engine, select
from sqlalchemy.orm import DeclarativeBase, Mapped, Session, mapped_column
from models.types import FrozenPydanticModelColumn
class _ConcretePayload(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
name: str
count: int
class _EmailDestination(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
channel: Literal["email"] = "email"
address: str
class _SlackDestination(BaseModel):
model_config = ConfigDict(extra="forbid", frozen=True, strict=True, validate_default=True)
channel: Literal["slack"] = "slack"
channel_id: str
type _Destination = Annotated[_EmailDestination | _SlackDestination, Field(discriminator="channel")]
class _Base(DeclarativeBase):
pass
class _PydanticJSONRecord(_Base):
__tablename__ = "integration_pydantic_json_records"
id: Mapped[int] = mapped_column(sa.Integer, primary_key=True, autoincrement=True)
concrete: Mapped[_ConcretePayload] = mapped_column(
FrozenPydanticModelColumn(
_ConcretePayload,
),
nullable=False,
)
destination: Mapped[_Destination] = mapped_column(
FrozenPydanticModelColumn(
TypeAdapter(_Destination),
model_types=(_EmailDestination, _SlackDestination),
),
nullable=False,
)
@pytest.fixture
def pydantic_json_engine(db_session_with_containers: Session) -> Engine:
bind = db_session_with_containers.get_bind()
assert isinstance(bind, Engine)
return bind
@pytest.fixture(autouse=True)
def _pydantic_json_schema(pydantic_json_engine: Engine) -> Iterator[None]:
_Base.metadata.create_all(pydantic_json_engine)
try:
yield
finally:
_Base.metadata.drop_all(pydantic_json_engine)
def test_real_database_stores_single_encoded_json_and_round_trips_a_concrete_model(
pydantic_json_engine: Engine,
) -> None:
with Session(pydantic_json_engine) as session:
record = _PydanticJSONRecord(
concrete=_ConcretePayload(name="container", count=3),
destination=_EmailDestination(address="reviewer@example.com"),
)
session.add(record)
session.commit()
record_id = record.id
with Session(pydantic_json_engine) as session:
stored_json = session.execute(
sa.text("SELECT concrete FROM integration_pydantic_json_records WHERE id = :record_id"),
{"record_id": record_id},
).scalar_one()
restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id))
assert stored_json == '{"name":"container","count":3}'
assert restored is not None
assert restored.concrete == _ConcretePayload(name="container", count=3)
assert isinstance(restored.concrete, _ConcretePayload)
def test_real_database_restores_the_discriminated_union_subtype(pydantic_json_engine: Engine) -> None:
with Session(pydantic_json_engine) as session:
record = _PydanticJSONRecord(
concrete=_ConcretePayload(name="container", count=4),
destination=_SlackDestination(channel_id="C123"),
)
session.add(record)
session.commit()
record_id = record.id
with Session(pydantic_json_engine) as session:
restored = session.scalar(select(_PydanticJSONRecord).where(_PydanticJSONRecord.id == record_id))
assert restored is not None
assert restored.destination == _SlackDestination(channel_id="C123")
assert isinstance(restored.destination, _SlackDestination)
@@ -0,0 +1,249 @@
from __future__ import annotations
import pytest
from pydantic import ValidationError
from controllers.common import human_input_v2_contracts as contracts
from controllers.common.human_input import HumanInputFormSubmitPayload
from controllers.common.human_input_v2_contracts import (
BatchGetContactsQuery,
ExternalContactCreateRequest,
FormAccessRequestResponse,
HumanInputV2ServiceFormSubmitRequest,
IMIntegration,
IMSyncResultItem,
IMSyncRun,
ListIMIdentitiesQuery,
MessageTemplateTestRequest,
NodeDataMigrationFailureResponse,
NodeDataMigrationPayload,
NodeDataMigrationResponse,
UpdateIMIntegrationRequest,
)
def test_request_dto_coerces_enum_values_and_forbids_extra_fields() -> None:
request_body = MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}})
assert request_body.channel.value == "email"
with pytest.raises(ValidationError):
MessageTemplateTestRequest.model_validate({"channel": "email", "inputs": {}, "unexpected": True})
def test_update_im_integration_cas_fields_are_both_present_or_both_absent() -> None:
credentials = {
"provider": "feishu",
"app_id": "app-id",
"app_secret": "app-secret",
}
create_request = UpdateIMIntegrationRequest.model_validate({"credentials": credentials})
update_request = UpdateIMIntegrationRequest.model_validate(
{
"credentials": credentials,
"expected_integration_id": "integration-id",
"expected_config_version": 3,
}
)
assert create_request.expected_integration_id is None
assert create_request.expected_config_version is None
assert update_request.expected_integration_id == "integration-id"
assert update_request.expected_config_version == 3
with pytest.raises(ValidationError):
UpdateIMIntegrationRequest.model_validate(
{
"credentials": credentials,
"expected_integration_id": "integration-id",
}
)
def test_im_integration_and_sync_run_expose_captured_revision() -> None:
integration = IMIntegration.model_validate(
{
"provider": "feishu",
"status": "connected",
"integration_id": "integration-id",
"config_version": 4,
}
)
sync_run = IMSyncRun.model_validate(
{
"id": "run-id",
"status": "queued",
"result_counts": {"added": 0, "not_matched": 0, "failed": 0, "removed": 0, "skipped": 0},
"provider": "feishu",
"integration_id": "integration-id",
"integration_config_version": 4,
}
)
assert integration.integration_id == "integration-id"
assert integration.config_version == 4
assert sync_run.integration_id == "integration-id"
assert sync_run.integration_config_version == 4
def test_delete_im_integration_requires_complete_cas_token() -> None:
delete_query_model = getattr(contracts, "DeleteIMIntegrationQuery", None)
assert delete_query_model is not None
query = delete_query_model.model_validate(
{
"expected_integration_id": "integration-id",
"expected_config_version": "4",
}
)
assert query.expected_integration_id == "integration-id"
assert query.expected_config_version == 4
with pytest.raises(ValidationError):
delete_query_model.model_validate({"expected_integration_id": "integration-id"})
def test_external_contact_avatar_is_optional() -> None:
request_body = ExternalContactCreateRequest.model_validate(
{
"name": "External Approver",
"email": "approver@example.com",
}
)
assert request_body.avatar is None
def test_node_data_migration_contract_matches_frontend_adapter_boundary() -> None:
request_body = NodeDataMigrationPayload.model_validate(
{
"nodes": [
{
"node_id": "node-1",
"node_data": {
"version": "1",
"future_legacy_field": "ignored",
},
"future_envelope_field": "ignored",
}
],
"future_request_field": "ignored",
}
)
assert request_body.nodes[0].node_data.version == "1"
assert not hasattr(request_body.nodes[0].node_data, "future_legacy_field")
assert set(NodeDataMigrationResponse.model_json_schema()["properties"]) == {"data"}
assert contracts.LegacyHITLv1NodeData.model_json_schema()["properties"]["version"]["const"] == "1"
failure = NodeDataMigrationFailureResponse.model_validate(
{
"message": "Migration failed",
"status": 400,
"blockers": [
{
"node_id": "node-1",
"node_title": "Approval",
"code": "unresolved-member",
"method_id": "email-1",
"value": "member-1",
}
],
}
)
assert failure.blockers[0].code == "unresolved-member"
with pytest.raises(ValidationError):
NodeDataMigrationPayload.model_validate(
{
"nodes": [
{
"node_id": "node-1",
"node_data": {"version": "2"},
}
]
}
)
with pytest.raises(ValidationError):
NodeDataMigrationPayload.model_validate(
{
"nodes": [
{"node_id": "node-1", "node_data": {"version": "1"}},
{"node_id": "node-1", "node_data": {"version": "1"}},
]
}
)
def test_v1_and_v2_submit_payloads_are_independent() -> None:
v2_submit_model = getattr(contracts, "HumanInputV2FormSubmitRequest", None)
assert v2_submit_model is not None
v1_schema = HumanInputFormSubmitPayload.model_json_schema()
v2_schema = v2_submit_model.model_json_schema()
assert "challenge_token" not in v1_schema["properties"]
assert "otp_code" not in v1_schema["properties"]
assert "challenge_token" in v2_schema["properties"]
assert "otp_code" in v2_schema["properties"]
def test_service_v2_submit_payload_does_not_accept_public_otp_fields() -> None:
payload = {
"user": "end-user",
"inputs": {},
"action": "approve",
}
request_body = HumanInputV2ServiceFormSubmitRequest.model_validate(payload)
assert request_body.user == "end-user"
with pytest.raises(ValidationError):
HumanInputV2ServiceFormSubmitRequest.model_validate({**payload, "otp_code": "123456"})
def test_public_v2_submit_requires_complete_email_proof() -> None:
submit_model = contracts.HumanInputV2FormSubmitRequest
base_payload = {"inputs": {}, "action": "approve"}
submit_model.model_validate(base_payload)
submit_model.model_validate(
{
**base_payload,
"challenge_token": "challenge-token",
"otp_code": "123456",
}
)
with pytest.raises(ValidationError):
submit_model.model_validate({**base_payload, "otp_code": "123456"})
with pytest.raises(ValidationError):
submit_model.model_validate({**base_payload, "challenge_token": "challenge-token"})
def test_access_request_response_exposes_resend_cooldown() -> None:
schema = FormAccessRequestResponse.model_json_schema()
assert "resend_after_seconds" in schema["properties"]
def test_batch_get_contacts_query_forbids_extra_fields() -> None:
with pytest.raises(ValidationError):
BatchGetContactsQuery.model_validate({"contact_ids": ["contact-1"], "unexpected": True})
def test_sync_result_item_has_no_contact_type() -> None:
schema = IMSyncResultItem.model_json_schema()
assert "type" not in schema["properties"]
def test_im_identity_search_includes_provider_user_id() -> None:
keyword_description = ListIMIdentitiesQuery.model_json_schema()["properties"]["keyword"]["description"]
assert "provider user ID" in keyword_description
@@ -0,0 +1,60 @@
from __future__ import annotations
from inspect import unwrap
import pytest
from flask import Flask
from werkzeug.exceptions import NotImplemented
from controllers.service_api.app import human_input_form as human_input_form_module
def test_v1_and_v2_service_form_routes_are_distinct() -> None:
urls = {
url
for _resource, resource_urls, _route_doc, _kwargs in human_input_form_module.service_api_ns.resources
for url in resource_urls
}
v2_form_api = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
assert "/form/human_input/<string:form_token>" in urls
assert "/form/human-input/<string:form_token>" in urls
assert v2_form_api is not None
assert human_input_form_module.WorkflowHumanInputFormApi is not v2_form_api
def test_v2_service_get_requires_query_user(app: Flask) -> None:
resource_cls = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
assert resource_cls is not None
get_method = resource_cls.get
user_doc = getattr(get_method, "__apidoc__", {})["params"]["user"]
assert user_doc["required"] is True
with app.test_request_context("/v1/form/human-input/token?user=end-user", method="GET"):
with pytest.raises(NotImplemented):
unwrap(get_method)(
resource_cls(),
app_model=object(),
end_user=object(),
form_token="token",
)
def test_v2_service_post_uses_independent_payload(app: Flask) -> None:
resource_cls = getattr(human_input_form_module, "WorkflowHumanInputV2FormApi", None)
assert resource_cls is not None
post_method = resource_cls.post
with app.test_request_context(
"/v1/form/human-input/token",
method="POST",
json={"user": "end-user", "inputs": {}, "action": "approve"},
):
with pytest.raises(NotImplemented):
unwrap(post_method)(
resource_cls(),
app_model=object(),
end_user=object(),
form_token="token",
)
@@ -0,0 +1,72 @@
from __future__ import annotations
from inspect import unwrap
import pytest
from flask import Flask
from werkzeug.exceptions import NotImplemented
from controllers.web import human_input_form as human_input_form_v1
from controllers.web import human_input_form_access_request as human_input_form_v2
def _registered_urls(module) -> set[str]:
return {
url
for _resource, resource_urls, _route_doc, _kwargs in module.web_ns.resources
for url in resource_urls
}
def test_v1_and_v2_public_form_routes_are_distinct() -> None:
urls = _registered_urls(human_input_form_v2)
v2_form_api = getattr(human_input_form_v2, "HumanInputV2FormApi", None)
assert "/form/human_input/<string:form_token>" in urls
assert "/form/human_input/<string:form_token>/upload-token" in urls
assert "/form/human-input/<string:form_token>" in urls
assert "/form/human-input/<string:form_token>/access-request" in urls
assert "/form/human-input/<string:form_token>/upload-token" in urls
assert v2_form_api is not None
assert human_input_form_v1.HumanInputFormApi is not v2_form_api
@pytest.mark.parametrize(
("resource_name", "method_name", "path", "json_body"),
[
("HumanInputV2FormApi", "get", "/api/form/human-input/token", None),
(
"HumanInputV2FormApi",
"post",
"/api/form/human-input/token",
{"inputs": {}, "action": "approve"},
),
(
"HumanInputV2FormUploadTokenApi",
"post",
"/api/form/human-input/token/upload-token",
None,
),
(
"FormAccessRequestApi",
"post",
"/api/form/human-input/token/access-request",
None,
),
],
)
def test_v2_public_form_routes_are_exposed_as_independent_stubs(
app: Flask,
resource_name: str,
method_name: str,
path: str,
json_body: dict[str, object] | None,
) -> None:
resource_cls = getattr(human_input_form_v2, resource_name, None)
assert resource_cls is not None
method = getattr(resource_cls, method_name)
with app.test_request_context(path, method=method_name.upper(), json=json_body):
with pytest.raises(NotImplemented):
unwrap(method)(resource_cls(), form_token="token")
@@ -337,14 +337,14 @@ class TestWorkflowBasedAppRunner:
graph_runtime_state.register_paused_node("node-1")
workflow_entry = SimpleNamespace(graph_engine=SimpleNamespace(graph_runtime_state=graph_runtime_state))
emails: list[dict] = []
form_deliveries: list[dict] = []
class _Dispatch:
def apply_async(self, *, kwargs, queue):
emails.append({"kwargs": kwargs, "queue": queue})
form_deliveries.append({"kwargs": kwargs, "queue": queue})
monkeypatch.setattr(
"core.app.apps.workflow_app_runner.dispatch_human_input_email_task",
"core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task",
_Dispatch(),
)
monkeypatch.setattr(
@@ -373,7 +373,7 @@ class TestWorkflowBasedAppRunner:
assert any(isinstance(event, QueueWorkflowSucceededEvent) for event, _ in published)
paused_event = next(event for event, _ in published if isinstance(event, QueueWorkflowPausedEvent))
assert paused_event.paused_nodes == ["node-1"]
assert emails
assert form_deliveries
def test_handle_graph_aborted_publishes_stopped_terminal(self):
published: list[object] = []
@@ -34,7 +34,7 @@ class _DummyWorkflowEntry:
self.graph_engine = _DummyGraphEngine()
def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch):
def test_handle_pause_event_enqueues_form_delivery_task(monkeypatch: pytest.MonkeyPatch):
queue_manager = _DummyQueueManager()
runner = WorkflowBasedAppRunner(queue_manager=queue_manager, app_id="app-id")
workflow_entry = _DummyWorkflowEntry()
@@ -42,7 +42,7 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
graph_reason = HitlRequired(session_id="form-123", node_id="node-1", node_title="Review")
event = GraphRunPausedEvent(reasons=[graph_reason], outputs={})
email_task = MagicMock()
form_delivery_task = MagicMock()
enriched_reason = HumanInputRequired(
form_id="form-123",
form_content="content",
@@ -55,12 +55,12 @@ def test_handle_pause_event_enqueues_email_task(monkeypatch: pytest.MonkeyPatch)
"core.app.apps.workflow_app_runner.enrich_graph_pause_reasons",
lambda **_: [enriched_reason],
)
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_email_task", email_task)
monkeypatch.setattr("core.app.apps.workflow_app_runner.dispatch_human_input_form_delivery_task", form_delivery_task)
runner._handle_event(workflow_entry, event)
email_task.apply_async.assert_called_once()
kwargs = email_task.apply_async.call_args.kwargs["kwargs"]
form_delivery_task.apply_async.assert_called_once()
kwargs = form_delivery_task.apply_async.call_args.kwargs["kwargs"]
assert kwargs["form_id"] == "form-123"
assert kwargs["node_title"] == "Review"
@@ -0,0 +1,318 @@
"""Behavior tests for the Human Input v2 form aggregate and frozen child facts."""
from dataclasses import FrozenInstanceError
from datetime import UTC, datetime, timedelta
import pytest
from core.human_input_v2.approval import (
ApproverGrant,
CanonicalSubjectKey,
DeliveryAttempt,
DeliveryEndpoint,
EmailAddressApprovalSubject,
EmailEndpointPlan,
EndpointAccessCapability,
FormInactiveReason,
FormRef,
FormSnapshotIdentifierFactory,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONObject,
HumanInputForm,
InvalidApproverGrantError,
InvalidSelectedActionError,
MatchedRecipientSource,
RecipientSourceKind,
ResolvedApprovalPlan,
ResolvedApprover,
SubjectSnapshot,
UploadCapability,
UploadFileAssociation,
)
from core.human_input_v2.entities import (
HumanInputDeliveryAttemptStatus,
HumanInputV2FormKind,
HumanInputV2FormStatus,
)
from core.human_input_v2.shared import (
AppId,
ApproverGrantId,
DeliveryAttemptId,
DeliveryEndpointId,
FormId,
NormalizedEmail,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
class _SequentialIdentifierFactory(FormSnapshotIdentifierFactory):
def __init__(self) -> None:
self._grant_number = 0
self._endpoint_number = 0
def new_grant_id(self) -> ApproverGrantId:
self._grant_number += 1
return ApproverGrantId(f"grant-{self._grant_number}")
def new_endpoint_id(self) -> DeliveryEndpointId:
self._endpoint_number += 1
return DeliveryEndpointId(f"endpoint-{self._endpoint_number}")
def _definition() -> FrozenFormDefinition:
return FrozenFormDefinition(
form_content="Please approve {{ request }}",
inputs=(
FrozenJSONObject.from_mapping(
{
"type": "paragraph",
"output_variable_name": "reason",
"default": {"type": "constant", "selector": [], "value": "ok"},
}
),
),
actions=(
FrozenFormAction(id="approve", title="Approve", button_style="primary"),
FrozenFormAction(id="reject", title="Reject", button_style="default"),
),
default_values=FrozenJSONObject.from_mapping({"reason": "ok", "metadata": {"labels": ["finance", "urgent"]}}),
node_title="Review",
display_in_ui=True,
)
def _approver(email: str, *, source_position: int = 0) -> ResolvedApprover:
normalized_email = NormalizedEmail(email)
return ResolvedApprover(
subject=EmailAddressApprovalSubject(normalized_email),
subject_key=CanonicalSubjectKey.for_email(normalized_email),
matched_sources=(MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, source_position, email),),
subject_snapshot=SubjectSnapshot(display_name=None, email=email),
endpoints=(EmailEndpointPlan(normalized_email),),
)
def _form(*, status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING) -> HumanInputForm:
grant = ApproverGrant.from_resolved_approver(
grant_id=ApproverGrantId("grant-1"),
form_ref=_FORM_REF,
approver=_approver("reviewer@example.com"),
now=_NOW,
)
return HumanInputForm(
ref=_FORM_REF,
app_id=AppId("app-1"),
definition=_definition(),
rendered_content="Rendered",
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
kind=HumanInputV2FormKind.RUNTIME,
status=status,
workflow_pause_id="pause-1",
node_execution_id="node-execution-1",
grants=(grant,),
created_at=_NOW,
updated_at=_NOW,
)
def test_frozen_structured_values_are_recursive_and_round_trip_without_mutable_domain_dicts() -> None:
definition = _definition()
primitive = definition.default_values.to_mapping()
assert primitive == {"metadata": {"labels": ["finance", "urgent"]}, "reason": "ok"}
primitive["reason"] = "changed"
assert definition.default_values.to_mapping()["reason"] == "ok"
with pytest.raises(FrozenInstanceError):
definition.node_title = "Changed"
with pytest.raises(TypeError, match="unsupported JSON value"):
FrozenJSONObject.from_mapping({"invalid": object()})
def test_grant_endpoint_token_upload_and_delivery_facts_remain_distinct_capabilities() -> None:
grant = ApproverGrant.from_resolved_approver(
grant_id=ApproverGrantId("grant-1"),
form_ref=_FORM_REF,
approver=_approver("reviewer@example.com"),
now=_NOW,
)
endpoint = DeliveryEndpoint.from_plan(
endpoint_id=DeliveryEndpointId("endpoint-1"),
grant_ref=grant.ref,
endpoint_plan=EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
access_capability=EndpointAccessCapability(
endpoint_ref=grant.ref.endpoint(DeliveryEndpointId("endpoint-1")),
token_hash="a" * 64,
),
now=_NOW,
)
upload_capability = UploadCapability(
id=UploadCapabilityId("upload-capability-1"),
endpoint_ref=endpoint.ref,
app_id=AppId("app-1"),
token_hash="b" * 64,
created_at=_NOW,
updated_at=_NOW,
)
upload = UploadFileAssociation(
id=UploadFileAssociationId("upload-association-1"),
capability_ref=upload_capability.ref,
upload_file_id="file-1",
created_at=_NOW,
updated_at=_NOW,
)
failed_attempt = DeliveryAttempt(
id=DeliveryAttemptId("attempt-1"),
endpoint_ref=endpoint.ref,
attempt_number=1,
status=HumanInputDeliveryAttemptStatus.FAILED,
scheduled_at=_NOW,
started_at=_NOW,
finished_at=_NOW,
provider_message_id=None,
failure_code="provider_rejected",
failure_reason="Recipient unavailable",
provider_response=FrozenJSONObject.from_mapping({"status": 400}),
created_at=_NOW,
updated_at=_NOW,
)
assert grant.subject_snapshot.email == "reviewer@example.com"
assert endpoint.access_capability is not None
assert endpoint.access_capability.endpoint_ref == endpoint.ref
assert upload.capability_ref.endpoint_ref == endpoint.ref
assert failed_attempt.endpoint_ref == endpoint.ref
assert not hasattr(endpoint.access_capability, "actor")
assert not hasattr(endpoint.access_capability, "verified_proof")
@pytest.mark.parametrize(
("status", "now", "expected_reason"),
[
(HumanInputV2FormStatus.SUBMITTED, _NOW, FormInactiveReason.SUBMITTED),
(HumanInputV2FormStatus.TIMEOUT, _NOW, FormInactiveReason.TIMED_OUT),
(HumanInputV2FormStatus.EXPIRED, _NOW, FormInactiveReason.STATUS_EXPIRED),
(
HumanInputV2FormStatus.WAITING,
UtcTimestamp(_NOW.value + timedelta(hours=3)),
FormInactiveReason.GLOBALLY_EXPIRED,
),
(
HumanInputV2FormStatus.WAITING,
UtcTimestamp(_NOW.value + timedelta(minutes=90)),
FormInactiveReason.TIMED_OUT,
),
],
)
def test_form_returns_stable_inactive_reasons(
status: HumanInputV2FormStatus,
now: UtcTimestamp,
expected_reason: FormInactiveReason,
) -> None:
state = _form(status=status).state_at(now)
assert state.reason is expected_reason
def test_waiting_form_accepts_valid_transition_decision_without_mutating_status() -> None:
form = _form()
decision = form.decide_submission(
grant_id=ApproverGrantId("grant-1"),
selected_action_id="approve",
now=_NOW,
)
assert decision.form_ref == _FORM_REF
assert decision.grant_id == ApproverGrantId("grant-1")
assert decision.selected_action_id == "approve"
assert form.status is HumanInputV2FormStatus.WAITING
def test_form_rejects_invalid_grant_and_selected_action_without_mutating_status() -> None:
form = _form()
with pytest.raises(InvalidApproverGrantError):
form.decide_submission(
grant_id=ApproverGrantId("grant-missing"),
selected_action_id="approve",
now=_NOW,
)
with pytest.raises(InvalidSelectedActionError):
form.decide_submission(
grant_id=ApproverGrantId("grant-1"),
selected_action_id="escalate",
now=_NOW,
)
assert form.status is HumanInputV2FormStatus.WAITING
def test_resolved_plan_maps_deterministically_to_one_grant_per_approver_and_separate_endpoints() -> None:
first_approver = _approver("first@example.com", source_position=0)
second_email = NormalizedEmail("second@example.com")
second_approver = ResolvedApprover(
subject=EmailAddressApprovalSubject(second_email),
subject_key=CanonicalSubjectKey.for_email(second_email),
matched_sources=(
MatchedRecipientSource(RecipientSourceKind.DYNAMIC_EMAIL, 1, "node.email"),
MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, 2, "second@example.com"),
),
subject_snapshot=SubjectSnapshot(display_name="Second", email="second@example.com"),
endpoints=(EmailEndpointPlan(second_email), EmailEndpointPlan(NormalizedEmail("backup@example.com"))),
)
plan = ResolvedApprovalPlan(
approvers=(first_approver, second_approver),
rejected_recipients=(),
failure_reason=None,
)
creation = HumanInputForm.create_from_plan(
ref=_FORM_REF,
app_id=AppId("app-1"),
definition=_definition(),
rendered_content="Rendered",
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
kind=HumanInputV2FormKind.RUNTIME,
workflow_pause_id="pause-1",
node_execution_id="node-execution-1",
plan=plan,
identifier_factory=_SequentialIdentifierFactory(),
now=_NOW,
)
assert [grant.id for grant in creation.form.grants] == [
ApproverGrantId("grant-1"),
ApproverGrantId("grant-2"),
]
assert [endpoint.id for endpoint in creation.endpoints] == [
DeliveryEndpointId("endpoint-1"),
DeliveryEndpointId("endpoint-2"),
DeliveryEndpointId("endpoint-3"),
]
assert creation.form.grants[1].matched_sources == second_approver.matched_sources
assert [endpoint.grant_ref.grant_id for endpoint in creation.endpoints] == [
ApproverGrantId("grant-1"),
ApproverGrantId("grant-2"),
ApproverGrantId("grant-2"),
]
def test_failed_delivery_fact_does_not_change_form_lifecycle() -> None:
form = _form()
before = form.state_at(_NOW)
after = form.state_at(_NOW)
assert before.is_waiting
assert after.is_waiting
assert form.status is HumanInputV2FormStatus.WAITING
@@ -0,0 +1,527 @@
"""Boundary and failure-path tests for Form Core immutable domain values."""
from datetime import UTC, datetime, timedelta
from math import nan
import pytest
from core.human_input_v2.approval import (
ApproverGrant,
CanonicalSubjectKey,
ConsoleEndpointConfiguration,
ConsoleEndpointPlan,
ContactApprovalSubject,
DeliveryAttempt,
DeliveryEndpoint,
EmailAddressApprovalSubject,
EmailEndpointConfiguration,
EmailEndpointPlan,
EndpointAccessCapability,
FormCreation,
FormInactiveReason,
FormRef,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONArray,
FrozenJSONObject,
HumanInputForm,
IMEndpointConfiguration,
IMEndpointPlan,
MatchedRecipientSource,
RecipientResolutionFailureReason,
RecipientSourceKind,
ResolvedApprovalPlan,
ResolvedApprover,
SubjectSnapshot,
UploadCapability,
UploadFileAssociation,
WebEndpointConfiguration,
WebEndpointPlan,
)
from core.human_input_v2.entities import (
HumanInputDeliveryAttemptStatus,
HumanInputV2FormKind,
HumanInputV2FormStatus,
IMProvider,
)
from core.human_input_v2.shared import (
AppId,
ApproverGrantId,
ContactId,
DeliveryAttemptId,
DeliveryEndpointId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UploadCapabilityId,
UploadFileAssociationId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
def _email_approver(email: str = "reviewer@example.com") -> ResolvedApprover:
normalized_email = NormalizedEmail(email)
return ResolvedApprover(
subject=EmailAddressApprovalSubject(normalized_email),
subject_key=CanonicalSubjectKey.for_email(normalized_email),
matched_sources=(MatchedRecipientSource(RecipientSourceKind.ONE_TIME_EMAIL, 0, email),),
subject_snapshot=SubjectSnapshot("Reviewer", email),
endpoints=(EmailEndpointPlan(normalized_email),),
)
def _grant(
*,
grant_id: str = "grant-1",
form_ref: FormRef = _FORM_REF,
approver: ResolvedApprover | None = None,
) -> ApproverGrant:
return ApproverGrant.from_resolved_approver(
grant_id=ApproverGrantId(grant_id),
form_ref=form_ref,
approver=approver or _email_approver(),
now=_NOW,
)
def _definition() -> FrozenFormDefinition:
return FrozenFormDefinition(
form_content="Approve",
inputs=(),
actions=(FrozenFormAction("approve", "Approve", "primary"),),
default_values=FrozenJSONObject.from_mapping({}),
node_title=None,
display_in_ui=None,
)
def _form(
*,
kind: HumanInputV2FormKind = HumanInputV2FormKind.RUNTIME,
workflow_pause_id: str | None = "pause-1",
node_execution_id: str | None = "execution-1",
grants: tuple[ApproverGrant, ...] | list[ApproverGrant] | None = None,
status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING,
) -> HumanInputForm:
return HumanInputForm(
ref=_FORM_REF,
app_id=AppId("app-1"),
definition=_definition(),
rendered_content="Rendered",
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
kind=kind,
status=status,
workflow_pause_id=workflow_pause_id,
node_execution_id=node_execution_id,
grants=(_grant(),) if grants is None else grants,
created_at=_NOW,
updated_at=_NOW,
)
@pytest.mark.parametrize(
("action_id", "title", "button_style"),
[("", "Approve", "primary"), ("approve", "", "primary"), ("approve", "Approve", "")],
)
def test_frozen_form_action_rejects_each_blank_component(action_id: str, title: str, button_style: str) -> None:
with pytest.raises(ValueError, match="must not be blank"):
FrozenFormAction(action_id, title, button_style)
def test_frozen_definition_rejects_mutable_collections_and_duplicate_actions() -> None:
action = FrozenFormAction("approve", "Approve", "primary")
with pytest.raises(TypeError, match="immutable tuples"):
FrozenFormDefinition("Approve", [], (action,), FrozenJSONObject.from_mapping({}), None, None)
with pytest.raises(TypeError, match="immutable tuples"):
FrozenFormDefinition("Approve", (), [action], FrozenJSONObject.from_mapping({}), None, None)
with pytest.raises(ValueError, match="unique"):
FrozenFormDefinition("Approve", (), (action, action), FrozenJSONObject.from_mapping({}), None, None)
def test_recursive_frozen_json_accepts_all_json_shapes_and_rejects_invalid_values() -> None:
frozen = FrozenJSONObject.from_mapping(
{
"none": None,
"text": "value",
"boolean": True,
"integer": 1,
"float": 1.5,
"array": [False, {"nested": "value"}],
}
)
assert frozen.to_mapping()["array"] == [False, {"nested": "value"}]
with pytest.raises(ValueError, match="finite"):
FrozenJSONObject.from_mapping({"invalid": nan})
with pytest.raises(TypeError, match="unsupported"):
FrozenJSONObject.from_mapping({"invalid": object()})
with pytest.raises(TypeError, match="immutable tuple"):
FrozenJSONArray([])
with pytest.raises(TypeError, match="immutable tuple"):
FrozenJSONObject([])
with pytest.raises(ValueError, match="duplicate"):
FrozenJSONObject((("key", 1), ("key", 2)))
def test_direct_frozen_json_construction_rejects_mutable_and_non_finite_nested_values() -> None:
nested_array = FrozenJSONArray((1, FrozenJSONObject((("nested", True),))))
nested_object = FrozenJSONObject((("array", nested_array),))
assert nested_object.to_mapping() == {"array": [1, {"nested": True}]}
with pytest.raises(TypeError, match="frozen JSON"):
FrozenJSONArray(([1],))
with pytest.raises(TypeError, match="frozen JSON"):
FrozenJSONObject((("nested", {"value": 1}),))
with pytest.raises(ValueError, match="finite"):
FrozenJSONArray((nan,))
def test_grant_rejects_mutable_sources_and_mismatched_subject_key() -> None:
approver = _email_approver()
with pytest.raises(TypeError, match="immutable tuple"):
ApproverGrant(
ref=_FORM_REF.grant(ApproverGrantId("grant-1")),
subject=approver.subject,
subject_key=approver.subject_key,
matched_sources=list(approver.matched_sources),
subject_snapshot=approver.subject_snapshot,
created_at=_NOW,
updated_at=_NOW,
)
with pytest.raises(ValueError, match="subject key"):
ApproverGrant(
ref=_FORM_REF.grant(ApproverGrantId("grant-1")),
subject=ContactApprovalSubject(ContactId("contact-1")),
subject_key=CanonicalSubjectKey.for_contact(ContactId("contact-2")),
matched_sources=approver.matched_sources,
subject_snapshot=approver.subject_snapshot,
created_at=_NOW,
updated_at=_NOW,
)
def test_endpoint_factory_maps_each_channel_to_a_typed_configuration() -> None:
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
plans = (
EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
IMEndpointPlan(
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_tenant_id="provider-tenant-1",
identity_id=IMIdentityId("identity-1"),
binding_id=IMBindingId("binding-1"),
provider_user_id="provider-user-1",
),
WebEndpointPlan(),
ConsoleEndpointPlan(),
)
endpoints = tuple(
DeliveryEndpoint.from_plan(
endpoint_id=DeliveryEndpointId(f"endpoint-{position}"),
grant_ref=grant_ref,
endpoint_plan=plan,
access_capability=None,
now=_NOW,
)
for position, plan in enumerate(plans)
)
assert isinstance(endpoints[0].configuration, EmailEndpointConfiguration)
assert isinstance(endpoints[1].configuration, IMEndpointConfiguration)
assert isinstance(endpoints[2].configuration, WebEndpointConfiguration)
assert isinstance(endpoints[3].configuration, ConsoleEndpointConfiguration)
assert len({endpoint.address_hash for endpoint in endpoints}) == 4
@pytest.mark.parametrize(
("provider_tenant_id", "provider_user_id"),
[("", "user-1"), ("tenant-1", "")],
)
def test_im_endpoint_configuration_rejects_blank_provider_identity(
provider_tenant_id: str,
provider_user_id: str,
) -> None:
with pytest.raises(ValueError, match="must not be blank"):
IMEndpointConfiguration(
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_tenant_id=provider_tenant_id,
identity_id=IMIdentityId("identity-1"),
binding_id=None,
provider_user_id=provider_user_id,
)
def test_hashes_and_endpoint_capability_owner_are_strict() -> None:
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
endpoint_ref = grant_ref.endpoint(DeliveryEndpointId("endpoint-1"))
with pytest.raises(ValueError, match="SHA-256"):
EndpointAccessCapability(endpoint_ref, "short")
with pytest.raises(ValueError, match="SHA-256"):
EndpointAccessCapability(endpoint_ref, "z" * 64)
with pytest.raises(ValueError, match="owner"):
DeliveryEndpoint(
ref=endpoint_ref,
configuration=WebEndpointConfiguration(),
address_hash="a" * 64,
access_capability=EndpointAccessCapability(grant_ref.endpoint(DeliveryEndpointId("endpoint-2")), "b" * 64),
created_at=_NOW,
updated_at=_NOW,
)
with pytest.raises(ValueError, match="SHA-256"):
UploadCapability(UploadCapabilityId("upload-1"), endpoint_ref, AppId("app-1"), "z" * 64, _NOW, _NOW)
def test_delivery_attempt_validates_sequence_terminal_time_and_failure_diagnostics() -> None:
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
with pytest.raises(ValueError, match="positive"):
DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
0,
HumanInputDeliveryAttemptStatus.QUEUED,
_NOW,
None,
None,
None,
None,
None,
None,
_NOW,
_NOW,
)
with pytest.raises(ValueError, match="finished_at"):
DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.FAILED,
_NOW,
_NOW,
None,
None,
"failed",
None,
None,
_NOW,
_NOW,
)
with pytest.raises(ValueError, match="only failed"):
DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.SENT,
_NOW,
_NOW,
_NOW,
"message-1",
"invalid",
None,
None,
_NOW,
_NOW,
)
with pytest.raises(ValueError, match="only failed"):
DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.SENT,
_NOW,
_NOW,
_NOW,
"message-1",
None,
"invalid",
None,
_NOW,
_NOW,
)
queued = DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.QUEUED,
_NOW,
None,
None,
None,
None,
None,
None,
_NOW,
_NOW,
)
assert queued.status is HumanInputDeliveryAttemptStatus.QUEUED
@pytest.mark.parametrize(
("failure_code", "failure_reason", "provider_response"),
[
(None, None, None),
("", " ", None),
],
)
def test_failed_delivery_attempt_requires_at_least_one_diagnostic(
failure_code: str | None,
failure_reason: str | None,
provider_response: FrozenJSONObject | None,
) -> None:
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
with pytest.raises(ValueError, match="failure diagnostic"):
DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.FAILED,
_NOW,
_NOW,
_NOW,
None,
failure_code,
failure_reason,
provider_response,
_NOW,
_NOW,
)
@pytest.mark.parametrize(
("failure_code", "failure_reason", "provider_response"),
[
("provider_rejected", None, None),
(None, "Recipient unavailable", None),
(None, None, FrozenJSONObject.from_mapping({"status": 400})),
],
)
def test_failed_delivery_attempt_accepts_each_supported_diagnostic(
failure_code: str | None,
failure_reason: str | None,
provider_response: FrozenJSONObject | None,
) -> None:
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
attempt = DeliveryAttempt(
DeliveryAttemptId("attempt-1"),
endpoint_ref,
1,
HumanInputDeliveryAttemptStatus.FAILED,
_NOW,
_NOW,
_NOW,
None,
failure_code,
failure_reason,
provider_response,
_NOW,
_NOW,
)
assert attempt.status is HumanInputDeliveryAttemptStatus.FAILED
def test_upload_file_requires_a_non_blank_file_identifier() -> None:
endpoint_ref = _FORM_REF.grant(ApproverGrantId("grant-1")).endpoint(DeliveryEndpointId("endpoint-1"))
capability = UploadCapability(UploadCapabilityId("upload-1"), endpoint_ref, AppId("app-1"), "b" * 64, _NOW, _NOW)
with pytest.raises(ValueError, match="must not be blank"):
UploadFileAssociation(UploadFileAssociationId("association-1"), capability.ref, " ", _NOW, _NOW)
def test_form_and_creation_reject_invalid_owner_and_collection_shapes() -> None:
with pytest.raises(TypeError, match="grants"):
_form(grants=[_grant()])
with pytest.raises(ValueError, match="runtime form"):
_form(workflow_pause_id=None)
with pytest.raises(ValueError, match="runtime form"):
_form(node_execution_id=None)
delivery_test = _form(
kind=HumanInputV2FormKind.DELIVERY_TEST,
workflow_pause_id=None,
node_execution_id=None,
)
assert delivery_test.kind is HumanInputV2FormKind.DELIVERY_TEST
other_ref = FormRef(WorkspaceId("workspace-2"), FormId("form-2"))
with pytest.raises(ValueError, match="another owner"):
_form(grants=(_grant(form_ref=other_ref),))
with pytest.raises(ValueError, match="unique"):
_form(grants=(_grant(), _grant()))
same_subject = _email_approver()
with pytest.raises(ValueError, match="unique"):
_form(
grants=(
_grant(grant_id="grant-1", approver=same_subject),
_grant(grant_id="grant-2", approver=same_subject),
)
)
valid_form = _form()
with pytest.raises(TypeError, match="immutable tuple"):
FormCreation(valid_form, [])
outside_endpoint = DeliveryEndpoint.from_plan(
endpoint_id=DeliveryEndpointId("endpoint-outside"),
grant_ref=other_ref.grant(ApproverGrantId("grant-outside")),
endpoint_plan=WebEndpointPlan(),
access_capability=None,
now=_NOW,
)
with pytest.raises(ValueError, match="outside"):
FormCreation(valid_form, (outside_endpoint,))
def test_inactive_form_returns_state_instead_of_a_transition_decision() -> None:
form = _form(status=HumanInputV2FormStatus.SUBMITTED)
result = form.decide_submission(
grant_id=ApproverGrantId("grant-1"),
selected_action_id="approve",
now=_NOW,
)
assert result.reason is FormInactiveReason.SUBMITTED
def test_failed_recipient_plan_cannot_create_a_form_snapshot() -> None:
plan = ResolvedApprovalPlan(
approvers=(),
rejected_recipients=(),
failure_reason=RecipientResolutionFailureReason.NO_VALID_RECIPIENTS,
)
class _UnusedFactory:
def new_grant_id(self) -> ApproverGrantId:
raise AssertionError("must not generate identifiers")
def new_endpoint_id(self) -> DeliveryEndpointId:
raise AssertionError("must not generate identifiers")
with pytest.raises(ValueError, match="approvers"):
HumanInputForm.create_from_plan(
ref=_FORM_REF,
app_id=AppId("app-1"),
definition=_definition(),
rendered_content="Rendered",
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
kind=HumanInputV2FormKind.RUNTIME,
workflow_pause_id="pause-1",
node_execution_id="execution-1",
plan=plan,
identifier_factory=_UnusedFactory(),
now=_NOW,
)
@@ -0,0 +1,519 @@
"""Behavior contracts for the independent Email OTP proof-session aggregate."""
from __future__ import annotations
from dataclasses import replace
from datetime import UTC, datetime, timedelta
import pytest
from core.human_input_v2.approval import (
ContactOTPSubject,
CurrentEmailOTPIdentity,
EmailAddressOTPSubject,
FormRef,
OTPChallenge,
OTPChallengeRejectionReason,
OTPCodeHash,
VerifiedEmailOTPProof,
authorize_email_otp_proof,
)
from core.human_input_v2.entities import HumanInputOTPChallengeStatus
from core.human_input_v2.shared import (
ApproverGrantId,
ContactId,
FormId,
NormalizedEmail,
OTPChallengeId,
UtcTimestamp,
WorkspaceId,
)
_ISSUED_AT = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_EXPIRES_AT = UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))
_RESEND_AFTER = UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60))
_RAW_CODE = "123456"
_ENCODED_HASH = "test-sha256$8d969eef6ecad3c29a3a629280e686cff8ca4a8d"
_TOKEN_HASH = "a" * 64
_EMAIL = NormalizedEmail("Reviewer@Example.com")
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
_GRANT_REF = _FORM_REF.grant(ApproverGrantId("grant-1"))
_SUBJECT = ContactOTPSubject(ContactId("contact-1"))
class _MutableClock:
current: UtcTimestamp
def __init__(self, current: UtcTimestamp = _ISSUED_AT) -> None:
self.current = current
def now(self) -> UtcTimestamp:
return self.current
def advance(self, delta: timedelta) -> None:
self.current = UtcTimestamp(self.current.value + delta)
class _DeterministicHasher:
hash_calls: list[str]
verify_calls: list[tuple[str, OTPCodeHash]]
def __init__(self) -> None:
self.hash_calls = []
self.verify_calls = []
def hash_code(self, plaintext_code: str) -> OTPCodeHash:
self.hash_calls.append(plaintext_code)
return OTPCodeHash(encoded_value=_ENCODED_HASH, algorithm="test-sha256")
def verify_code(self, plaintext_code: str, code_hash: OTPCodeHash) -> bool:
self.verify_calls.append((plaintext_code, code_hash))
return plaintext_code == _RAW_CODE and code_hash.encoded_value == _ENCODED_HASH
def _issue(
*,
clock: _MutableClock | None = None,
hasher: _DeterministicHasher | None = None,
challenge_number: int = 1,
send_count: int = 1,
) -> OTPChallenge:
active_clock = clock or _MutableClock()
active_hasher = hasher or _DeterministicHasher()
return OTPChallenge.issue(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId(f"challenge-{challenge_number}")),
subject=_SUBJECT,
normalized_email=_EMAIL,
challenge_token_hash=_TOKEN_HASH,
plaintext_code=_RAW_CODE,
send_count=send_count,
clock=active_clock,
code_hasher=active_hasher,
)
def _construct_challenge(
*,
expires_at: UtcTimestamp = _EXPIRES_AT,
resend_after: UtcTimestamp = _RESEND_AFTER,
) -> OTPChallenge:
return OTPChallenge(
ref=_GRANT_REF.challenge(OTPChallengeId("challenge-direct")),
subject=_SUBJECT,
normalized_email=_EMAIL,
challenge_token_hash=_TOKEN_HASH,
code_hash=OTPCodeHash(encoded_value=_ENCODED_HASH, algorithm="test-sha256"),
status=HumanInputOTPChallengeStatus.PENDING,
expires_at=expires_at,
resend_after=resend_after,
send_count=1,
attempt_count=0,
verified_at=None,
invalidated_at=None,
created_at=_ISSUED_AT,
updated_at=_ISSUED_AT,
)
def test_issue_uses_injected_clock_and_hash_port_without_retaining_plaintext() -> None:
clock = _MutableClock()
hasher = _DeterministicHasher()
challenge = _issue(clock=clock, hasher=hasher)
assert hasher.hash_calls == [_RAW_CODE]
assert challenge.created_at == clock.now()
assert challenge.expires_at == UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))
assert challenge.resend_after == UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60))
assert _RAW_CODE not in repr(challenge)
assert _RAW_CODE not in challenge.to_public_primitive().values()
assert "code_hash" not in challenge.to_public_primitive()
assert "challenge_token_hash" not in challenge.to_public_primitive()
def test_challenge_expires_at_the_exact_ten_minute_boundary() -> None:
challenge = _issue()
before = challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=-1)))
exact = challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
assert before.rejection is None
assert exact.rejection is OTPChallengeRejectionReason.EXPIRED
def test_replacement_rejects_before_cooldown_without_incrementing_send_count() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=59, microseconds=999999)))
challenge = _issue()
decision = challenge.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
challenge_token_hash="b" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=_DeterministicHasher(),
)
assert decision.rejection is OTPChallengeRejectionReason.RESEND_COOLDOWN
assert decision.previous is challenge
assert decision.replacement is None
assert decision.previous.send_count == 1
def test_replacement_is_allowed_at_exact_cooldown_and_invalidates_previous() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
challenge = _issue()
decision = challenge.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
challenge_token_hash="b" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=_DeterministicHasher(),
)
assert decision.rejection is None
assert decision.previous.status is HumanInputOTPChallengeStatus.INVALIDATED
assert decision.previous.invalidated_at == clock.now()
assert decision.replacement is not None
assert decision.replacement.status is HumanInputOTPChallengeStatus.PENDING
assert decision.replacement.send_count == 2
assert decision.replacement.attempt_count == 0
@pytest.mark.parametrize(
"elapsed",
[timedelta(minutes=10), timedelta(minutes=10, microseconds=1)],
ids=["exact-boundary", "past-boundary"],
)
def test_expired_replacement_returns_expired_previous_without_hashing(elapsed: timedelta) -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + elapsed))
hasher = _DeterministicHasher()
challenge = _issue()
decision = challenge.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
challenge_token_hash="b" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=hasher,
)
assert decision.rejection is OTPChallengeRejectionReason.EXPIRED
assert decision.previous.status is HumanInputOTPChallengeStatus.EXPIRED
assert decision.previous.updated_at == clock.now()
assert decision.previous.send_count == challenge.send_count
assert decision.previous.attempt_count == challenge.attempt_count
assert decision.replacement is None
assert hasher.hash_calls == []
def test_fifth_send_is_allowed_and_further_send_is_rejected_without_hashing() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
fourth = _issue(send_count=4)
fifth_hasher = _DeterministicHasher()
fifth = fourth.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-5")),
challenge_token_hash="e" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=fifth_hasher,
)
assert fifth.replacement is not None
assert fifth.replacement.send_count == 5
rejected_hasher = _DeterministicHasher()
clock.advance(timedelta(seconds=60))
rejected = fifth.replacement.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-6")),
challenge_token_hash="f" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=rejected_hasher,
)
assert rejected.rejection is OTPChallengeRejectionReason.SEND_LIMIT_REACHED
assert rejected.previous.send_count == 5
assert rejected_hasher.hash_calls == []
@pytest.mark.parametrize(
("terminal_status", "expected_reason"),
[
(HumanInputOTPChallengeStatus.VERIFIED, OTPChallengeRejectionReason.ALREADY_VERIFIED),
(HumanInputOTPChallengeStatus.INVALIDATED, OTPChallengeRejectionReason.INVALIDATED),
(HumanInputOTPChallengeStatus.EXPIRED, OTPChallengeRejectionReason.EXPIRED),
],
)
def test_terminal_challenge_rejects_replacement_without_incrementing_counters(
terminal_status: HumanInputOTPChallengeStatus,
expected_reason: OTPChallengeRejectionReason,
) -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60)))
pending = _issue()
if terminal_status is HumanInputOTPChallengeStatus.VERIFIED:
challenge = pending.verify(
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=_DeterministicHasher(),
).challenge
elif terminal_status is HumanInputOTPChallengeStatus.INVALIDATED:
challenge = pending.invalidate(clock=clock)
else:
expiry_clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
challenge = pending.verify(
plaintext_code=_RAW_CODE,
clock=expiry_clock,
code_hasher=_DeterministicHasher(),
).challenge
previous_attempt_count = challenge.attempt_count
hasher = _DeterministicHasher()
decision = challenge.replace(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-2")),
challenge_token_hash="b" * 64,
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=hasher,
)
assert decision.rejection is expected_reason
assert decision.previous.send_count == 1
assert decision.previous.attempt_count == previous_attempt_count
assert hasher.hash_calls == []
def test_five_invalid_attempts_are_consumed_and_sixth_is_rejected_without_hashing() -> None:
clock = _MutableClock()
hasher = _DeterministicHasher()
challenge = _issue(clock=clock, hasher=hasher)
for expected_count in range(1, 6):
decision = challenge.verify(plaintext_code="000000", clock=clock, code_hasher=hasher)
assert decision.rejection is OTPChallengeRejectionReason.INVALID_CODE
assert decision.challenge.attempt_count == expected_count
challenge = decision.challenge
verify_call_count = len(hasher.verify_calls)
rejected = challenge.verify(plaintext_code="000000", clock=clock, code_hasher=hasher)
assert rejected.rejection is OTPChallengeRejectionReason.ATTEMPT_LIMIT_REACHED
assert rejected.challenge.attempt_count == 5
assert len(hasher.verify_calls) == verify_call_count
def test_successful_verification_consumes_one_attempt_and_returns_immutable_limited_proof() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
hasher = _DeterministicHasher()
challenge = _issue()
decision = challenge.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
assert decision.rejection is None
assert decision.challenge.status is HumanInputOTPChallengeStatus.VERIFIED
assert decision.challenge.attempt_count == 1
assert decision.proof is not None
assert decision.proof == VerifiedEmailOTPProof(
challenge_ref=challenge.ref,
subject=_SUBJECT,
normalized_email=_EMAIL,
verified_at=clock.now(),
)
assert _RAW_CODE not in repr(decision.proof)
serialized = decision.proof.to_primitive()
assert serialized == {
"type": "email_otp",
"otp_challenge_id": "challenge-1",
"form_id": "form-1",
"approver_grant_id": "grant-1",
"subject_type": "contact",
"contact_id": "contact-1",
"verified_email": "reviewer@example.com",
"verified_at": "2026-07-25T08:00:30Z",
}
assert not any("hash" in key or "code" in key for key in serialized)
attribute_name = "normalized_email"
with pytest.raises((AttributeError, TypeError)):
setattr(decision.proof, attribute_name, NormalizedEmail("other@example.com"))
def test_verified_and_invalidated_challenges_reject_further_verification_without_counter_changes() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
hasher = _DeterministicHasher()
verified = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher).challenge
invalidated = _issue().invalidate(clock=clock)
verified_result = verified.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
invalidated_result = invalidated.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
assert verified_result.rejection is OTPChallengeRejectionReason.ALREADY_VERIFIED
assert invalidated_result.rejection is OTPChallengeRejectionReason.INVALIDATED
assert verified_result.challenge.attempt_count == 1
assert invalidated_result.challenge.attempt_count == 0
def test_expired_verification_does_not_consume_an_attempt_or_call_hash_port() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10)))
hasher = _DeterministicHasher()
challenge = _issue()
decision = challenge.verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=hasher)
assert decision.rejection is OTPChallengeRejectionReason.EXPIRED
assert decision.challenge.status is HumanInputOTPChallengeStatus.EXPIRED
assert decision.challenge.attempt_count == 0
assert hasher.verify_calls == []
def test_submission_boundary_rejects_raw_code_and_accepts_only_current_verified_identity() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
proof = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=_DeterministicHasher()).proof
assert proof is not None
current_identity = CurrentEmailOTPIdentity(_GRANT_REF, _SUBJECT, _EMAIL)
raw_result = authorize_email_otp_proof(_RAW_CODE, current_identity=current_identity)
accepted = authorize_email_otp_proof(proof, current_identity=current_identity)
assert raw_result.rejection is OTPChallengeRejectionReason.RAW_CODE_NOT_VERIFIED
assert accepted.rejection is None
assert accepted.proof is proof
@pytest.mark.parametrize(
("current_identity", "expected_reason"),
[
(
CurrentEmailOTPIdentity(
_GRANT_REF,
_SUBJECT,
NormalizedEmail("changed@example.com"),
),
OTPChallengeRejectionReason.STALE_IDENTITY,
),
(CurrentEmailOTPIdentity(_GRANT_REF, None, None), OTPChallengeRejectionReason.STALE_IDENTITY),
(
CurrentEmailOTPIdentity(
_GRANT_REF,
ContactOTPSubject(ContactId("recreated-contact")),
_EMAIL,
),
OTPChallengeRejectionReason.STALE_IDENTITY,
),
(
CurrentEmailOTPIdentity(
_FORM_REF.grant(ApproverGrantId("other-grant")),
_SUBJECT,
_EMAIL,
),
OTPChallengeRejectionReason.GRANT_MISMATCH,
),
],
)
def test_submission_boundary_rejects_changed_deleted_recreated_or_wrong_grant_identity(
current_identity: CurrentEmailOTPIdentity,
expected_reason: OTPChallengeRejectionReason,
) -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
proof = _issue().verify(plaintext_code=_RAW_CODE, clock=clock, code_hasher=_DeterministicHasher()).proof
assert proof is not None
decision = authorize_email_otp_proof(proof, current_identity=current_identity)
assert decision.rejection is expected_reason
def test_email_address_subject_requires_the_same_normalized_email() -> None:
with pytest.raises(ValueError, match="subject email"):
OTPChallenge.issue(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-email")),
subject=EmailAddressOTPSubject(NormalizedEmail("recipient@example.com")),
normalized_email=NormalizedEmail("other@example.com"),
challenge_token_hash="c" * 64,
plaintext_code=_RAW_CODE,
send_count=1,
clock=_MutableClock(),
code_hasher=_DeterministicHasher(),
)
def test_email_address_proof_requires_matching_email_and_serializes_without_contact_identity() -> None:
email = NormalizedEmail("recipient@example.com")
proof = VerifiedEmailOTPProof(
challenge_ref=_GRANT_REF.challenge(OTPChallengeId("challenge-email")),
subject=EmailAddressOTPSubject(email),
normalized_email=email,
verified_at=_ISSUED_AT,
)
assert proof.to_primitive()["subject_type"] == "email_address"
assert proof.to_primitive()["contact_id"] is None
with pytest.raises(ValueError, match="subject email"):
VerifiedEmailOTPProof(
challenge_ref=proof.challenge_ref,
subject=proof.subject,
normalized_email=NormalizedEmail("other@example.com"),
verified_at=proof.verified_at,
)
def test_state_result_exposes_usable_and_rejected_branches() -> None:
challenge = _issue()
assert challenge.state_at(_ISSUED_AT).is_usable is True
assert challenge.state_at(UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10))).is_usable is False
def test_challenge_rejects_malformed_persistence_state() -> None:
challenge = _issue()
with pytest.raises(ValueError, match="challenge token hash"):
replace(challenge, challenge_token_hash="A" * 64)
with pytest.raises(ValueError, match="expires_at"):
replace(challenge, expires_at=_ISSUED_AT)
with pytest.raises(ValueError, match="updated_at"):
replace(challenge, updated_at=UtcTimestamp(_ISSUED_AT.value - timedelta(seconds=1)))
with pytest.raises(ValueError, match="requires only verified_at"):
replace(challenge, status=HumanInputOTPChallengeStatus.VERIFIED)
with pytest.raises(ValueError, match="requires only invalidated_at"):
replace(challenge, status=HumanInputOTPChallengeStatus.INVALIDATED)
with pytest.raises(ValueError, match="cannot have terminal timestamps"):
replace(challenge, verified_at=_ISSUED_AT)
@pytest.mark.parametrize(
"resend_after",
[
UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=59, microseconds=999999)),
UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=60, microseconds=1)),
],
ids=["short", "long"],
)
def test_direct_construction_requires_exact_resend_cooldown(resend_after: UtcTimestamp) -> None:
with pytest.raises(ValueError, match="resend_after"):
_construct_challenge(resend_after=resend_after)
@pytest.mark.parametrize(
"expires_at",
[
UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=-1)),
UtcTimestamp(_ISSUED_AT.value + timedelta(minutes=10, microseconds=1)),
],
ids=["short", "long"],
)
def test_direct_construction_requires_exact_expiry(expires_at: UtcTimestamp) -> None:
with pytest.raises(ValueError, match="expires_at"):
_construct_challenge(expires_at=expires_at)
def test_invalidating_a_terminal_challenge_is_idempotent() -> None:
clock = _MutableClock(UtcTimestamp(_ISSUED_AT.value + timedelta(seconds=30)))
verified = (
_issue()
.verify(
plaintext_code=_RAW_CODE,
clock=clock,
code_hasher=_DeterministicHasher(),
)
.challenge
)
assert verified.invalidate(clock=clock) is verified
@@ -0,0 +1,195 @@
"""Immutable approval plan value tests."""
import json
from dataclasses import FrozenInstanceError
import pytest
from core.human_input_v2.approval import (
CanonicalSubjectKey,
ConsoleEndpointPlan,
ContactApprovalSubject,
DeliveryCapabilitySnapshot,
EmailAddressApprovalSubject,
EmailEndpointPlan,
EndUserApprovalSubject,
IMEndpointPlan,
MatchedRecipientSource,
RecipientRejectionReason,
RecipientResolutionFailureReason,
RecipientSourceKind,
RejectedRecipient,
ResolvedApprovalPlan,
ResolvedApprover,
SubjectSnapshot,
WebEndpointPlan,
)
from core.human_input_v2.entities import HumanInputDeliveryChannel, IMProvider
from core.human_input_v2.shared import (
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
)
def test_canonical_subject_keys_are_typed_and_email_keys_do_not_expose_the_address() -> None:
email = NormalizedEmail("USER@example.com")
assert CanonicalSubjectKey.for_contact(ContactId("contact-1")).value == "contact:contact-1"
assert CanonicalSubjectKey.for_end_user(EndUserId("end-user-1")).value == "end_user:end-user-1"
email_key = CanonicalSubjectKey.for_email(email)
assert email_key.value.startswith("email_address:")
assert email.value not in email_key.value
assert email_key == CanonicalSubjectKey.for_email(NormalizedEmail("user@example.com"))
@pytest.mark.parametrize(
"invalid_key",
["", "contact:", "end_user:", "email_address:not-a-sha256"],
)
def test_canonical_subject_key_rejects_values_outside_the_portable_format(invalid_key: str) -> None:
with pytest.raises(ValueError, match="canonical subject key"):
CanonicalSubjectKey(invalid_key)
def test_subject_values_are_discriminated_without_conflating_delivery_channels() -> None:
assert ContactApprovalSubject(ContactId("contact-1")).to_primitive() == {
"type": "contact",
"contact_id": "contact-1",
}
assert EndUserApprovalSubject(EndUserId("end-user-1")).to_primitive() == {
"type": "end_user",
"end_user_id": "end-user-1",
}
assert EmailAddressApprovalSubject(NormalizedEmail("user@example.com")).to_primitive() == {
"type": "email_address",
"normalized_email": "user@example.com",
}
def test_approval_plan_is_deeply_immutable_and_has_an_explicit_serializable_boundary() -> None:
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
endpoint = EmailEndpointPlan(NormalizedEmail("user@example.com"))
approver = ResolvedApprover(
subject=ContactApprovalSubject(ContactId("contact-1")),
subject_key=CanonicalSubjectKey.for_contact(ContactId("contact-1")),
matched_sources=(source,),
subject_snapshot=SubjectSnapshot(display_name="Reviewer", email="user@example.com"),
endpoints=(endpoint,),
)
plan = ResolvedApprovalPlan(approvers=(approver,), rejected_recipients=(), failure_reason=None)
primitive = plan.to_primitive()
assert json.loads(json.dumps(primitive)) == primitive
assert primitive["approvers"][0]["endpoints"] == [
{"channel": HumanInputDeliveryChannel.EMAIL.value, "email_address": "user@example.com"}
]
with pytest.raises(FrozenInstanceError):
plan.approvers = ()
with pytest.raises(TypeError):
json.dumps(plan)
def test_im_endpoint_and_rejected_fact_keep_transport_neutral_structured_facts() -> None:
endpoint = IMEndpointPlan(
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_tenant_id="provider-tenant-1",
identity_id=IMIdentityId("identity-1"),
binding_id=IMBindingId("binding-1"),
provider_user_id="provider-user-1",
)
rejection = RejectedRecipient(
source=MatchedRecipientSource(RecipientSourceKind.DYNAMIC_EMAIL, 2, "node-1.email"),
reason=RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE,
rejected_value="list",
)
plan = ResolvedApprovalPlan(
approvers=(),
rejected_recipients=(rejection,),
failure_reason=RecipientResolutionFailureReason.NO_VALID_RECIPIENTS,
)
assert endpoint.to_primitive() == {
"channel": "im",
"integration_id": "integration-1",
"provider": "feishu",
"provider_tenant_id": "provider-tenant-1",
"identity_id": "identity-1",
"binding_id": "binding-1",
"provider_user_id": "provider-user-1",
}
assert plan.to_primitive()["rejected_recipients"] == [
{
"source": {"kind": "dynamic_email", "position": 2, "reference": "node-1.email"},
"reason": "unsupported_dynamic_type",
"rejected_value": "list",
}
]
assert plan.to_primitive()["failure_reason"] == "no_valid_recipients"
def test_value_invariants_reject_mutable_collections_and_inconsistent_subject_keys() -> None:
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
endpoint = EmailEndpointPlan(NormalizedEmail("user@example.com"))
snapshot = SubjectSnapshot("Reviewer", "user@example.com")
with pytest.raises(ValueError, match="position"):
MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, -1, "contact-1")
with pytest.raises(TypeError, match="immutable tuples"):
ResolvedApprover(
ContactApprovalSubject(ContactId("contact-1")),
CanonicalSubjectKey.for_contact(ContactId("contact-1")),
[source],
snapshot,
(endpoint,),
)
with pytest.raises(ValueError, match="does not match"):
ResolvedApprover(
ContactApprovalSubject(ContactId("contact-1")),
CanonicalSubjectKey.for_contact(ContactId("contact-2")),
(source,),
snapshot,
(endpoint,),
)
def test_plan_and_capability_invariants_require_deeply_immutable_consistent_values() -> None:
source = MatchedRecipientSource(RecipientSourceKind.STATIC_CONTACT, 0, "contact-1")
approver = ResolvedApprover(
ContactApprovalSubject(ContactId("contact-1")),
CanonicalSubjectKey.for_contact(ContactId("contact-1")),
(source,),
SubjectSnapshot("Reviewer", "user@example.com"),
(EmailEndpointPlan(NormalizedEmail("user@example.com")),),
)
with pytest.raises(TypeError, match="immutable tuples"):
ResolvedApprovalPlan([approver], (), None)
with pytest.raises(ValueError, match="cannot have a failure"):
ResolvedApprovalPlan((approver,), (), RecipientResolutionFailureReason.NO_VALID_RECIPIENTS)
with pytest.raises(ValueError, match="must have a failure"):
ResolvedApprovalPlan((), (), None)
with pytest.raises(TypeError, match="immutable tuple"):
DeliveryCapabilitySnapshot(im_bindings=[])
with pytest.raises(TypeError, match="immutable frozensets"):
DeliveryCapabilitySnapshot(contact_web_ids=set())
def test_interaction_endpoints_and_unbound_im_endpoint_have_stable_primitive_shapes() -> None:
endpoint = IMEndpointPlan(
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_tenant_id="provider-tenant-1",
identity_id=IMIdentityId("identity-1"),
binding_id=None,
provider_user_id="provider-user-1",
)
assert endpoint.to_primitive()["binding_id"] is None
assert WebEndpointPlan().to_primitive() == {"channel": "web"}
assert ConsoleEndpointPlan().to_primitive() == {"channel": "console"}
@@ -0,0 +1,557 @@
"""Behavior tests for the single-entry recipient resolver."""
from datetime import UTC, datetime
import pytest
from core.human_input_v2.approval import (
CanonicalSubjectKey,
ConsoleEndpointPlan,
ContactApprovalSubject,
ContactInitiatorSnapshot,
ContactRecipientSpecification,
CurrentInitiatorRecipientSpecification,
DebugRecipientReplacement,
DeliveryCapabilitySnapshot,
DynamicEmailRecipientSpecification,
DynamicRecipientValue,
EmailAddressApprovalSubject,
EmailEndpointPlan,
EndUserApprovalSubject,
EndUserInitiatorSnapshot,
IMEndpointPlan,
OneTimeEmailRecipientSpecification,
RecipientRejectionReason,
RecipientResolutionFailureReason,
RecipientResolver,
RecipientSourceKind,
UnsupportedDynamicRecipientValue,
WebEndpointPlan,
)
from core.human_input_v2.contact_directory import Contact, ContactDirectorySnapshot
from core.human_input_v2.entities import HumanInputDeliveryChannel, IMProvider
from core.human_input_v2.im_integration import EffectiveIMBindingSnapshot
from core.human_input_v2.shared import (
AccountId,
ContactId,
EndUserId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_WORKSPACE_ID = WorkspaceId("workspace-1")
def _workspace_contact(
contact_id: str = "contact-1",
account_id: str = "account-1",
email: str | None = "reviewer@example.com",
workspace_id: WorkspaceId = _WORKSPACE_ID,
) -> Contact:
return Contact.workspace_member(
contact_id=ContactId(contact_id),
workspace_id=workspace_id,
account_id=AccountId(account_id),
name=f"Reviewer {contact_id}",
email=email,
now=_NOW,
)
def _organization_contact(
contact_id: str = "contact-1",
account_id: str = "account-1",
email: str | None = "reviewer@example.com",
) -> Contact:
return Contact.organization_account(
contact_id=ContactId(contact_id),
account_id=AccountId(account_id),
name=f"Reviewer {contact_id}",
email=email,
now=_NOW,
)
def _directory(*contacts: Contact) -> ContactDirectorySnapshot:
selected_contacts = contacts or (_workspace_contact(),)
return ContactDirectorySnapshot(
workspace_id=_WORKSPACE_ID,
contacts=selected_contacts,
member_account_ids=frozenset(
contact.account_id for contact in selected_contacts if contact.account_id is not None
),
)
def _im_binding(
*,
contact_id: str = "contact-1",
integration_id: str = "integration-1",
identity_id: str = "identity-1",
binding_id: str | None = "binding-1",
provider: IMProvider = IMProvider.FEISHU,
) -> EffectiveIMBindingSnapshot:
return EffectiveIMBindingSnapshot(
integration_id=IntegrationId(integration_id),
integration_config_version=1,
provider=provider,
provider_tenant_id="provider-tenant-1",
contact_id=ContactId(contact_id),
account_id=AccountId("account-1"),
identity_id=IMIdentityId(identity_id),
binding_id=IMBindingId(binding_id) if binding_id is not None else None,
provider_user_id=f"provider-{identity_id}",
display_name="Reviewer",
email="reviewer@example.com",
)
def test_static_contact_dynamic_email_and_initiator_collapse_to_one_contact() -> None:
specifications = (
ContactRecipientSpecification("contact-1"),
DynamicEmailRecipientSpecification(("node-1", "email")),
CurrentInitiatorRecipientSpecification(),
)
plan = RecipientResolver.resolve(
specifications=specifications,
directory=_directory(),
dynamic_values=(DynamicRecipientValue(("node-1", "email"), " REVIEWER@example.com "),),
initiator=ContactInitiatorSnapshot(ContactId("contact-1")),
capabilities=DeliveryCapabilitySnapshot(),
)
assert len(plan.approvers) == 1
approver = plan.approvers[0]
assert approver.subject == ContactApprovalSubject(ContactId("contact-1"))
assert [source.kind for source in approver.matched_sources] == [
RecipientSourceKind.STATIC_CONTACT,
RecipientSourceKind.DYNAMIC_EMAIL,
RecipientSourceKind.CURRENT_INITIATOR,
]
assert approver.endpoints == (EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),)
assert plan.rejected_recipients == ()
def test_unmatched_email_is_upgraded_to_email_subject_and_normalized_duplicates_collapse() -> None:
specifications = (
OneTimeEmailRecipientSpecification(" Person@Example.com "),
DynamicEmailRecipientSpecification(("node-1", "email")),
)
plan = RecipientResolver.resolve(
specifications=specifications,
directory=_directory(),
dynamic_values=(DynamicRecipientValue(("node-1", "email"), "person@example.COM"),),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert len(plan.approvers) == 1
approver = plan.approvers[0]
assert approver.subject == EmailAddressApprovalSubject(NormalizedEmail("person@example.com"))
assert approver.subject_key == CanonicalSubjectKey.for_email(NormalizedEmail("person@example.com"))
assert [source.kind for source in approver.matched_sources] == [
RecipientSourceKind.ONE_TIME_EMAIL,
RecipientSourceKind.DYNAMIC_EMAIL,
]
assert approver.endpoints == (EmailEndpointPlan(NormalizedEmail("person@example.com")),)
def test_email_matching_contact_with_unavailable_account_does_not_bypass_contact_governance() -> None:
contact = _workspace_contact()
directory = ContactDirectorySnapshot(
workspace_id=_WORKSPACE_ID,
contacts=(contact,),
member_account_ids=frozenset({AccountId("account-1")}),
unavailable_account_ids=frozenset({AccountId("account-1")}),
)
plan = RecipientResolver.resolve(
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
directory=directory,
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert plan.approvers == ()
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
]
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
def test_email_matching_organization_contact_outside_workspace_visibility_does_not_fallback() -> None:
contact = _organization_contact()
directory = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(contact,))
plan = RecipientResolver.resolve(
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
directory=directory,
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert plan.approvers == ()
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
]
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
def test_email_matching_cross_workspace_contact_does_not_fallback() -> None:
contact = _workspace_contact(workspace_id=WorkspaceId("workspace-2"))
directory = ContactDirectorySnapshot(
workspace_id=_WORKSPACE_ID,
contacts=(contact,),
member_account_ids=frozenset({AccountId("account-1")}),
)
plan = RecipientResolver.resolve(
specifications=(OneTimeEmailRecipientSpecification("reviewer@example.com"),),
directory=directory,
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert plan.approvers == ()
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
(RecipientRejectionReason.CONTACT_UNAVAILABLE, "reviewer@example.com")
]
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
def test_invalid_and_unsupported_recipients_are_retained_without_dropping_valid_approvers() -> None:
specifications = (
OneTimeEmailRecipientSpecification("not-an-email"),
DynamicEmailRecipientSpecification(("node-1", "email")),
OneTimeEmailRecipientSpecification("valid@example.com"),
)
plan = RecipientResolver.resolve(
specifications=specifications,
directory=_directory(),
dynamic_values=(
DynamicRecipientValue(
("node-1", "email"),
UnsupportedDynamicRecipientValue("list"),
),
),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert [approver.subject for approver in plan.approvers] == [
EmailAddressApprovalSubject(NormalizedEmail("valid@example.com"))
]
assert [(rejection.reason, rejection.rejected_value) for rejection in plan.rejected_recipients] == [
(RecipientRejectionReason.INVALID_EMAIL, "not-an-email"),
(RecipientRejectionReason.UNSUPPORTED_DYNAMIC_TYPE, "list"),
]
assert plan.failure_reason is None
@pytest.mark.parametrize(
("specification", "dynamic_values", "reason", "value"),
[
(ContactRecipientSpecification(" "), (), RecipientRejectionReason.INVALID_CONTACT_ID, " "),
(
DynamicEmailRecipientSpecification(()),
(),
RecipientRejectionReason.INVALID_DYNAMIC_SELECTOR,
"",
),
(
DynamicEmailRecipientSpecification(("node-1", "email")),
(),
RecipientRejectionReason.DYNAMIC_VALUE_UNAVAILABLE,
"node-1.email",
),
(
DynamicEmailRecipientSpecification(("node-1", "email")),
(DynamicRecipientValue(("node-1", "email"), "invalid"),),
RecipientRejectionReason.INVALID_EMAIL,
"invalid",
),
],
)
def test_invalid_input_paths_return_stable_no_valid_recipients(
specification,
dynamic_values: tuple[DynamicRecipientValue, ...],
reason: RecipientRejectionReason,
value: str,
) -> None:
first = RecipientResolver.resolve(
specifications=(specification,),
directory=_directory(),
dynamic_values=dynamic_values,
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
second = RecipientResolver.resolve(
specifications=(specification,),
directory=_directory(),
dynamic_values=dynamic_values,
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert first == second
assert first.approvers == ()
assert [(rejection.reason, rejection.rejected_value) for rejection in first.rejected_recipients] == [
(reason, value)
]
assert first.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
def test_unavailable_contact_and_initiator_do_not_block_other_recipients() -> None:
plan = RecipientResolver.resolve(
specifications=(
ContactRecipientSpecification("missing-contact"),
CurrentInitiatorRecipientSpecification(),
OneTimeEmailRecipientSpecification("valid@example.com"),
),
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert len(plan.approvers) == 1
assert [rejection.reason for rejection in plan.rejected_recipients] == [
RecipientRejectionReason.CONTACT_UNAVAILABLE,
RecipientRejectionReason.INITIATOR_UNAVAILABLE,
]
def test_email_and_all_effective_im_bindings_create_parallel_deterministic_endpoints() -> None:
capabilities = DeliveryCapabilitySnapshot(
im_bindings=(
_im_binding(
integration_id="integration-2",
identity_id="identity-2",
provider=IMProvider.SLACK,
),
_im_binding(),
),
contact_console_ids=frozenset({ContactId("contact-1")}),
)
plan = RecipientResolver.resolve(
specifications=(ContactRecipientSpecification("contact-1"),),
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=capabilities,
)
assert [endpoint.channel for endpoint in plan.approvers[0].endpoints] == [
HumanInputDeliveryChannel.EMAIL,
HumanInputDeliveryChannel.IM,
HumanInputDeliveryChannel.IM,
HumanInputDeliveryChannel.CONSOLE,
]
assert plan.approvers[0].endpoints == (
EmailEndpointPlan(NormalizedEmail("reviewer@example.com")),
IMEndpointPlan(
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_tenant_id="provider-tenant-1",
identity_id=IMIdentityId("identity-1"),
binding_id=IMBindingId("binding-1"),
provider_user_id="provider-identity-1",
),
IMEndpointPlan(
integration_id=IntegrationId("integration-2"),
provider=IMProvider.SLACK,
provider_tenant_id="provider-tenant-1",
identity_id=IMIdentityId("identity-2"),
binding_id=IMBindingId("binding-1"),
provider_user_id="provider-identity-2",
),
ConsoleEndpointPlan(),
)
def test_email_only_and_explicit_web_capability_are_planned_separately() -> None:
email_only = RecipientResolver.resolve(
specifications=(OneTimeEmailRecipientSpecification("person@example.com"),),
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
email_and_web = RecipientResolver.resolve(
specifications=(OneTimeEmailRecipientSpecification("person@example.com"),),
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(email_address_web_available=True),
)
assert email_only.approvers[0].endpoints == (EmailEndpointPlan(NormalizedEmail("person@example.com")),)
assert email_and_web.approvers[0].endpoints == (
EmailEndpointPlan(NormalizedEmail("person@example.com")),
WebEndpointPlan(),
)
def test_subject_without_any_usable_endpoint_is_rejected() -> None:
contact = _workspace_contact(email=None)
plan = RecipientResolver.resolve(
specifications=(ContactRecipientSpecification("contact-1"),),
directory=_directory(contact),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
assert plan.approvers == ()
assert [rejection.reason for rejection in plan.rejected_recipients] == [RecipientRejectionReason.NO_USABLE_ENDPOINT]
assert plan.failure_reason is RecipientResolutionFailureReason.NO_VALID_RECIPIENTS
def test_debug_replacement_is_request_scoped_and_does_not_mutate_saved_specifications() -> None:
saved_specifications = (OneTimeEmailRecipientSpecification("configured@example.com"),)
plan = RecipientResolver.resolve(
specifications=saved_specifications,
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
debug_replacement=DebugRecipientReplacement(ContactInitiatorSnapshot(ContactId("contact-1"))),
)
assert saved_specifications == (OneTimeEmailRecipientSpecification("configured@example.com"),)
assert [approver.subject for approver in plan.approvers] == [ContactApprovalSubject(ContactId("contact-1"))]
assert [source.kind for source in plan.approvers[0].matched_sources] == [RecipientSourceKind.DEBUG_REPLACEMENT]
def test_end_user_initiator_remains_a_distinct_subject_with_request_scoped_capabilities() -> None:
end_user_id = EndUserId("end-user-1")
plan = RecipientResolver.resolve(
specifications=(CurrentInitiatorRecipientSpecification(),),
directory=_directory(),
dynamic_values=(),
initiator=EndUserInitiatorSnapshot(
end_user_id=end_user_id,
display_name="End User",
email="ENDUSER@example.com",
),
capabilities=DeliveryCapabilitySnapshot(end_user_web_ids=frozenset({end_user_id})),
)
assert plan.approvers[0].subject == EndUserApprovalSubject(end_user_id)
assert plan.approvers[0].endpoints == (
EmailEndpointPlan(NormalizedEmail("enduser@example.com")),
WebEndpointPlan(),
)
@pytest.mark.parametrize("email", [None, "invalid"])
def test_end_user_initiator_without_usable_email_can_use_an_explicit_web_endpoint(email: str | None) -> None:
end_user_id = EndUserId("end-user-1")
plan = RecipientResolver.resolve(
specifications=(CurrentInitiatorRecipientSpecification(),),
directory=_directory(),
dynamic_values=(),
initiator=EndUserInitiatorSnapshot(end_user_id, "End User", email),
capabilities=DeliveryCapabilitySnapshot(end_user_web_ids=frozenset({end_user_id})),
)
assert plan.approvers[0].endpoints == (WebEndpointPlan(),)
def test_contact_web_capability_and_duplicate_endpoints_are_deterministically_deduplicated() -> None:
duplicate_binding = _im_binding()
plan = RecipientResolver.resolve(
specifications=(
ContactRecipientSpecification("contact-1"),
ContactRecipientSpecification("contact-1"),
),
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(
im_bindings=(duplicate_binding, duplicate_binding),
contact_web_ids=frozenset({ContactId("contact-1")}),
),
)
assert [endpoint.channel for endpoint in plan.approvers[0].endpoints] == [
HumanInputDeliveryChannel.EMAIL,
HumanInputDeliveryChannel.IM,
HumanInputDeliveryChannel.WEB,
]
def test_resolver_rejects_mutable_top_level_inputs() -> None:
with pytest.raises(TypeError, match="immutable tuples"):
RecipientResolver.resolve(
specifications=[OneTimeEmailRecipientSpecification("user@example.com")],
directory=_directory(),
dynamic_values=(),
initiator=None,
capabilities=DeliveryCapabilitySnapshot(),
)
def test_unknown_recipient_specification_fails_closed_instead_of_resolving_current_initiator() -> None:
with pytest.raises(AssertionError):
RecipientResolver.resolve(
specifications=(object(),),
directory=_directory(),
dynamic_values=(),
initiator=ContactInitiatorSnapshot(ContactId("contact-1")),
capabilities=DeliveryCapabilitySnapshot(),
)
def test_every_ordered_plan_component_is_equal_across_repeated_resolution() -> None:
specifications = (
OneTimeEmailRecipientSpecification("z@example.com"),
ContactRecipientSpecification("contact-1"),
OneTimeEmailRecipientSpecification("a@example.com"),
DynamicEmailRecipientSpecification(("node-1", "bad")),
)
dynamic_values = (DynamicRecipientValue(("node-1", "bad"), "invalid"),)
capabilities = DeliveryCapabilitySnapshot(
im_bindings=(_im_binding(),),
contact_console_ids=frozenset({ContactId("contact-1")}),
)
first = RecipientResolver.resolve(
specifications=specifications,
directory=_directory(),
dynamic_values=dynamic_values,
initiator=None,
capabilities=capabilities,
)
second = RecipientResolver.resolve(
specifications=specifications,
directory=_directory(),
dynamic_values=dynamic_values,
initiator=None,
capabilities=capabilities,
)
assert first == second
assert [approver.subject_key.value for approver in first.approvers] == [
CanonicalSubjectKey.for_email(NormalizedEmail("z@example.com")).value,
"contact:contact-1",
CanonicalSubjectKey.for_email(NormalizedEmail("a@example.com")).value,
]
assert [rejection.source.position for rejection in first.rejected_recipients] == [3]
@@ -0,0 +1,98 @@
"""Recipient specification and workflow configuration boundary tests."""
from dataclasses import FrozenInstanceError
import pytest
from core.human_input_v2.approval import (
ContactRecipientSpecification,
CurrentInitiatorRecipientSpecification,
DynamicEmailRecipientSpecification,
DynamicRecipientValue,
OneTimeEmailRecipientSpecification,
UnsupportedDynamicRecipientValue,
WorkflowRecipientSpecificationAdapter,
)
from core.workflow.nodes.human_input_v2.entities import (
Contact,
DebugModeConfig,
DynamicEmail,
HumanInputNodeData,
Initiator,
MessageTemplateConfig,
OnetimeEmail,
)
def _node_data() -> HumanInputNodeData:
return HumanInputNodeData(
title="Approval",
recipients_spec=[
Contact(contact_id="contact-1"),
OnetimeEmail(email=" INVALID "),
DynamicEmail(selector=["node-1", "email"]),
Initiator(),
],
message_template=MessageTemplateConfig(subject="Approval requested", body="Review the request"),
debug_mode=DebugModeConfig(channels=[]),
)
def test_workflow_node_configuration_is_explicitly_adapted_to_immutable_specifications() -> None:
node_data = _node_data()
specifications = WorkflowRecipientSpecificationAdapter.from_node_data(node_data)
assert specifications == (
ContactRecipientSpecification(contact_id="contact-1"),
OneTimeEmailRecipientSpecification(email=" INVALID "),
DynamicEmailRecipientSpecification(selector=("node-1", "email")),
CurrentInitiatorRecipientSpecification(),
)
node_data.recipients_spec.append(OnetimeEmail(email="later@example.com"))
assert len(specifications) == 4
def test_specification_values_are_frozen_and_serialize_only_at_the_boundary() -> None:
specification = OneTimeEmailRecipientSpecification(email=" USER@example.com ")
assert specification.to_primitive() == {"type": "onetime_email", "email": " USER@example.com "}
with pytest.raises(FrozenInstanceError):
specification.email = "other@example.com"
def test_dynamic_runtime_values_capture_supported_and_unsupported_types_immutably() -> None:
supported = DynamicRecipientValue.from_runtime(("node-1", "email"), "user@example.com")
unsupported = DynamicRecipientValue.from_runtime(("node-2", "emails"), ["user@example.com"])
assert supported == DynamicRecipientValue(selector=("node-1", "email"), value="user@example.com")
assert unsupported == DynamicRecipientValue(
selector=("node-2", "emails"),
value=UnsupportedDynamicRecipientValue(value_type="list"),
)
with pytest.raises(FrozenInstanceError):
unsupported.selector = ("other",)
def test_specification_and_runtime_snapshots_reject_mutable_or_invalid_shapes() -> None:
with pytest.raises(TypeError, match="immutable tuple"):
DynamicEmailRecipientSpecification(["node-1", "email"])
with pytest.raises(TypeError, match="immutable tuple"):
DynamicRecipientValue(["node-1", "email"], "user@example.com")
with pytest.raises(ValueError, match="must not be blank"):
UnsupportedDynamicRecipientValue("")
@pytest.mark.parametrize(
("specification", "primitive"),
[
(ContactRecipientSpecification("contact-1"), {"type": "contact", "contact_id": "contact-1"}),
(
DynamicEmailRecipientSpecification(("node-1", "email")),
{"type": "dynamic_email", "selector": ["node-1", "email"]},
),
(CurrentInitiatorRecipientSpecification(), {"type": "initiator"}),
],
)
def test_every_specification_has_a_stable_primitive_shape(specification, primitive: dict[str, object]) -> None:
assert specification.to_primitive() == primitive
@@ -0,0 +1,558 @@
"""Pure current-state authorization contracts for Human Input v2 submission."""
from dataclasses import fields
from datetime import UTC, datetime, timedelta
import pytest
from core.human_input_v2.approval import (
AccountSubmissionActor,
ApproverGrant,
AuthorizationContext,
ContactApprovalSubject,
ContactOTPSubject,
CurrentContactAuthorizationFacts,
CurrentEndUserAuthorizationFacts,
CurrentIMAuthorizationFacts,
DeliveryEndpoint,
EmailAddressApprovalSubject,
EmailAddressOTPSubject,
EmailAddressSubmissionActor,
EndUserApprovalSubject,
EndUserSubmissionActor,
FormRef,
FrozenFormAction,
FrozenFormDefinition,
FrozenJSONObject,
HumanInputForm,
IMEndpointConfiguration,
SubmissionAuthorizationDecision,
SubmissionAuthorizationRejection,
SubmissionAuthorizer,
VerifiedAccountSessionProof,
VerifiedEmailOTPProof,
VerifiedIMIdentityProof,
VerifiedTrustedEndUserProof,
)
from core.human_input_v2.approval.recipient_resolution import CanonicalSubjectKey, SubjectSnapshot
from core.human_input_v2.entities import HumanInputV2FormKind, HumanInputV2FormStatus, IMProvider
from core.human_input_v2.shared import (
AccountId,
AppId,
ApproverGrantId,
ContactId,
DeliveryEndpointId,
EndUserId,
FormId,
IMBindingId,
IMIdentityId,
IntegrationId,
NormalizedEmail,
OTPChallengeId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_FORM_REF = FormRef(WorkspaceId("workspace-1"), FormId("form-1"))
_ACCOUNT_ID = AccountId("account-1")
_CONTACT_ID = ContactId("contact-1")
_EMAIL = NormalizedEmail("reviewer@example.com")
_END_USER_ID = EndUserId("end-user-1")
_INTEGRATION_ID = IntegrationId("integration-1")
_IM_IDENTITY_ID = IMIdentityId("identity-1")
_IM_BINDING_ID = IMBindingId("binding-1")
def _grant(subject: ContactApprovalSubject | EndUserApprovalSubject | EmailAddressApprovalSubject) -> ApproverGrant:
grant_ref = _FORM_REF.grant(ApproverGrantId("grant-1"))
if isinstance(subject, ContactApprovalSubject):
subject_key = CanonicalSubjectKey.for_contact(subject.contact_id)
elif isinstance(subject, EndUserApprovalSubject):
subject_key = CanonicalSubjectKey.for_end_user(subject.end_user_id)
else:
subject_key = CanonicalSubjectKey.for_email(subject.normalized_email)
return ApproverGrant(
ref=grant_ref,
subject=subject,
subject_key=subject_key,
matched_sources=(),
subject_snapshot=SubjectSnapshot("Reviewer", str(_EMAIL)),
created_at=_NOW,
updated_at=_NOW,
)
def _form(grant: ApproverGrant, *, status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING) -> HumanInputForm:
return HumanInputForm(
ref=_FORM_REF,
app_id=AppId("app-1"),
definition=FrozenFormDefinition(
form_content="Approve",
inputs=(),
actions=(FrozenFormAction("approve", "Approve", "primary"),),
default_values=FrozenJSONObject.from_mapping({}),
node_title="Review",
display_in_ui=True,
),
rendered_content="Approve",
node_timeout_at=UtcTimestamp(_NOW.value + timedelta(hours=1)),
global_expires_at=UtcTimestamp(_NOW.value + timedelta(hours=2)),
kind=HumanInputV2FormKind.RUNTIME,
status=status,
workflow_pause_id="pause-1",
node_execution_id="node-execution-1",
grants=(grant,),
created_at=_NOW,
updated_at=_NOW,
)
def _im_endpoint(grant: ApproverGrant) -> DeliveryEndpoint:
return DeliveryEndpoint(
ref=grant.ref.endpoint(DeliveryEndpointId("endpoint-1")),
configuration=IMEndpointConfiguration(
integration_id=_INTEGRATION_ID,
provider=IMProvider.SLACK,
provider_tenant_id="provider-tenant-1",
identity_id=_IM_IDENTITY_ID,
binding_id=_IM_BINDING_ID,
provider_user_id="provider-user-1",
),
address_hash="a" * 64,
access_capability=None,
created_at=_NOW,
updated_at=_NOW,
)
def _contact_facts(
*,
contact_id: ContactId = _CONTACT_ID,
account_id: AccountId | None = _ACCOUNT_ID,
normalized_email: NormalizedEmail | None = _EMAIL,
account_active: bool = True,
workspace_available: bool = True,
) -> CurrentContactAuthorizationFacts:
return CurrentContactAuthorizationFacts(
contact_id=contact_id,
account_id=account_id,
normalized_email=normalized_email,
account_active=account_active,
workspace_available=workspace_available,
)
def _im_facts(*, binding_id: IMBindingId | None = _IM_BINDING_ID) -> CurrentIMAuthorizationFacts:
return CurrentIMAuthorizationFacts(
integration_id=_INTEGRATION_ID,
provider=IMProvider.SLACK,
provider_tenant_id="provider-tenant-1",
contact_id=_CONTACT_ID,
account_id=_ACCOUNT_ID,
identity_id=_IM_IDENTITY_ID,
binding_id=binding_id,
provider_user_id="provider-user-1",
)
def _context(
grant: ApproverGrant,
*,
endpoint: DeliveryEndpoint | None = None,
contact: CurrentContactAuthorizationFacts | None = None,
end_user: CurrentEndUserAuthorizationFacts | None = None,
im: CurrentIMAuthorizationFacts | None = None,
status: HumanInputV2FormStatus = HumanInputV2FormStatus.WAITING,
) -> AuthorizationContext:
return AuthorizationContext(
form=_form(grant, status=status),
grant=grant,
endpoint=endpoint,
current_contact=contact,
current_end_user=end_user,
current_im_binding=im,
)
def _email_proof(grant: ApproverGrant, *, contact_id: ContactId | None = _CONTACT_ID) -> VerifiedEmailOTPProof:
subject = ContactOTPSubject(contact_id) if contact_id is not None else EmailAddressOTPSubject(_EMAIL)
return VerifiedEmailOTPProof(
challenge_ref=grant.ref.challenge(OTPChallengeId("challenge-1")),
subject=subject,
normalized_email=_EMAIL,
verified_at=_NOW,
)
def _im_proof(*, binding_id: IMBindingId | None = _IM_BINDING_ID) -> VerifiedIMIdentityProof:
return VerifiedIMIdentityProof(
integration_id=_INTEGRATION_ID,
identity_id=_IM_IDENTITY_ID,
binding_id=binding_id,
provider=IMProvider.SLACK,
provider_tenant_id="provider-tenant-1",
provider_user_id="provider-user-1",
)
def _authorize(
context: AuthorizationContext,
proof: object,
*,
selected_action_id: str = "approve",
now: UtcTimestamp = _NOW,
) -> SubmissionAuthorizationDecision:
return SubmissionAuthorizer.authorize(
context=context,
proof=proof,
selected_action_id=selected_action_id,
now=now,
)
@pytest.mark.parametrize("raw_candidate", ["123456", {"token": "secret"}, object()])
def test_raw_credentials_fail_closed_at_the_pure_authorization_boundary(raw_candidate: object) -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(_context(grant, contact=_contact_facts()), raw_candidate)
assert decision.authorized is None
assert decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
def test_grant_endpoint_verified_proof_and_actor_are_not_interchangeable() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
endpoint = _im_endpoint(grant)
context = _context(grant, endpoint=endpoint, contact=_contact_facts(), im=_im_facts())
grant_decision = _authorize(context, grant)
endpoint_decision = _authorize(context, endpoint)
proof_decision = _authorize(context, _im_proof())
assert grant_decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
assert endpoint_decision.rejection is SubmissionAuthorizationRejection.RAW_CREDENTIAL_NOT_VERIFIED
assert isinstance(proof_decision.authorized.actor, AccountSubmissionActor)
assert proof_decision.authorized.proof != proof_decision.authorized.actor
def test_verified_account_session_resolves_the_current_contact_account_actor() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(_context(grant, contact=_contact_facts()), VerifiedAccountSessionProof(_ACCOUNT_ID))
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
def test_trusted_end_user_remains_a_distinct_end_user_actor() -> None:
grant = _grant(EndUserApprovalSubject(_END_USER_ID))
current_end_user = CurrentEndUserAuthorizationFacts(
end_user_id=_END_USER_ID,
app_id=AppId("app-1"),
workspace_available=True,
)
decision = _authorize(
_context(grant, end_user=current_end_user),
VerifiedTrustedEndUserProof(end_user_id=_END_USER_ID, app_id=AppId("app-1")),
)
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == EndUserSubmissionActor(_END_USER_ID)
def test_standalone_email_otp_resolves_an_email_address_actor() -> None:
grant = _grant(EmailAddressApprovalSubject(_EMAIL))
decision = _authorize(_context(grant), _email_proof(grant, contact_id=None))
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == EmailAddressSubmissionActor(_EMAIL)
def test_contact_email_otp_uses_current_account_when_available() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(_context(grant, contact=_contact_facts()), _email_proof(grant))
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
def test_external_contact_email_otp_uses_the_verified_email_actor() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(
_context(grant, contact=_contact_facts(account_id=None)),
_email_proof(grant),
)
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == EmailAddressSubmissionActor(_EMAIL)
def test_current_im_binding_resolves_only_to_the_current_dify_account() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
endpoint = _im_endpoint(grant)
decision = _authorize(
_context(grant, endpoint=endpoint, contact=_contact_facts(), im=_im_facts()),
_im_proof(),
)
assert decision.rejection is None
assert decision.authorized is not None
assert decision.authorized.actor == AccountSubmissionActor(_ACCOUNT_ID)
def test_verified_proof_scoped_to_another_grant_is_rejected() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
other_grant = ApproverGrant(
ref=_FORM_REF.grant(ApproverGrantId("grant-2")),
subject=grant.subject,
subject_key=grant.subject_key,
matched_sources=(),
subject_snapshot=grant.subject_snapshot,
created_at=_NOW,
updated_at=_NOW,
)
decision = _authorize(_context(grant, contact=_contact_facts()), _email_proof(other_grant))
assert decision.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
@pytest.mark.parametrize(
("contact", "expected"),
[
(None, SubmissionAuthorizationRejection.STALE_IDENTITY),
(_contact_facts(account_active=False), SubmissionAuthorizationRejection.ACCOUNT_DISABLED),
(
_contact_facts(contact_id=ContactId("recreated-contact")),
SubmissionAuthorizationRejection.STALE_IDENTITY,
),
(
_contact_facts(normalized_email=NormalizedEmail("changed@example.com")),
SubmissionAuthorizationRejection.STALE_IDENTITY,
),
(
_contact_facts(workspace_available=False),
SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE,
),
],
)
def test_contact_email_proof_revalidates_every_current_identity_fact(
contact: CurrentContactAuthorizationFacts | None,
expected: SubmissionAuthorizationRejection,
) -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(_context(grant, contact=contact), _email_proof(grant))
assert decision.rejection is expected
def test_changed_effective_im_binding_rejects_historical_im_proof() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
endpoint = _im_endpoint(grant)
decision = _authorize(
_context(
grant,
endpoint=endpoint,
contact=_contact_facts(),
im=_im_facts(binding_id=IMBindingId("replacement-binding")),
),
_im_proof(),
)
assert decision.rejection is SubmissionAuthorizationRejection.IM_BINDING_CHANGED
@pytest.mark.parametrize(
("status", "expected"),
[
(HumanInputV2FormStatus.SUBMITTED, SubmissionAuthorizationRejection.FORM_ALREADY_SUBMITTED),
(HumanInputV2FormStatus.TIMEOUT, SubmissionAuthorizationRejection.FORM_TIMED_OUT),
(HumanInputV2FormStatus.EXPIRED, SubmissionAuthorizationRejection.FORM_STATUS_EXPIRED),
],
)
def test_inactive_form_states_remain_stable_and_transport_neutral(
status: HumanInputV2FormStatus,
expected: SubmissionAuthorizationRejection,
) -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(_context(grant, contact=_contact_facts(), status=status), _email_proof(grant))
assert decision.rejection is expected
def test_authorization_context_is_an_immutable_snapshot_without_version_or_reload_hooks() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
mutable_current_state = {"account_active": True, "workspace_available": True}
context = _context(
grant,
contact=_contact_facts(
account_active=mutable_current_state["account_active"],
workspace_available=mutable_current_state["workspace_available"],
),
)
mutable_current_state["account_active"] = False
mutable_current_state["workspace_available"] = False
decision = _authorize(context, _email_proof(grant))
assert decision.rejection is None
assert {field.name for field in fields(AuthorizationContext)}.isdisjoint(
{"contact_version", "binding_version", "reload", "loader"}
)
assert AuthorizationContext.__dataclass_params__.frozen is True
@pytest.mark.parametrize(
("provider_tenant_id", "provider_user_id"),
[("", "provider-user-1"), ("provider-tenant-1", " ")],
)
def test_verified_im_proof_rejects_blank_provider_identity(
provider_tenant_id: str,
provider_user_id: str,
) -> None:
with pytest.raises(ValueError, match="must not be blank"):
VerifiedIMIdentityProof(
integration_id=_INTEGRATION_ID,
identity_id=_IM_IDENTITY_ID,
binding_id=_IM_BINDING_ID,
provider=IMProvider.SLACK,
provider_tenant_id=provider_tenant_id,
provider_user_id=provider_user_id,
)
def test_authorization_context_rejects_grant_or_endpoint_from_another_owner() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
other_grant = ApproverGrant(
ref=_FORM_REF.grant(ApproverGrantId("grant-2")),
subject=EndUserApprovalSubject(_END_USER_ID),
subject_key=CanonicalSubjectKey.for_end_user(_END_USER_ID),
matched_sources=(),
subject_snapshot=SubjectSnapshot("Other Reviewer", None),
created_at=_NOW,
updated_at=_NOW,
)
with pytest.raises(ValueError, match="grant does not belong"):
AuthorizationContext(_form(grant), other_grant, None, None, None, None)
with pytest.raises(ValueError, match="endpoint does not belong"):
AuthorizationContext(_form(grant), grant, _im_endpoint(other_grant), None, None, None)
def test_authorization_decision_requires_exactly_one_outcome() -> None:
with pytest.raises(ValueError, match="exactly one outcome"):
SubmissionAuthorizationDecision(None, None)
def test_invalid_selected_action_returns_transport_neutral_rejection() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(
_context(grant, contact=_contact_facts()),
VerifiedAccountSessionProof(_ACCOUNT_ID),
selected_action_id="unknown",
)
assert decision.rejection is SubmissionAuthorizationRejection.INVALID_SELECTED_ACTION
def test_waiting_form_past_global_expiry_returns_global_expired_rejection() -> None:
grant = _grant(ContactApprovalSubject(_CONTACT_ID))
decision = _authorize(
_context(grant, contact=_contact_facts()),
VerifiedAccountSessionProof(_ACCOUNT_ID),
now=UtcTimestamp(_NOW.value + timedelta(hours=3)),
)
assert decision.rejection is SubmissionAuthorizationRejection.FORM_GLOBALLY_EXPIRED
def test_account_session_requires_contact_grant_current_contact_and_matching_account() -> None:
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
wrong_subject = _authorize(_context(end_user_grant), VerifiedAccountSessionProof(_ACCOUNT_ID))
missing_contact = _authorize(_context(contact_grant), VerifiedAccountSessionProof(_ACCOUNT_ID))
wrong_account = _authorize(
_context(contact_grant, contact=_contact_facts()),
VerifiedAccountSessionProof(AccountId("account-2")),
)
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
assert missing_contact.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
assert wrong_account.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
def test_trusted_end_user_requires_end_user_grant_current_owner_and_workspace() -> None:
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
proof = VerifiedTrustedEndUserProof(_END_USER_ID, AppId("app-1"))
wrong_subject = _authorize(_context(contact_grant, contact=_contact_facts()), proof)
missing_end_user = _authorize(_context(end_user_grant), proof)
unavailable_workspace = _authorize(
_context(
end_user_grant,
end_user=CurrentEndUserAuthorizationFacts(_END_USER_ID, AppId("app-1"), False),
),
proof,
)
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
assert missing_end_user.rejection is SubmissionAuthorizationRejection.END_USER_UNAVAILABLE
assert unavailable_workspace.rejection is SubmissionAuthorizationRejection.WORKSPACE_UNAVAILABLE
def test_email_proof_requires_matching_subject_kind_and_contact_incarnation() -> None:
standalone_grant = _grant(EmailAddressApprovalSubject(_EMAIL))
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
standalone_with_contact_proof = _authorize(_context(standalone_grant), _email_proof(standalone_grant))
contact_with_email_address_proof = _authorize(
_context(contact_grant, contact=_contact_facts()),
_email_proof(contact_grant, contact_id=None),
)
changed_contact_incarnation = _authorize(
_context(contact_grant, contact=_contact_facts()),
_email_proof(contact_grant, contact_id=ContactId("contact-2")),
)
assert standalone_with_contact_proof.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
assert contact_with_email_address_proof.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
assert changed_contact_incarnation.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
def test_im_proof_requires_contact_account_and_current_effective_binding() -> None:
end_user_grant = _grant(EndUserApprovalSubject(_END_USER_ID))
contact_grant = _grant(ContactApprovalSubject(_CONTACT_ID))
wrong_subject = _authorize(_context(end_user_grant), _im_proof())
missing_contact = _authorize(_context(contact_grant), _im_proof())
contact_without_account = _authorize(
_context(contact_grant, contact=_contact_facts(account_id=None)),
_im_proof(),
)
missing_binding = _authorize(
_context(contact_grant, contact=_contact_facts()),
_im_proof(),
)
assert wrong_subject.rejection is SubmissionAuthorizationRejection.GRANT_NOT_MATCHED
assert missing_contact.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
assert contact_without_account.rejection is SubmissionAuthorizationRejection.STALE_IDENTITY
assert missing_binding.rejection is SubmissionAuthorizationRejection.IM_BINDING_CHANGED
@@ -0,0 +1,217 @@
"""Domain tests for canonical Contact identity and ownership."""
from dataclasses import FrozenInstanceError, replace
from datetime import UTC, datetime
import pytest
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactIdentitySource,
ContactRejectionCode,
ExternalContactOwner,
OrganizationAccountOwner,
WorkspaceMemberOwner,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
DeploymentScope,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
WorkspaceScope,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, tzinfo=UTC))
def test_organization_account_contact_has_deployment_owner() -> None:
contact = Contact.organization_account(
contact_id=ContactId("contact-1"),
account_id=AccountId("account-1"),
name="Ada",
email="ADA@example.com",
now=_NOW,
)
assert contact.identity_source is ContactIdentitySource.ORGANIZATION_ACCOUNT
assert contact.owner == OrganizationAccountOwner(AccountId("account-1"))
assert contact.normalized_email is not None
assert contact.normalized_email.value == "ada@example.com"
def test_workspace_member_contact_has_workspace_and_account_owner() -> None:
contact = Contact.workspace_member(
contact_id=ContactId("contact-1"),
workspace_id=WorkspaceId("workspace-1"),
account_id=AccountId("account-1"),
name="Ada",
email=None,
now=_NOW,
)
assert contact.identity_source is ContactIdentitySource.WORKSPACE_MEMBER
assert contact.owner == WorkspaceMemberOwner(WorkspaceId("workspace-1"), AccountId("account-1"))
def test_external_contact_requires_workspace_and_email() -> None:
contact = Contact.external(
contact_id=ContactId("contact-1"),
workspace_id=WorkspaceId("workspace-1"),
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
assert contact.identity_source is ContactIdentitySource.EXTERNAL
assert contact.owner == ExternalContactOwner(WorkspaceId("workspace-1"))
@pytest.mark.parametrize(
("identity_source", "owner"),
[
(ContactIdentitySource.ORGANIZATION_ACCOUNT, ExternalContactOwner(WorkspaceId("workspace-1"))),
(ContactIdentitySource.WORKSPACE_MEMBER, OrganizationAccountOwner(AccountId("account-1"))),
(
ContactIdentitySource.EXTERNAL,
WorkspaceMemberOwner(WorkspaceId("workspace-1"), AccountId("account-1")),
),
],
)
def test_invalid_identity_source_owner_combination_is_rejected(identity_source, owner) -> None:
with pytest.raises(ContactDirectoryError) as error:
Contact.create(
contact_id=ContactId("contact-1"),
identity_source=identity_source,
owner=owner,
name="Ada",
email="ada@example.com",
now=_NOW,
)
assert error.value.code is ContactRejectionCode.INVALID_OWNER
def test_external_contact_without_email_is_rejected() -> None:
with pytest.raises(ContactDirectoryError) as error:
Contact.create(
contact_id=ContactId("contact-1"),
identity_source=ContactIdentitySource.EXTERNAL,
owner=ExternalContactOwner(WorkspaceId("workspace-1")),
name="Reviewer",
email=None,
now=_NOW,
)
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
def test_identity_source_is_immutable_across_profile_changes() -> None:
contact = Contact.organization_account(
contact_id=ContactId("contact-1"),
account_id=AccountId("account-1"),
name="Ada",
email="ada@example.com",
now=_NOW,
)
with pytest.raises(FrozenInstanceError):
contact.identity_source = ContactIdentitySource.EXTERNAL # type: ignore[misc]
with pytest.raises(ContactDirectoryError) as error:
replace(contact, identity_source=ContactIdentitySource.EXTERNAL)
assert error.value.code is ContactRejectionCode.INVALID_OWNER
def test_contact_directory_scope_is_derived_from_immutable_owner() -> None:
organization = Contact.organization_account(
contact_id=ContactId("organization"),
account_id=AccountId("account-1"),
name="Ada",
email=None,
now=_NOW,
)
external = Contact.external(
contact_id=ContactId("external"),
workspace_id=WorkspaceId("workspace-1"),
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
assert organization.directory_scope == DeploymentScope()
assert external.directory_scope == WorkspaceScope(WorkspaceId("workspace-1"))
def test_contact_rejects_blank_name_and_inconsistent_email_values() -> None:
with pytest.raises(ContactDirectoryError) as blank_name:
Contact.organization_account(
contact_id=ContactId("contact-1"),
account_id=AccountId("account-1"),
name=" ",
email=None,
now=_NOW,
)
assert blank_name.value.code is ContactRejectionCode.INVALID_NAME
with pytest.raises(ContactDirectoryError) as invalid_email:
Contact.organization_account(
contact_id=ContactId("contact-1"),
account_id=AccountId("account-1"),
name="Ada",
email="invalid",
now=_NOW,
)
assert invalid_email.value.code is ContactRejectionCode.INVALID_EMAIL
with pytest.raises(ContactDirectoryError) as inconsistent_email:
Contact(
id=ContactId("contact-1"),
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
owner=OrganizationAccountOwner(AccountId("account-1")),
name=" Ada ",
normalized_name="wrong",
email="ada@example.com",
normalized_email=NormalizedEmail("different@example.com"),
avatar_file_id=None,
created_at=_NOW,
updated_at=_NOW,
)
assert inconsistent_email.value.code is ContactRejectionCode.INVALID_EMAIL
def test_contact_rejects_normalized_email_without_deliverable_email() -> None:
with pytest.raises(ContactDirectoryError) as error:
Contact(
id=ContactId("contact-1"),
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
owner=OrganizationAccountOwner(AccountId("account-1")),
name="Ada",
normalized_name="ada",
email=None,
normalized_email=NormalizedEmail("ada@example.com"),
avatar_file_id=None,
created_at=_NOW,
updated_at=_NOW,
)
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
def test_contact_canonicalizes_persisted_name_values() -> None:
contact = Contact(
id=ContactId("contact-1"),
identity_source=ContactIdentitySource.ORGANIZATION_ACCOUNT,
owner=OrganizationAccountOwner(AccountId("account-1")),
name=" Ada ",
normalized_name="stale-name",
email=None,
normalized_email=None,
avatar_file_id=None,
created_at=_NOW,
updated_at=_NOW,
)
assert contact.name == "Ada"
assert contact.normalized_name == "ada"
@@ -0,0 +1,12 @@
"""Tests for stable Contact Directory rejection contracts."""
from core.human_input_v2.contact_directory import ContactDirectoryError, ContactRejection, ContactRejectionCode
def test_contact_rejection_is_transport_neutral_and_stable() -> None:
rejection = ContactRejection(ContactRejectionCode.CONFLICTING_IDENTITY)
error = ContactDirectoryError(rejection)
assert rejection.to_primitive() == {"reason": "conflicting_identity"}
assert error.code is ContactRejectionCode.CONFLICTING_IDENTITY
assert str(error) == "conflicting_identity"
@@ -0,0 +1,184 @@
"""Table-driven tests for Contact Directory resolution and lifecycle policy."""
from datetime import UTC, datetime
import pytest
from core.human_input_v2.contact_directory import (
Contact,
ContactDirectoryError,
ContactDirectoryPolicy,
ContactDirectorySnapshot,
ContactRejectionCode,
ContactResolution,
)
from core.human_input_v2.shared import AccountId, ContactId, UtcTimestamp, WorkspaceId
_NOW = UtcTimestamp(datetime(2026, 7, 25, tzinfo=UTC))
_WORKSPACE_ID = WorkspaceId("workspace-1")
def _organization_contact() -> Contact:
return Contact.organization_account(
contact_id=ContactId("organization-contact"),
account_id=AccountId("account-1"),
name="Ada",
email="ada@example.com",
now=_NOW,
)
def _workspace_member_contact() -> Contact:
return Contact.workspace_member(
contact_id=ContactId("member-contact"),
workspace_id=_WORKSPACE_ID,
account_id=AccountId("account-2"),
name="Grace",
email="grace@example.com",
now=_NOW,
)
@pytest.mark.parametrize(
("contact_factory", "member_accounts", "platform_contacts", "unavailable_accounts", "expected"),
[
(_organization_contact, {AccountId("account-1")}, set(), set(), ContactResolution.WORKSPACE),
(_organization_contact, set(), {ContactId("organization-contact")}, set(), ContactResolution.PLATFORM),
(_organization_contact, set(), set(), set(), ContactResolution.ABSENT),
(
_organization_contact,
{AccountId("account-1")},
{ContactId("organization-contact")},
{AccountId("account-1")},
ContactResolution.ABSENT,
),
(_workspace_member_contact, {AccountId("account-2")}, set(), set(), ContactResolution.WORKSPACE),
(_workspace_member_contact, set(), set(), set(), ContactResolution.ABSENT),
],
)
def test_account_contact_resolution(
contact_factory,
member_accounts: set[AccountId],
platform_contacts: set[ContactId],
unavailable_accounts: set[AccountId],
expected: ContactResolution,
) -> None:
contact = contact_factory()
snapshot = ContactDirectorySnapshot(
workspace_id=_WORKSPACE_ID,
contacts=(contact,),
member_account_ids=frozenset(member_accounts),
platform_contact_ids=frozenset(platform_contacts),
unavailable_account_ids=frozenset(unavailable_accounts),
)
assert ContactDirectoryPolicy.resolve_for_workspace(snapshot, contact.id) is expected
assert contact == contact_factory()
def test_external_contact_resolves_only_in_its_owner_workspace() -> None:
contact = Contact.external(
contact_id=ContactId("external-contact"),
workspace_id=_WORKSPACE_ID,
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
owner_snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(contact,))
other_snapshot = ContactDirectorySnapshot(workspace_id=WorkspaceId("workspace-2"), contacts=(contact,))
assert ContactDirectoryPolicy.resolve_for_workspace(owner_snapshot, contact.id) is ContactResolution.EXTERNAL
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.resolve_for_workspace(other_snapshot, contact.id)
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
def test_missing_contact_resolves_absent() -> None:
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
assert ContactDirectoryPolicy.resolve_for_workspace(snapshot, ContactId("missing")) is ContactResolution.ABSENT
def test_external_admission_rejects_normalized_email_collision() -> None:
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID, contacts=(_organization_contact(),))
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.admit_external(
snapshot,
contact_id=ContactId("external-contact"),
name="Duplicate",
email=" ADA@EXAMPLE.COM ",
now=_NOW,
)
assert error.value.code is ContactRejectionCode.CONFLICTING_IDENTITY
def test_deleted_external_email_can_be_recreated_with_a_new_contact_id() -> None:
empty_after_deletion = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
recreated = ContactDirectoryPolicy.admit_external(
empty_after_deletion,
contact_id=ContactId("new-contact"),
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
assert recreated.id == ContactId("new-contact")
def test_hard_deletion_rejects_cross_organization_contact() -> None:
contact = Contact.external(
contact_id=ContactId("external-contact"),
workspace_id=WorkspaceId("workspace-2"),
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.ensure_external_deletable(contact, _WORKSPACE_ID)
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
def test_hard_deletion_accepts_contact_from_owner_workspace_without_mutation() -> None:
contact = Contact.external(
contact_id=ContactId("external-contact"),
workspace_id=_WORKSPACE_ID,
name="Reviewer",
email="reviewer@example.com",
now=_NOW,
)
ContactDirectoryPolicy.ensure_external_deletable(contact, _WORKSPACE_ID)
assert contact.owner.workspace_id == _WORKSPACE_ID
def test_workspace_member_resolution_rejects_cross_organization_use() -> None:
contact = _workspace_member_contact()
snapshot = ContactDirectorySnapshot(workspace_id=WorkspaceId("workspace-2"), contacts=(contact,))
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.resolve_for_workspace(snapshot, contact.id)
assert error.value.code is ContactRejectionCode.CROSS_ORGANIZATION
def test_external_admission_rejects_invalid_email() -> None:
snapshot = ContactDirectorySnapshot(workspace_id=_WORKSPACE_ID)
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.admit_external(
snapshot,
contact_id=ContactId("external-contact"),
name="Reviewer",
email="invalid",
now=_NOW,
)
assert error.value.code is ContactRejectionCode.INVALID_EMAIL
def test_hard_deletion_rejects_account_backed_contact() -> None:
with pytest.raises(ContactDirectoryError) as error:
ContactDirectoryPolicy.ensure_external_deletable(_organization_contact(), _WORKSPACE_ID)
assert error.value.code is ContactRejectionCode.INVALID_OWNER
@@ -0,0 +1,169 @@
"""Effective IM binding resolution tests."""
from datetime import UTC, datetime
from core.human_input_v2.contact_directory import Contact, ContactSnapshot
from core.human_input_v2.entities import IMBindingScope, IMProvider
from core.human_input_v2.im_integration import (
BindingResolutionKind,
EffectiveBindingResolver,
IMBinding,
IMIdentity,
IntegrationRevisionToken,
ProviderTenantIdentity,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IntegrationId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_INTEGRATION_ID = IntegrationId("integration-1")
_WORKSPACE_ID = WorkspaceId("workspace-1")
def _contact(email: str | None = "reviewer@example.com") -> ContactSnapshot:
return ContactSnapshot(
contact=Contact.organization_account(
contact_id=ContactId("contact-1"),
account_id=AccountId("account-1"),
name="Reviewer",
email=email,
now=_NOW,
),
account_available=True,
)
def _identity(identity_id: str, provider_user_id: str, email: str = "reviewer@example.com") -> IMIdentity:
return IMIdentity.create(
identity_id=IMIdentityId(identity_id),
integration_id=_INTEGRATION_ID,
provider=IMProvider.FEISHU,
provider_user_id=provider_user_id,
display_name="Reviewer",
email=email,
raw_payload={"secret-provider-fact": "must-not-leak"},
last_seen_sync_run_id=None,
last_seen_at=_NOW,
now=_NOW,
)
def _binding(binding_id: str, identity: IMIdentity, scope: IMBindingScope) -> IMBinding:
return IMBinding.create(
binding_id=IMBindingId(binding_id),
integration_id=_INTEGRATION_ID,
scope=scope,
scope_id=str(_WORKSPACE_ID if scope is IMBindingScope.WORKSPACE else _INTEGRATION_ID),
contact_id=ContactId("contact-1"),
identity_id=identity.id,
provider=IMProvider.FEISHU,
bound_by_account_id=None,
now=_NOW,
)
def _resolve(*, identities: tuple[IMIdentity, ...], bindings: tuple[IMBinding, ...]):
return EffectiveBindingResolver.resolve(
integration_revision=IntegrationRevisionToken(_INTEGRATION_ID, 1),
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
workspace_id=_WORKSPACE_ID,
contact=_contact(),
identities=identities,
bindings=bindings,
)
def test_workspace_binding_has_priority_over_organization_binding() -> None:
organization_identity = _identity("identity-org", "org-user")
workspace_identity = _identity("identity-workspace", "workspace-user")
result = _resolve(
identities=(organization_identity, workspace_identity),
bindings=(
_binding("binding-org", organization_identity, IMBindingScope.ORGANIZATION),
_binding("binding-workspace", workspace_identity, IMBindingScope.WORKSPACE),
),
)
assert result.kind is BindingResolutionKind.WORKSPACE_OVERRIDE
assert result.binding is not None
assert result.binding.provider_user_id == "workspace-user"
def test_reset_to_global_is_represented_by_absent_workspace_override() -> None:
organization_identity = _identity("identity-org", "org-user")
result = _resolve(
identities=(organization_identity,),
bindings=(_binding("binding-org", organization_identity, IMBindingScope.ORGANIZATION),),
)
assert result.kind is BindingResolutionKind.ORGANIZATION_BINDING
assert result.binding is not None
assert result.binding.binding_id == IMBindingId("binding-org")
def test_normalized_email_fallback_is_used_when_no_binding_exists() -> None:
identity = _identity("identity-1", "provider-user-1", " REVIEWER@EXAMPLE.COM ")
result = _resolve(identities=(identity,), bindings=())
assert result.kind is BindingResolutionKind.EMAIL_FALLBACK
assert result.binding is not None
assert result.binding.binding_id is None
assert result.binding.account_id == AccountId("account-1")
assert not hasattr(result.binding, "raw_payload")
assert not hasattr(result.binding, "encrypted_credentials")
def test_missing_email_or_matching_identity_returns_not_available() -> None:
without_email = EffectiveBindingResolver.resolve(
integration_revision=IntegrationRevisionToken(_INTEGRATION_ID, 1),
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
workspace_id=_WORKSPACE_ID,
contact=_contact(None),
identities=(),
bindings=(),
)
without_identity = _resolve(identities=(), bindings=())
assert without_email.kind is BindingResolutionKind.NOT_AVAILABLE
assert without_identity.kind is BindingResolutionKind.NOT_AVAILABLE
def test_integration_or_provider_mismatch_returns_stable_rejection_without_binding() -> None:
identity = IMIdentity.create(
identity_id=IMIdentityId("identity-other"),
integration_id=IntegrationId("integration-other"),
provider=IMProvider.SLACK,
provider_user_id="provider-user-1",
display_name="Reviewer",
email="reviewer@example.com",
raw_payload={},
last_seen_sync_run_id=None,
last_seen_at=_NOW,
now=_NOW,
)
binding = IMBinding.create(
binding_id=IMBindingId("binding-other"),
integration_id=_INTEGRATION_ID,
scope=IMBindingScope.WORKSPACE,
scope_id=str(_WORKSPACE_ID),
contact_id=ContactId("contact-1"),
identity_id=identity.id,
provider=IMProvider.FEISHU,
bound_by_account_id=None,
now=_NOW,
)
result = _resolve(identities=(identity,), bindings=(binding,))
assert result.kind is BindingResolutionKind.INVALID_BINDING
assert result.binding is None
@@ -0,0 +1,242 @@
"""Domain tests for IM Integration configuration revision semantics."""
from dataclasses import replace
from datetime import UTC, datetime
import pytest
from core.human_input_v2.entities import IMBindingScope, IMIntegrationStatus, IMProvider
from core.human_input_v2.im_integration import (
ConfigurationTransitionKind,
EncryptedCredentials,
IMBinding,
IMIdentity,
IMIntegration,
IntegrationRevisionToken,
OpaqueProviderPayload,
ProviderTenantIdentity,
StaleRevision,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IntegrationId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_LATER = UtcTimestamp(datetime(2026, 7, 25, 9, tzinfo=UTC))
def _credentials(secret: str) -> EncryptedCredentials:
return EncryptedCredentials.from_mapping({"app_id": "app-1", "encrypted_app_secret": secret})
def _integration() -> IMIntegration:
return IMIntegration.create(
integration_id=IntegrationId("integration-1"),
workspace_id=WorkspaceId("workspace-1"),
provider_tenant=ProviderTenantIdentity(IMProvider.FEISHU, "provider-tenant-1"),
encrypted_credentials=_credentials("ciphertext-1"),
configured_by_account_id=AccountId("account-1"),
callback_url="https://example.com/callback",
now=_NOW,
)
def test_first_creation_owns_complete_revision_token() -> None:
integration = _integration()
assert integration.revision == IntegrationRevisionToken(IntegrationId("integration-1"), 1)
assert integration.status is IMIntegrationStatus.CONFIGURED
assert integration.encrypted_credentials.to_mapping()["encrypted_app_secret"] == "ciphertext-1"
@pytest.mark.parametrize("config_version", [0, -1])
def test_revision_token_requires_positive_version(config_version: int) -> None:
with pytest.raises(ValueError, match="positive"):
IntegrationRevisionToken(IntegrationId("integration-1"), config_version)
def test_opaque_values_reject_empty_or_non_object_payloads() -> None:
with pytest.raises(ValueError, match="must not be empty"):
EncryptedCredentials.from_mapping({})
with pytest.raises(ValueError, match="JSON object"):
EncryptedCredentials("[]").to_mapping()
with pytest.raises(ValueError, match="JSON object"):
OpaqueProviderPayload("[]").to_mapping()
def test_provider_tenant_and_integration_require_valid_persisted_values() -> None:
with pytest.raises(ValueError, match="must not be blank"):
ProviderTenantIdentity(IMProvider.FEISHU, " ")
with pytest.raises(ValueError, match="positive"):
replace(_integration(), config_version=0)
def test_identity_and_binding_reject_invalid_current_state() -> None:
with pytest.raises(ValueError, match="provider user id"):
IMIdentity.create(
identity_id=IMIdentityId("identity-1"),
integration_id=IntegrationId("integration-1"),
provider=IMProvider.FEISHU,
provider_user_id=" ",
display_name=None,
email=None,
raw_payload={},
last_seen_sync_run_id=None,
last_seen_at=None,
now=_NOW,
)
with pytest.raises(ValueError, match="scope id"):
IMBinding.create(
binding_id=IMBindingId("binding-1"),
integration_id=IntegrationId("integration-1"),
scope=IMBindingScope.WORKSPACE,
scope_id=" ",
contact_id=ContactId("contact-1"),
identity_id=IMIdentityId("identity-1"),
provider=IMProvider.FEISHU,
bound_by_account_id=None,
now=_NOW,
)
with pytest.raises(ValueError, match="organization binding scope"):
IMBinding.create(
binding_id=IMBindingId("binding-1"),
integration_id=IntegrationId("integration-1"),
scope=IMBindingScope.ORGANIZATION,
scope_id="integration-other",
contact_id=ContactId("contact-1"),
identity_id=IMIdentityId("identity-1"),
provider=IMProvider.FEISHU,
bound_by_account_id=None,
now=_NOW,
)
def test_confirmed_credential_rotation_advances_once_and_preserves_current_state() -> None:
integration = _integration()
decision = integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=integration.provider_tenant,
encrypted_credentials=_credentials("ciphertext-2"),
configured_by_account_id=AccountId("account-2"),
callback_url="https://example.com/new-callback",
now=_LATER,
)
assert decision.kind is ConfigurationTransitionKind.CREDENTIAL_ROTATION
assert decision.integration.id == integration.id
assert decision.integration.revision.config_version == 2
assert decision.invalidation.invalidate_identities is False
assert decision.invalidation.invalidate_bindings is False
def test_credential_rotation_rejects_replacement_identity() -> None:
integration = _integration()
with pytest.raises(ValueError, match="must preserve integration identity"):
integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=integration.provider_tenant,
encrypted_credentials=_credentials("ciphertext-2"),
configured_by_account_id=None,
callback_url=None,
now=_LATER,
replacement_integration_id=IntegrationId("integration-2"),
)
def test_provider_tenant_replacement_gets_new_identity_and_invalidates_current_state() -> None:
integration = _integration()
decision = integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=ProviderTenantIdentity(IMProvider.SLACK, "provider-tenant-2"),
encrypted_credentials=EncryptedCredentials.from_mapping(
{
"client_id": "client-1",
"encrypted_client_secret": "ciphertext-2",
"encrypted_signing_secret": "ciphertext-3",
"encrypted_bot_token": "ciphertext-4",
}
),
configured_by_account_id=AccountId("account-2"),
callback_url=None,
now=_LATER,
replacement_integration_id=IntegrationId("integration-2"),
)
assert decision.kind is ConfigurationTransitionKind.PROVIDER_REPLACEMENT
assert decision.integration.id == IntegrationId("integration-2")
assert decision.integration.revision.config_version == 1
assert decision.invalidation.invalidate_identities is True
assert decision.invalidation.invalidate_bindings is True
stale = decision.integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=decision.integration.provider_tenant,
encrypted_credentials=decision.integration.encrypted_credentials,
configured_by_account_id=AccountId("account-2"),
callback_url=None,
now=_LATER,
)
assert stale == StaleRevision(expected=integration.revision, actual=decision.integration.revision)
def test_provider_replacement_requires_new_integration_identity() -> None:
integration = _integration()
with pytest.raises(ValueError, match="requires a new integration identity"):
integration.reconfigure(
expected_revision=integration.revision,
provider_tenant=ProviderTenantIdentity(IMProvider.SLACK, "provider-tenant-2"),
encrypted_credentials=EncryptedCredentials.from_mapping(
{
"client_id": "client-1",
"encrypted_client_secret": "ciphertext-2",
"encrypted_signing_secret": "ciphertext-3",
"encrypted_bot_token": "ciphertext-4",
}
),
configured_by_account_id=None,
callback_url=None,
now=_LATER,
)
def test_stale_update_and_delete_return_stable_results() -> None:
integration = _integration()
stale_token = IntegrationRevisionToken(integration.id, 9)
update = integration.reconfigure(
expected_revision=stale_token,
provider_tenant=integration.provider_tenant,
encrypted_credentials=_credentials("ciphertext-2"),
configured_by_account_id=AccountId("account-2"),
callback_url=None,
now=_LATER,
)
deletion = integration.plan_deletion(stale_token)
expected = StaleRevision(expected=stale_token, actual=integration.revision)
assert update == expected
assert deletion == expected
def test_connectivity_diagnostics_do_not_advance_configuration_revision() -> None:
integration = _integration()
updated = integration.record_diagnostics(
status=IMIntegrationStatus.CONNECTED,
safe_status_reason="Connection verified",
checked_at=_LATER,
)
assert updated.revision == integration.revision
assert updated.status is IMIntegrationStatus.CONNECTED
assert updated.last_checked_at == _LATER
@@ -0,0 +1,21 @@
"""Shape tests for transaction-oriented IM persistence ports."""
from core.human_input_v2.im_integration import IMControlPlaneRepository
def test_repository_port_is_transaction_oriented_instead_of_table_crud() -> None:
public_methods = {
name for name, value in vars(IMControlPlaneRepository).items() if callable(value) and not name.startswith("_")
}
assert {
"create_integration",
"compare_and_swap_configuration",
"compare_and_swap_delete",
"create_or_get_active_run",
"load_reconciliation_snapshot",
"apply_reconciliation",
"resolve_effective_binding",
"append_sync_results",
} <= public_methods
assert not {"create", "read", "update", "delete"} & public_methods
@@ -0,0 +1,256 @@
"""Pure reconciliation tests for the IM Control Plane."""
from datetime import UTC, datetime
from core.human_input_v2.contact_directory import Contact, ContactSnapshot
from core.human_input_v2.entities import IMBindingScope, IMProvider, IMSyncRunStatus
from core.human_input_v2.im_integration import (
IMBinding,
IMIdentity,
IMSyncRun,
IntegrationRevisionToken,
MatchKind,
ProviderDirectoryEntry,
ReconciliationSnapshot,
SyncReconciler,
)
from core.human_input_v2.shared import (
AccountId,
ContactId,
IMBindingId,
IMIdentityId,
IMSyncRunId,
IntegrationId,
UtcTimestamp,
WorkspaceId,
)
_NOW = UtcTimestamp(datetime(2026, 7, 25, 8, tzinfo=UTC))
_REVISION = IntegrationRevisionToken(IntegrationId("integration-1"), 3)
def _contact(contact_id: str, account_id: str, email: str, *, available: bool = True) -> ContactSnapshot:
return ContactSnapshot(
contact=Contact.organization_account(
contact_id=ContactId(contact_id),
account_id=AccountId(account_id),
name=account_id,
email=email,
now=_NOW,
),
account_available=available,
)
def test_sync_run_captures_complete_integration_revision() -> None:
run = IMSyncRun.create(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
started_by_account_id=AccountId("account-1"),
now=_NOW,
)
assert run.integration_revision == _REVISION
assert run.status is IMSyncRunStatus.QUEUED
assert run.is_active is True
def test_sync_run_start_is_idempotent_after_leaving_queued_state() -> None:
run = IMSyncRun.create(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
started_by_account_id=None,
now=_NOW,
)
started = run.start(_NOW)
assert started.status is IMSyncRunStatus.RUNNING
assert started.start(_NOW) is started
def test_reconciler_prefers_provider_user_id_before_conflicting_email() -> None:
first_contact = _contact("contact-1", "account-1", "first@example.com")
other_contact = _contact("contact-2", "account-2", "other@example.com")
identity = IMIdentity.create(
identity_id=IMIdentityId("identity-1"),
integration_id=_REVISION.integration_id,
provider=IMProvider.FEISHU,
provider_user_id="provider-user-1",
display_name="Old Name",
email="first@example.com",
raw_payload={"source": "previous"},
last_seen_sync_run_id=None,
last_seen_at=_NOW,
now=_NOW,
)
binding = IMBinding.create(
binding_id=IMBindingId("binding-1"),
integration_id=_REVISION.integration_id,
scope=IMBindingScope.ORGANIZATION,
scope_id=str(_REVISION.integration_id),
contact_id=first_contact.contact.id,
identity_id=identity.id,
provider=IMProvider.FEISHU,
bound_by_account_id=None,
now=_NOW,
)
entry = ProviderDirectoryEntry.create(
provider_user_id="provider-user-1",
display_name="Current Name",
email=" OTHER@EXAMPLE.COM ",
raw_payload={"source": "provider"},
)
snapshot = ReconciliationSnapshot(
identities=(identity,),
bindings=(binding,),
contacts=(first_contact, other_contact),
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=(entry,),
snapshot=snapshot,
)
assert len(plan.actions) == 1
assert plan.actions[0].match_kind is MatchKind.PROVIDER_USER_ID
assert plan.actions[0].identity_id == identity.id
assert plan.actions[0].contact_id == first_contact.contact.id
assert plan.removed_identity_ids == ()
def test_reconciler_uses_normalized_email_fallback_without_creating_contacts() -> None:
contact = _contact("contact-1", "account-1", "reviewer@example.com")
entry = ProviderDirectoryEntry.create(
provider_user_id="provider-user-1",
display_name="Reviewer",
email=" REVIEWER@EXAMPLE.COM ",
raw_payload={},
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=(entry,),
snapshot=ReconciliationSnapshot(contacts=(contact,)),
)
assert plan.actions[0].match_kind is MatchKind.NORMALIZED_EMAIL
assert plan.actions[0].contact_id == contact.contact.id
assert plan.actions[0].identity_id is None
assert not hasattr(plan, "contacts_to_create")
def test_reconciler_uses_workspace_member_contact_for_email_fallback() -> None:
workspace_member = ContactSnapshot(
contact=Contact.workspace_member(
contact_id=ContactId("contact-workspace-member"),
workspace_id=WorkspaceId("workspace-1"),
account_id=AccountId("account-1"),
name="Workspace Reviewer",
email="reviewer@example.com",
now=_NOW,
),
account_available=True,
)
entry = ProviderDirectoryEntry.create(
provider_user_id="provider-user-1",
display_name="Reviewer",
email=" REVIEWER@EXAMPLE.COM ",
raw_payload={},
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=(entry,),
snapshot=ReconciliationSnapshot(contacts=(workspace_member,)),
)
assert plan.actions[0].match_kind is MatchKind.NORMALIZED_EMAIL
assert plan.actions[0].contact_id == workspace_member.contact.id
def test_reconciler_does_not_use_external_contact_for_email_fallback() -> None:
external_contact = ContactSnapshot(
contact=Contact.external(
contact_id=ContactId("contact-external"),
workspace_id=WorkspaceId("workspace-1"),
name="External Reviewer",
email="reviewer@example.com",
now=_NOW,
),
account_available=True,
)
entry = ProviderDirectoryEntry.create(
provider_user_id="provider-user-1",
display_name="Reviewer",
email="reviewer@example.com",
raw_payload={},
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=(entry,),
snapshot=ReconciliationSnapshot(contacts=(external_contact,)),
)
assert plan.actions[0].match_kind is MatchKind.UNMATCHED
assert plan.actions[0].contact_id is None
def test_reconciler_returns_unmatched_for_missing_or_ineligible_contact() -> None:
unavailable = _contact("contact-1", "account-1", "reviewer@example.com", available=False)
entries = (
ProviderDirectoryEntry.create(
provider_user_id="provider-user-1", display_name="Reviewer", email="reviewer@example.com", raw_payload={}
),
ProviderDirectoryEntry.create(
provider_user_id="provider-user-2", display_name="No Email", email=None, raw_payload={}
),
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=entries,
snapshot=ReconciliationSnapshot(contacts=(unavailable,)),
)
assert [action.match_kind for action in plan.actions] == [MatchKind.UNMATCHED, MatchKind.UNMATCHED]
assert all(action.contact_id is None for action in plan.actions)
def test_reconciler_marks_missing_current_identities_for_removal() -> None:
identity = IMIdentity.create(
identity_id=IMIdentityId("identity-1"),
integration_id=_REVISION.integration_id,
provider=IMProvider.FEISHU,
provider_user_id="missing-user",
display_name=None,
email=None,
raw_payload={},
last_seen_sync_run_id=None,
last_seen_at=_NOW,
now=_NOW,
)
plan = SyncReconciler.reconcile(
sync_run_id=IMSyncRunId("run-1"),
integration_revision=_REVISION,
provider=IMProvider.FEISHU,
entries=(),
snapshot=ReconciliationSnapshot(identities=(identity,)),
)
assert plan.removed_identity_ids == (identity.id,)
@@ -0,0 +1,79 @@
"""Architecture tests for the infrastructure-free Human Input v2 domain."""
import ast
import os
import subprocess
import sys
from pathlib import Path
from core.human_input_v2.entities import ContactId as LegacyContactId
from core.human_input_v2.shared import ContactId
_DOMAIN_ROOT = Path(__file__).resolve().parents[4] / "core/human_input_v2"
_FORBIDDEN_PREFIXES = (
"flask",
"controllers",
"models",
"sqlalchemy",
"core.model_runtime",
"core.plugin",
"dify_plugin",
)
def test_domain_packages_have_no_transport_or_persistence_imports() -> None:
violations: list[str] = []
domain_files = sorted((_DOMAIN_ROOT / "contact_directory").glob("*.py"))
domain_files.extend(sorted((_DOMAIN_ROOT / "im_integration").glob("*.py")))
approval_files = sorted((_DOMAIN_ROOT / "approval").glob("*.py"))
domain_files.extend(approval_files)
assert approval_files, "the approval domain package must exist"
for path in domain_files:
tree = ast.parse(path.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.Import):
imported_modules = [alias.name for alias in node.names]
elif isinstance(node, ast.ImportFrom) and node.module is not None:
imported_modules = [node.module]
else:
continue
for imported_module in imported_modules:
if imported_module.startswith(_FORBIDDEN_PREFIXES):
violations.append(f"{path.name}: {imported_module}")
assert violations == []
def test_approval_package_import_does_not_load_transport_persistence_or_provider_clients() -> None:
script = f"""
import sys
import core.human_input_v2.approval
forbidden_prefixes = {_FORBIDDEN_PREFIXES!r}
violations = sorted(
module_name
for module_name in sys.modules
if any(
module_name == prefix or module_name.startswith(f"{{prefix}}.")
for prefix in forbidden_prefixes
)
)
if violations:
raise SystemExit(f"forbidden modules loaded: {{violations}}")
"""
completed = subprocess.run(
[sys.executable, "-c", script],
cwd=_DOMAIN_ROOT.parents[1],
check=False,
capture_output=True,
env={**os.environ, "PYTHONPATH": str(_DOMAIN_ROOT.parents[1])},
text=True,
)
assert completed.returncode == 0, completed.stderr
def test_legacy_contact_id_remains_a_string_shaped_transport_identifier() -> None:
assert LegacyContactId("contact-1") == "contact-1"
assert ContactId("contact-1").to_primitive() == "contact-1"
@@ -0,0 +1,75 @@
"""Tests for values shared by Human Input v2 bounded contexts."""
import json
from datetime import UTC, datetime, timedelta, timezone
import pytest
from core.human_input_v2.shared import (
AccountId,
ContactId,
DeploymentScope,
EndUserId,
NormalizedEmail,
UtcTimestamp,
WorkspaceId,
WorkspaceScope,
)
@pytest.mark.parametrize("identifier_type", [AccountId, ContactId, EndUserId, WorkspaceId])
@pytest.mark.parametrize("invalid_value", ["", " ", "\n"])
def test_typed_ids_reject_blank_values(identifier_type, invalid_value: str) -> None:
with pytest.raises(ValueError, match="must not be blank"):
identifier_type(invalid_value)
def test_typed_ids_are_equal_only_within_the_same_id_type() -> None:
assert ContactId("same") == ContactId("same")
assert ContactId("same") != AccountId("same")
assert ContactId("same").to_primitive() == "same"
@pytest.mark.parametrize(
("raw", "normalized"),
[
(" USER@Example.COM ", "user@example.com"),
("StraSSE@EXAMPLE.COM", "strasse@example.com"),
],
)
def test_normalized_email_canonicalizes_equality(raw: str, normalized: str) -> None:
assert NormalizedEmail(raw) == NormalizedEmail(normalized)
assert NormalizedEmail(raw).to_primitive() == normalized
@pytest.mark.parametrize("invalid_email", ["", "plain-text", "@example.com", "user@", "a b@example.com"])
def test_normalized_email_rejects_invalid_values(invalid_email: str) -> None:
with pytest.raises(ValueError, match="valid email"):
NormalizedEmail(invalid_email)
def test_owner_scopes_are_explicit_and_immutable() -> None:
workspace_id = WorkspaceId("workspace-1")
assert DeploymentScope().to_primitive() == {"kind": "deployment"}
assert WorkspaceScope(workspace_id).to_primitive() == {
"kind": "workspace",
"workspace_id": "workspace-1",
}
with pytest.raises(AttributeError):
WorkspaceScope(workspace_id).workspace_id = WorkspaceId("workspace-2") # type: ignore[misc]
def test_utc_timestamp_normalizes_and_serializes_at_the_boundary() -> None:
source = datetime(2026, 7, 25, 10, 30, tzinfo=timezone(timedelta(hours=8)))
timestamp = UtcTimestamp(source)
assert timestamp.value == datetime(2026, 7, 25, 2, 30, tzinfo=UTC)
assert timestamp.to_primitive() == "2026-07-25T02:30:00Z"
with pytest.raises(TypeError):
json.dumps(timestamp)
def test_utc_timestamp_rejects_naive_datetime() -> None:
with pytest.raises(ValueError, match="timezone-aware"):
UtcTimestamp(datetime(2026, 7, 25, 2, 30))

Some files were not shown because too many files have changed in this diff Show More