## Summary
Splits AI agent tool resolution into two independent rails:
- **Native tools** — capabilities baked into the model SDK
(Anthropic/OpenAI `web_search`, xAI `web`/`x` provider options). Bound
by `NativeToolBinderService`, controlled by per-agent
`modelConfiguration` toggles. Opaque to Twenty — executed on the model
provider's servers.
- **Action tools** — registry-scoped tools from `ToolRegistryService`
(code interpreter, send email, record CRUD, etc.). Permission-gated via
the agent's role. Executed on Twenty's server.
Both rails merge into a single `ToolSet` at call time. When both
surfaces expose a search tool the model picks at runtime — coexistence
is intentional (relevant once Exa returns as an action, see below).
## Notable changes worth calling out
**Contract change: `AgentAsyncExecutorService.executeAgent` no longer
accepts `rolePermissionConfig`.** Workflow agents now scope exclusively
by the agent's own permission-tab role (`unionOf: [agentRoleId]`). The
previous role-merging path (caller role intersected with agent role) is
removed. No agent role → no registry tools (fail-closed by design).
**`NativeToolBinderService` relocated** from
`core-modules/tool-provider/native/` →
`metadata-modules/ai/ai-models/services/`. The binder needs SDK-package
knowledge, which lives in `ai-models`. Old location created a backwards
module dependency.
**`NATIVE_MODEL_TOOLS_BY_SDK_PACKAGE` is exhaustive over
`AiSdkPackage`** (`Record<>`, not `Partial<Record<>>`). Adding a new SDK
without thinking about native tools now fails the build. SDKs without
native tools (Bedrock, Google, Mistral, Azure, OpenAI-compatible) get
explicit `{}` entries.
**Discriminated union `kind: 'sdk-tool' | 'provider-option'`** lets one
registry describe both function tools (Anthropic/OpenAI) and runtime
sources (xAI). Follows the local `tool-provider` convention from #19321.
## Deferred to follow-ups
- **Exa web search is dropped from this PR** (along with its
`WEB_SEARCH_TOOL` permission flag and the Exa-specific gating). Exa
comes back as an **action/app tool** once apps can define permission
flags through the SDK — ongoing work in #20481.
- **xAI native search currently errors.** xAI deprecated its Live Search
API (the `web`/`x` provider-option sources this rail maps to), so xAI
returns `410` when native search is actually exercised. The code path
itself is clear — it's only hit if you test xAI native tools. Fixed
separately alongside the broader xAI model fixes.
## Conscious non-decisions
- **No "twenty-native" category.** `native` is reserved for
model/provider SDK features; everything Twenty-owned is just a
tool/action.
- **Coexistence over precedence.** No rule forcing an action search tool
to override native search (or vice-versa) — when both exist, it's the
user's choice in workflow agents and the model's choice in chat.
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: Félix Malfait <felix@twenty.com>
## Summary
Three related fixes to the auto-creation of People records from calendar
events and email messages, all centred on the data-quality problem of
contacts being created with missing or malformed names.
### 1. Enrich names on existing contacts (commit 1)
Previously: when an email or calendar import matched an existing Person
by email, the existing record was left untouched — even if the new
source carried a better name.
This is the root cause of contacts like `"Félix"` (no last name)
sticking around forever: the `To:`/`Cc:` headers of outbound emails
rarely include a display name, and Google Calendar only returns
`displayName` for attendees already in the organizer's address book. So
the first sighting often creates a Person as `{firstName: "felix",
lastName: ""}`, and a later inbound `From: "Félix Malfait"
<felix@twenty.com>` — which would have produced the right name — gets
silently dropped because the Person already exists.
The new `computePeopleToEnrichNames` bucket and
`CreatePersonService.enrichPeopleNames` method fill in missing
`firstName`/`lastName` fields from the new parsed name, with
conservative rules:
- Only enrich when the existing Person's `createdBy.source` is
`CALENDAR` or `EMAIL` — `MANUAL`, `IMPORT`, `API`, `WORKFLOW`, etc. are
never touched.
- Only fill empty fields. Non-empty `firstName`/`lastName` are never
overwritten.
- Soft-deleted contacts continue to be handled by the existing restore
path.
### 2. Handle multi-comma "Last, First, Suffix" display names (commit 2)
The comma-inverted swap in the parser previously required *exactly* one
comma. Names like `"Smith, Jane, Jr."`, `"O'Brien, Mary, MD"` or `"Doe,
John, Patrick"` fell through to the space-split fallback, which stored
the comma in `firstName` (e.g. `"Smith,"`) and produced garbled records
(the avatar shows a single "B" and the name reads `"Barbey, Julien"`
because the entire string lives in `firstName`).
The regex now splits on the first comma and treats the remainder as the
first name, collapsing any further commas to spaces. Single-comma
behaviour is unchanged.
### 3. Perf: skip the parser when an existing record is already
populated (commit 3)
`computePeopleToEnrichNames` runs on every cron-driven email/calendar
import batch. The first version called the display-name parser for every
matched existing person, even when both `firstName` and `lastName` were
already set — i.e. the steady-state case after the initial enrichment
pass.
Reordered so the cheap "both fields populated" check short-circuits
before any parsing happens. Same behaviour, fewer parser calls on the
hot path.
## Test plan
- [x] 8 new unit tests for the enrichment bucket: empty `lastName`
enrichment, both `EMAIL` and `CALENDAR` sources, non-overwrite of
non-empty fields, skip on `MANUAL`/`IMPORT`, skip when the new source
also has no last name, skip for soft-deleted, fill `firstName` while
preserving `lastName`, handle null `name` field
- [x] 5 new parser tests for multi-comma forms: `"Last, First, Suffix"`,
credential suffixes (`MD`), three-token forms, whitespace around inner
commas, `:GROUP` tag interaction
- [x] 1 new parser test on the single-comma path covering multi-word
first names (`"Smith, Mary Jane"`)
- [x] All 15 existing parser tests still pass
- [x] All 116 tests in `contact-creation-manager` pass
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` + `npx oxfmt --check` on changed files
- [ ] Manual: trigger a fresh contact creation from an outbound email
with no display name, then a subsequent inbound email from the same
address with a full display name, and confirm the Person's last name
gets populated
## Summary
Follow-up to #20953. Migrates 23 of the 30 entities that were left in
`WORKSPACE_SCOPED_EXEMPTIONS` last time, so the lint rule's
workspaceId-enforcement default now covers most of the core/metadata
schema.
### Migrated (23 entities, 88 files, 22 commits)
| Family | Entities |
|---|---|
| Trivial caches | `NavigationMenuItem`, `Skill`, `DataSource`,
`Webhook`, `CommandMenuItem`, `IndexMetadata` |
| Views | `View`, `ViewField`, `ViewFieldGroup`, `ViewFilter`,
`ViewFilterGroup`, `ViewGroup`, `ViewSort` |
| Layouts | `PageLayout`, `PageLayoutTab`, `PageLayoutWidget` |
| Roles & permissions | `Role`, `RoleTarget`, `PermissionFlag`,
`ObjectPermission`, `FieldPermission`, `RowLevelPermissionPredicate`,
`RowLevelPermissionPredicateGroup` |
For each entity: swap `@InjectRepository(X)` →
`@InjectWorkspaceScopedRepository(X)` (and the field type →
`WorkspaceScopedRepository<X>`); rewrite every call site to pass
`workspaceId` as the first arg (stripped from `where`/criteria — the
wrapper throws if you include it now); register
`provideWorkspaceScopedRepository(X)` in every owning NestJS module;
update affected spec providers to
`getWorkspaceScopedRepositoryToken(X)`.
### Rule update
- `ApplicationRegistrationVariableEntity` was misclassified — moved to
`STRUCTURAL_EXEMPTIONS` (no `workspaceId` column; it's keyed on
`applicationRegistrationId` at the instance level).
- 22 of the 23 migrated entities removed from
`WORKSPACE_SCOPED_EXEMPTIONS` entirely (zero remaining raw
`@InjectRepository` sites).
- `RoleTargetEntity` also removed; one call site in
`user-workspace.service.ts` keeps a raw injection with an
`eslint-disable` + reason because `softRemove(...)` is not on the
wrapper API yet (the migration would require threading `workspaceId`
through `deleteUserWorkspace`'s three callers).
### Still exempted (7 entities, follow-up PRs)
| Entity | Why deferred |
|---|---|
| `ApplicationEntity` | ~50 sites with several cross-workspace lookups
by id (auth, OAuth, file-storage, cleanup) |
| `CalendarChannelEntity` / `MessageChannelEntity` | Use
`.increment(...)` (not on wrapper) and
`repository.manager.transaction(...)` — wrapper needs to grow
`.increment` + the transaction sites need `withManager` or dual-inject |
| `FieldMetadataEntity` / `ObjectMetadataEntity` | The metadata services
`extends TypeOrmQueryService<X>` and `super(rawRepo)` — requires
dual-inject or reworking the inheritance |
| `KeyValuePairEntity` | Allows `workspaceId: IsNull()` for
instance-level config; wrapper rejects null |
| `UpgradeMigrationEntity` | Same — instance-level + cross-workspace
ledger |
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — clean (0/0)
- [x] All 10 affected unit specs pass (115 tests) — api-key, agent-role,
permissions, workspace-roles-permissions-cache, view-filter-group,
workflow-version-step-operations, two-factor-authentication (service +
resolver), user-workspace, file
- [ ] Server integration tests in CI
## Summary
Closes#20662.
Two AI permission flags exist:
- **`AI`** (label "Ask AI") — user-facing: chat with AI agents, use AI
features
- **`AI_SETTINGS`** (label "AI") — admin: create and configure AI agents
After auditing every use of these flags I found:
### Frontend — chat UI gated by the admin permission (user-facing bug
from the issue)
A user granted only `Ask AI` could not see chat tabs, the "new chat"
button (desktop & mobile), or the chat content pane; thread
initialization was also skipped, leaving the chat in a half-initialized
state and producing intermittent `THREAD_NOT_FOUND` errors. Switched
these to `AI`:
- `MainNavigationDrawerTabsRow.tsx`
- `MainNavigationDrawer.tsx`
- `MobileNavigationBar.tsx`
- `AgentChatThreadInitializationEffect.tsx`
### Backend — admin-only resolvers gated by the user permission
(privilege escalation)
Two resolvers had a class-level guard of `AI`, letting any user with the
user-facing flag reach admin endpoints (skill CRUD, eval runs). Switched
the class-level guards to `AI_SETTINGS`:
- `SkillResolver` — create/update/delete/activate/deactivate skills
- `AgentTurnResolver` — read turns, run/grade evaluations
### Left as-is (already correct)
- `AgentResolver` — class-level `AI` for reads (workflow editors and
admin pages both need them), mutation-level `AI_SETTINGS` overrides for
writes
- `AgentChatResolver` & `AgentChatSubscriptionResolver` — already `AI`
- `AiGenerateTextController` — already `AI`
- Workspace AI config fields in `workspace.service.ts` — already
`AI_SETTINGS`
## Test plan
- [ ] As a user with `Ask AI` only (no `AI_SETTINGS`): chat tabs, "new
chat" button, and chat history pane are visible on desktop + mobile;
sending a message works; no `THREAD_NOT_FOUND` errors
- [ ] As a user with `AI_SETTINGS` but no `Ask AI`: chat UI is hidden
- [ ] As a user with `Ask AI` only: calling `skills` / `createSkill` /
`agentTurns` / `runEvaluationInput` via GraphQL returns permission
denied
- [ ] As an admin (`AI_SETTINGS`): skill settings and agent eval pages
still work
## Summary
closes https://github.com/twentyhq/core-team-issues/issues/2464
Introduces compile-time branded types to distinguish encrypted
ciphertext from plaintext strings, preventing mix-ups like the one fixed
in #20819 — but at the type level rather in addition to the one existing
at runtime.
### Branded string primitives
- Created `EncryptedString` and `PlaintextString` as hard nominal brands
using `z.string().brand(...)`, making them non-assignable to each other
or to raw `string`
- Created `isEncryptedString` type predicate to narrow `string` to
`EncryptedString` based on the `enc:v2:` envelope prefix
- Retyped `SecretEncryptionService`: `encryptVersioned` accepts
`PlaintextString`, `decryptVersioned` returns `PlaintextString`
### Entity typing
- Typed encrypted columns across entities:
`SigningKeyEntity.privateKey`,
`TwoFactorAuthenticationMethodEntity.secret`,
`ApplicationRegistrationVariableEntity.encryptedValue`,
`ApplicationVariableEntity.value`
- Parameterized JSONB types for connected account connection parameters
(`ImapSmtpCaldavParams<Pwd>`) with reusable aliases
`EncryptedImapSmtpCaldavParams` / `DecryptedImapSmtpCaldavParams`
- Typed DTOs (`CreateApplicationRegistrationVariableInput`,
`UpdateApplicationRegistrationVariablePayload`,
`UpdateApplicationVariableEntityInput`) with `PlaintextString`
### ApplicationVariable always-encrypt uniformization
- Retyped `ApplicationVariableEntity.value` to `EncryptedString | ''` —
all values are now encrypted regardless of `isSecret`
- Updated `ApplicationVariableEntityService` to always encrypt on write
and always decrypt on read
- Simplified `UpdateApplicationVariableActionHandlerService` by removing
conditional encrypt/decrypt-on-isSecret-toggle logic
- Added slow instance command (`2.9.0`) to backfill-encrypt existing
`isSecret=false` plaintext rows and tighten the `CHECK` constraint
### ConfigStorageService refactor
- Split `convertAndSecureValue` (which used `any`) into two well-typed
methods: `convertAndDecrypt` and `convertAndEncrypt`
- Introduced `isSensitiveStringValue` type predicate to narrow values
before encryption/decryption
### What's next
- Typeorm entity derivation to strictly type sitemap configuration as
code + handler logic for encryption rotation
- https://github.com/twentyhq/core-team-issues/issues/2465
Fixes https://github.com/twentyhq/twenty/issues/20225
The "Me" filter (current workspace member) worked in view filters but
not in dashboard widget filters — the server never resolved the
placeholder, and the widget side-panel UI had no "Me" option and only
allowed single selection.
Backend: `ChartDataQueryService` now forwards the current workspace
member id (from authContext) into filterValueDependencies, so the shared
filter logic resolves "Me" the same way it does for view filters. Added
unit tests for the converter.
Frontend: new multi-select picker for workspace member filters in the
widget side panel, mirroring the view filter's actor select: search
input, "Me" pinned item, and a multi-select workspace member list.
## Before
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 16
36@2x"
src="https://github.com/user-attachments/assets/b2cff46c-53e5-4e8a-a463-b106daf96c8c"
/>
## After
<img width="3024" height="1488" alt="CleanShot 2026-05-27 at 17 14
05@2x"
src="https://github.com/user-attachments/assets/8b3b5f11-44b9-4ae5-a2f3-9c7a689f4bb2"
/>
## Summary
- Fixes a bug where `noteTarget` (and any other morph-relation join
object) created via AI/MCP would land with `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` left null, even though the tool
reported success.
- Root cause: the Zod schema generators for the AI tools only branched
on `FieldMetadataType.RELATION`. MORPH_RELATION fields fell through to
the default case — for `create_*` they were exposed as `targetCompany:
string` instead of `targetCompanyId: uuid`, and for `group_by_*` they
were silently skipped entirely. Downstream
(`data-arg-processor.service.ts` and the group-by arg processor) already
accept the join-column form for both kinds of relations via
`computeMorphOrRelationFieldJoinColumnName` and
`isMorphOrRelationFlatFieldMetadata`, so the fix is purely in the schema
generators.
## Changes
- `record-properties.zod-schema.ts` — extend the existing RELATION
MANY_TO_ONE / ONE_TO_MANY branches to also match MORPH_RELATION.
- `group-by-tool.zod-schema.ts` — replace the silent MORPH_RELATION skip
with the same treatment as RELATION MANY_TO_ONE (exposes `${name}Id` as
a groupBy option).
- `test/integration/ai/suites/mcp-tool-execution.integration-spec.ts` —
new file. First integration test for tool execution end-to-end. Drives
the real MCP JSON-RPC endpoint with the seeded API key (`learn_tools`
for schema introspection, `execute_tool` for invocation):
- asserts `create_note_target`'s schema exposes `targetCompanyId` /
`targetPersonId` / `targetOpportunityId` as UUIDs and does **not**
expose `targetCompany` / `targetPerson` / `targetOpportunity`.
- creates a company + note + noteTarget via MCP, then queries the
workspace schema to confirm `targetCompanyId` is actually persisted in
the FK column.
- asserts `group_by_note_targets` schema accepts `targetCompanyId` as a
groupBy key.
- sets up 3 noteTargets (2 → company A, 1 → company B), calls
`group_by_note_targets` by `targetCompanyId`, and asserts the counts.
Out of scope: `record-filter.zod-schema.ts` has the same pattern (only
RELATION) — left for a follow-up so this PR stays focused on what was
reported.
## Test plan
- [x] `npx nx typecheck twenty-server`
- [x] `npx oxlint --type-aware` on changed files — clean
- [x] `npx oxfmt --check` on changed files — clean
- [x] Integration tests pass (4/4) after `database:reset`:
- `should expose the morph-relation join columns as \`${name}Id\` UUID
parameters`
- `should persist targetCompanyId when create_note_target is invoked via
MCP`
- `should expose targetCompanyId as a valid groupBy option`
- `should group noteTargets by targetCompanyId via MCP`
This pull request unifies outbound with inbound under the new feature
and the new email groups feature.
These are workspace level shared inboxes that are shared between all
workspace members.
outbound sending with SES works, we only listen for tenant status
events, rest is managed by AWS
PR refactors old code and webhook to be split for outbound and inbound
for proper separation
| Area | Change |
|---|---|
| AWS SES driver | Split into `AwsSesRegisterDomainService` (tenant +
identity + DKIM + MAIL FROM + configuration-set + EventBridge dest +
contact list) and `AwsSesSendEmailService` (SendEmail). |
| Reputation webhook | New `/webhooks/messaging/ses/outbound` route. SES
→ EventBridge (`Sending Status Enabled/Disabled` on default bus) → SNS →
router → `SesOutboundSendingStateHandlerService` updates
`emailing_domain.tenantStatus`. |
| Inbound webhook | Refactored into `SesInboundWebhookRouterService` +
`SesInboundMailHandlerService`. Shared `SnsSignatureVerifierService` +
`SnsSubscriptionConfirmerService` across both routes. |
| Global uniqueness | New migration + instance command:
`emailing_domain.domain` is now globally unique (one tenant per domain
across workspaces). |
| Tenant status | New `emailing_domain.tenantStatus` column (`ACTIVE` /
`PAUSED`) + `EmailingDomainTenantStatusService`. |
| Send-email mutation | New `sendEmailViaDomain` GraphQL mutation +
DTOs. |
| Cleanup | `EmailingDomainWorkspaceCleanupJob` wired into
`WorkspaceService.deleteWorkspace` — tears down SES tenant association +
identity on workspace delete. |
| Settings UI | Rewritten around reusable `SettingsTableListSection`.
"Email Group" → "Email Handle" rename. New cells for
status/source/forwarding. Outbound domains surfaced on workspace
settings page. |
### Env vars (new)
All in `config-variables.ts`, group `AWS_SES_SETTINGS`, all optional:
- `AWS_SES_REGION` — `@IsAWSRegion`, consumed by `AwsSesClientProvider`
+ driver factory
- `AWS_SES_ACCOUNT_ID` — used for ARN construction in driver factory
- `SES_SNS_TOPIC_ARN_ALLOWLIST` — **shared** by inbound + outbound
webhook routers, comma-separated list of accepted SNS topic ARNs
(verified via `sns-payload-validator`)
### Migrations
- `1778862608620-add-emailing-domain-tenant-status` (fast) — adds
`tenantStatus` column.
- `1778865501791-unique-emailing-domain-globally` (slow, idempotent) —
enforces global uniqueness on `domain`.
- Instance commands bumped to `2.5`.
### Infra dependency
Two coupled twenty-infra PRs:
- `ses-inbound-email` — receipt-rule + inbound SNS topic + S3 bucket
policy + KMS grant + `email_group_*` outputs.
- `ses-outbound-tf` — EventBridge rule + outbound SNS topic + SES IAM
policy + outbound `webhook_url` subscription. **Based on
`ses-inbound-email`.**
Merge order: inbound first, then outbound. Outbound PR's chart edit owns
the comma-joined `SES_SNS_TOPIC_ARN_ALLOWLIST` value (both ARNs).
Features lives under `/settings/general`
<img width="1496" height="845" alt="SCR-20260519-ofhi-2"
src="https://github.com/user-attachments/assets/a025485a-09f7-4131-91cd-0067690ff18d"
/>
---------
Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Co-authored-by: Félix Malfait <FelixMalfait@users.noreply.github.com>
## Summary
Adds a third tenancy enforcement layer for entities that live in shared
schemas (`core`, `metadata`) and carry a `workspaceId` column —
previously the only safeguard at this layer was developer discipline
(remembering to put `workspaceId` in every WHERE clause).
### The three layers, after this PR
| Layer | Scope | How it's enforced |
|---|---|---|
| 1. Workspace data | per-workspace schema (companies, people, custom
objects) | `twentyORMManager.getRepository(workspace, E)` — physical
isolation (own data source) |
| 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata,
views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory
map, lookups by id within it |
| 3. Core (new) | shared `core` schema (agent threads/turns/messages,
app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a
required positional argument on every read/write |
## What's in the PR
### The wrapper
(`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`)
- `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a
TypeORM `Repository<T>`, requires `workspaceId` on every
`find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count`
call, merging it into the WHERE or stamping it on the entity.
`createQueryBuilder` is an explicit escape hatch (caller scopes
manually).
- Provided via Nest DI with
`@InjectWorkspaceScopedRepository(EntityClass)` and the
`provideWorkspaceScopedRepository(EntityClass)` provider factory.
- 19 unit tests cover the merge behavior, override-on-conflict, and the
array-where (OR) case.
### Lint enforcement
(`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`)
- New `twenty/prefer-workspace-scoped-repository` rule (level:
**error**).
- Blacklist of entity names: raw `@InjectRepository(E)` is rejected if
`E` is on the list.
- Initial list: `AgentTurnEntity`, `AgentMessageEntity`,
`AgentMessagePartEntity`, `AgentChatThreadEntity`,
`AgentTurnEvaluationEntity`, `AgentEntity`.
- Designed to grow over time as more consumers are migrated.
- 5 rule tests.
### Migration in this PR
All consumers of the six blacklisted entities, including:
- AI agent / chat / monitor resolvers, services, and jobs
- `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`,
`ApplicationService`, `WorkspaceFlatAgentMapCacheService`
- Admin-panel chat (migrated where the lookup is workspace-known; one
documented `eslint-disable` on the threadId-discovery lookup that
necessarily precedes the `allowImpersonation` permission check)
- `AiAgentRoleService` unit spec updated to mock the scoped wrapper
## Future work (deliberately not in this PR)
A standalone audit identified ~14 additional `core`/`metadata` entities
with `workspaceId` that currently use raw `@InjectRepository` and could
be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42
sites), `AppTokenEntity` (10), `FileEntity` (7),
`BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each
should be its own PR — the migration is mechanical but the surface is
wide.
## Test plan
- [x] `npx nx typecheck twenty-server` — clean
- [x] `npx nx lint twenty-server` — 0 warnings, 0 errors
- [x] `npx jest workspace-scoped-repository` — 19/19 pass
- [x] `npx nx test twenty-oxlint-rules` — 215/215 pass
- [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass
- [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer)
- [ ] Manual smoke: AI agent monitor — list turns, run evaluation
(reviewer)
- [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
## Summary
When messages are imported, Twenty auto-creates a Person record for any
recipient that doesn't exist yet. The display-name parser used at that
point is `displayName.split(' ')[0] / [1]`, which silently mangles
several common header shapes:
| Header | Old result |
|-------------------------------------------------|-----------------------------------------|
| `"Doe, John" <...>` | `firstName="Doe,"`, `lastName="John"` |
| `"John.Doe Doe" <...>` | `firstName="John.Doe"`, `lastName="Doe"`|
| `"Mary Jane Watson" <...>` | `lastName="Jane"` ("Watson" dropped) |
| `"john.doe@x.com" <john.doe@x.com>` (forwarder) | full address in
`firstName` |
| `"Doe, John:GROUP" <...>` (group-tag servers) |
`firstName="John:GROUP"` |
This PR rewrites `getFirstNameAndLastNameFromHandleAndDisplayName` to
handle each pattern. Behaviour in order:
1. Trim + strip wrapping quotes
2. Swap `"Last, First"` comma form
3. Fall back to handle parsing when display name contains `@` (real
names don't)
4. Split single dotted tokens (`"john.doe"` → `"John"`, `"Doe"`)
5. Preserve multi-word last names (`tokens.slice(1).join(' ')`)
6. De-synthesize dot-glued first names (`"John.Doe Doe"` → `"John"`,
`"Doe"`)
7. Strip `:XXX` trailing tag suffix from each parsed field
## Test plan
- [x] 16 new unit test cases covering each shape
(`__tests__/get-first-name-and-last-name-from-handle-and-display-name.util.spec.ts`)
- [x] Lint + typecheck clean
- [ ] No regression in the messaging import flow
---------
Co-authored-by: neo773 <62795688+neo773@users.noreply.github.com>
Co-authored-by: neo773 <neo773@protonmail.com>
## Summary
- **Fix `callWithTimeout` timer leak**: the `setTimeout` was never
cleared when the callback resolved first, leaving orphaned timers (up to
15 minutes) in the Node.js event loop. Now uses `try/finally` with
`clearTimeout`.
- **Properly classify timeout errors**: introduced
`ExecutionTimedOutError` so the Lambda driver can throw
`LOGIC_FUNCTION_EXECUTION_TIMEOUT` instead of
`LOGIC_FUNCTION_EXECUTION_FAILED` — users now see "Function execution
timed out" instead of the generic "Function execution failed."
## Test plan
- [x] Execute a logic function normally and verify it still works
- [x] Trigger a logic function timeout and verify the error message says
"Function execution timed out" (not "Function execution failed")
- [x] Verify that a deleted logic function invocation logs a specific
"was deleted" warning
- [x] Verify no orphaned timers remain after logic function execution
completes
Remove usage of hasValidEnterpriseKey in FE (replaced by
hasValidSignedEnterpriseKey)
To avoid breaking change at deploy time, we will wait until after this
has been deployed in prod, to remove hasValidEnterpriseKey in the BE.
## Customer-reported bug
A customer hit this when using the AI chat:
```json
{
"message": "API key 760d4822-da40-4b3f-9031-40563d7ed6c9 has no role assigned",
"extensions": {
"code": "INTERNAL_SERVER_ERROR",
"userFriendlyMessage": "This API key has no role assigned."
}
}
```
Their integration authenticates via API key. Somewhere along the way,
the role bound to that API key was deleted, leaving the API key
authenticated but role-less. Any request that hits a permission check
(`getRoleIdForApiKeyId`) blows up.
## Root cause
In `RoleService.deleteManyRoles`, the pre-deletion cleanup
(`assignDefaultRoleToMembersWithRoleToDelete`) only rebinds **user
workspaces** to the workspace default role. API keys and agents pointing
at the role are ignored. Because `RoleTargetEntity.role` declares
`onDelete: 'CASCADE'`, the FK then drops the role_target rows for those
API keys / agents — but the API keys themselves stay in `api_key`, now
orphaned in `apiKeyRoleMap`.
A previous read-side workaround
([2767ddac44](https://github.com/twentyhq/twenty/commit/2767ddac44) —
make the `role` ResolveField nullable) handled the API-key-details page,
but did not address the write paths (`getRoleIdForApiKeyId`).
## Fix
- Rename `assignDefaultRoleToMembersWithRoleToDelete` →
`rebindTargetsOfRoleToDeleteToDefaultRole` and extend it to rebind API
keys (via `ApiKeyRoleService.assignRoleToApiKey`) and agents (via
`AiAgentRoleService.assignRoleToAgent`) in the same step, before the
role is deleted.
- If the workspace default role doesn't satisfy `canBeAssignedToApiKeys`
/ `canBeAssignedToAgents`, the inner `assignRoleTo*` validation throws.
We catch that and rethrow as a `PermissionsException` with a
role-deletion-context message and two new codes —
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` /
`ROLE_CANNOT_BE_ASSIGNED_TO_AGENTS` — so the admin sees a clear
"reassign these first" prompt rather than a confusing inner error.
## Scope / non-goals
- **Already-orphaned API keys are not auto-healed.** The customer still
needs to reassign a role to their existing orphan API key via the UI
(Settings > API Keys > [the key] > role). A separate cleanup command for
existing orphans is a follow-up.
- I did not investigate *why* the customer's session was authenticated
via API key in the AI chat — that may be their integration setup. Worth
confirming with them separately.
## Test plan
- [ ] Workspace with default role `Admin` (which has
`canBeAssignedToApiKeys: true`): create an API key with a custom role,
delete the custom role → API key is rebound to Admin, requests keep
working.
- [ ] Workspace with default role `Member` (default, has
`canBeAssignedToApiKeys: false`): create an API key with a custom role,
delete the custom role → role deletion fails with the new
`ROLE_CANNOT_BE_ASSIGNED_TO_API_KEYS` error explaining the admin must
reassign first. API key + custom role are both unchanged.
- [ ] Same two scenarios for agents (`canBeAssignedToAgents`).
- [ ] Existing user-workspace rebind behavior is unchanged.
- [ ] Role deletion with no dependent API keys / agents still works.
## Summary
Raises the artificial hardcoded ceiling on `maxNumberOfValues` for
custom FILES fields from `10` to `60` so users can attach more files per
record.
- Bumped `FILES_FIELD_MAX_NUMBER_OF_VALUES` constant in `twenty-shared`
from `10` to `60`
- Updated validator unit test (inline snapshots + "exceeds max" case)
- Updated create/update files-field metadata integration tests and Jest
snapshots
The frontend Zod schema only enforces a `min`, so no frontend changes
are required — the backend constant is the single source of truth for
the upper bound.
Refs #20942🤖 Generated with [Claude Code](https://claude.ai/code)
Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com>
Automated daily sync of `ai-providers.json` from
[models.dev](https://models.dev).
This PR updates pricing, context windows, and model availability based
on the latest data.
New models meeting inclusion criteria (tool calling, pricing data,
context limits) are added automatically.
Deprecated models are detected based on cost-efficiency within the same
model family.
**Please review before merging** — verify no critical models were
incorrectly deprecated.
Co-authored-by: FelixMalfait <6399865+FelixMalfait@users.noreply.github.com>
## Introduction
Should rely on custom typeorm entity loader layer that inspects the
upgradeMigration that has bene run to dynamically request existing col
only
# Introduction
closes https://github.com/twentyhq/private-issues/issues/484
This PR refactors the writeFile API to never expect to be passed a
mimetype, its extract is done programmatically low level so any callers
will pass through
Same for the file sanitization
## IANA override
Disclaimer for consistency we existing behavior we wanted to always have
`application/typescript`
- should we rather consider fallbacking to octect-steam instead ?
- Any pulbic assets that has .ts will now also fallback to
`application/typescript` instead of the official IANA
## Integration
Added coverage
## Summary
- Moves current version to previous versions array
- Sets TWENTY_CURRENT_VERSION to the new version
- Updates TWENTY_NEXT_VERSIONS with the next minor version
- Bumps twenty-client-sdk, twenty-sdk, and create-twenty-app to the same
version
## Checklist
- [ ] Verify version constants are correct
- [ ] Verify npm package versions match
Co-authored-by: Github Action Deploy <github-action-deploy@twenty.com>
[#20836](https://github.com/twentyhq/twenty/pull/20836) dropped the
channel objects but even though
calendarChannelId/messageChannelId/messageFolderId already existed in
compute standard flat field, there was never an upgrade command to readd
them on the surviving association objects
so existing workspaces lost the field metadata (columns survived) and
import workers throw
```Error: Unknown error importing calendar events for calendar channel <REDACTED> in workspace <REDACTED>: Query runner already released. Cannot run queries anymore.```
This PR adds that command
---------
Co-authored-by: prastoin <paul@twenty.com>
App permissions tab:
- The fallback uuidv4() for a marketplace field was generated twice, so
id and universalIdentifier could diverge; it's now computed once and
reused as it seemed to be the intention (even though I don't really
think it's a good idea)
- Renamed buildobjectMetadataItemsFromMarketplaceApp →
buildObjectMetadataItemsFromMarketplaceApp to follow camelCase.
Morph relation validation:
- Fixed the user-facing message "At least one relation is require" →
"...is required"
- Typos in the related test descriptions (Morh → Morph, samefield → same
field) and their snapshots.
Docs
- The UUID field-type row in views.mdx only listed IS; updated to the
full set supported by FILTER_OPERANDS_MAP (IS, IS_NOT, IS_EMPTY,
IS_NOT_EMPTY).
## Context
Adds the SDK plumbing for apps to declare custom permission flags and
the server-side manifest pipeline to persist them.
```typescript
import { definePermissionFlag } from 'twenty-sdk/define';
export const MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER = '…';
export default definePermissionFlag({
universalIdentifier: MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
key: 'MANAGE_INVOICES',
label: 'Manage Invoices',
description: 'Create, edit, and delete invoices',
icon: 'IconReceipt',
});
```
```typescript
import { defineApplicationRole, SystemPermissionFlag } from 'twenty-sdk/define';
import { MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER } from './permission-flags/manage-invoices';
export default defineApplicationRole({
universalIdentifier: DEFAULT_ROLE_UNIVERSAL_IDENTIFIER,
label: `${APP_DISPLAY_NAME} default function role`,
// ...
permissionFlagUniversalIdentifiers: [
SystemPermissionFlag.UPLOAD_FILE,
MANAGE_INVOICES_PERMISSION_FLAG_UNIVERSAL_IDENTIFIER,
],
});
```
The flag can then be referenced by UUID in a role's
permissionFlagUniversalIdentifiers. On sync, the catalog row lands in
core.permissionFlag and the link in core.rolePermissionFlag.
## Not in this PR
- Runtime permission checks.
PermissionsService.getUserWorkspacePermissions still builds its result
from Object.values(PermissionFlagType), so custom flags are stored but
not yet enforced, code asking "does this role have MANAGE_INVOICES?"
won't get a meaningful answer. Widening PermissionsService and
UserWorkspacePermissions.permissionFlags to support arbitrary flag keys
is the next PR.
- PermissionFlag from apps can only define "tool" permissions and not
"settings" as a permissionType, this parameter is not mutable. This is
because "settings" are for settings page (until we might decide to
separate both type of permissions into 2 different entities) and apps
can't declare settings page or interact with them so this parameter
would be unnecessary.
## Summary
Brings indexes management into the per-object Settings tab as a section
under Search (no feature flag, advanced mode only). Admins can create /
delete non-unique indexes with the UI; apps can declare indexes in code
with `defineIndex`. Composite-typed fields are now indexable by picking
a specific sub-column (e.g. `Address > City`).
A few related polish items also land here (invite-user dropdown lands on
the Invite tab; standard warning callout above the new-index form).
## What ships
### UI — custom indexes on per-object Settings
- New section directly under Search, wrapped in
`AdvancedSettingsWrapper`.
- Filter dropdown on the search bar toggles system-index visibility
(shown by default since advanced mode).
- **+ Add Index** button (disabled with tooltip once the per-object cap
is reached) navigates to a dedicated `SettingsObjectNewIndex` page
(matches the field-creation pattern, not a modal):
- Field picker mirrors the webhook event-form layout (rows of dropdowns,
implicit trailing empty row).
- Composite fields surface their sub-properties (`Address > City`,
`Currency > Amount`, …).
- BTREE / GIN type selector.
- Standard warning Callout: "Use indexes sparingly — each one speeds
reads but slows writes."
- Trash icon on `isCustom: true` rows → confirmation modal →
`deleteOneIndex`.
### Server — `createOneIndex` / `deleteOneIndex` mutations
- Gated by `SettingsPermissionGuard(DATA_MODEL)`.
- `IndexMetadataService` wraps the existing migration runner via
`WorkspaceMigrationValidateBuildAndRunService` so the metadata row and
the SQL index land atomically.
- Validation: rejects empty fields, duplicate `(fieldMetadataId,
subFieldName)` pairs, fields not on the object, requires `subFieldName`
for composite parents, forbids `subFieldName` on scalar/relation,
enforces `MAX_CUSTOM_INDEXES_PER_OBJECT = 10`.
- Delete refuses on `isCustom: false` rows so system indexes can't be
removed via this API.
- Dedicated GraphQL exception handler maps each typed error to the right
transport error class.
### Composite sub-field indexing
- Adds `subFieldName: string | null` column to
`IndexFieldMetadataEntity` (fast instance command).
- The flat-entity flow (`UniversalFlatIndexFieldMetadata`,
`FlatIndexFieldMetadata`, `from-universal-flat-index-to-flat-index`,
runner column resolution) all carry `subFieldName` through.
- For composite parents, the runner uses
`computeCompositeColumnName({...}, property)` for the picked sub-column;
for non-composite parents, behavior is unchanged.
- The `'::'` separator encodes `(fieldMetadataId, subFieldName)` for
dedup on the wire; the frontend uses the same separator inside the
Select component's string value.
### Apps can declare indexes in code (`defineIndex`)
- New `IndexManifest` + `IndexFieldManifest` types in
`twenty-shared/application` wired into the `Manifest` type.
- `defineIndex` SDK helper + `IndexConfig`. CLI manifest builder +
extractor recognize `defineIndex` / `ManifestEntityKey.Indexes`.
- Server: `from-index-manifest-to-universal-flat-index` converter
resolves field IDs, validates composite/scalar `subFieldName` rules, and
delegates to `generateFlatIndexMetadataWithNameOrThrow` for the
deterministic name.
- Orchestrator wires the loop after the field-resolution pass;
per-object cap enforced inline against the manifest.
- Cascade on uninstall is automatic — when an app disappears its indexes
drop with it (universal-flat-entity diff handles it).
- Rich-app fixture ships a real `defineIndex` on `PostCard.status`,
exercising the full manifest → install path in CI.
### Closed for now (open later if needed)
- Apps cannot declare `isUnique` indexes — unique constraints stay with
the field-creation flow.
- Apps cannot use a partial-`indexWhereClause` — the UI surface keeps
the framework's hardcoded allowlist.
- UI cannot create unique or partial indexes either; same reasons.
### Cleanups along the way
- Reused the existing `getCompositeSubFieldLabel` +
`COMPOSITE_FIELD_SUB_FIELD_LABELS` (deleted the duplicates I'd created
early in the PR).
- Moved `MAX_CUSTOM_INDEXES_PER_OBJECT` to `twenty-shared/constants`
(single source for FE + BE).
- Replaced inline `isDefined(x) && x !== ''` with `isNonEmptyString`
(from `@sniptt/guards`).
- Hoisted the per-object fields Map + inlined the cap counter into the
indexes orchestrator loop (drops the install scan from O(indexes ×
totalFields) to O(totalFields + indexes)).
- Per design-feedback: page-based create flow (not a modal), filter
dropdown on the SearchInput (not a separate toggle), webhook-style
picker, field icons.
### Unrelated polish that lands here
- "Invite user" link in the multi-workspace dropdown now lands on the
Invite tab directly (`#invite`) instead of the first tab of the members
page.
## Test plan
- [ ] `npx nx typecheck twenty-server / twenty-front / twenty-sdk /
twenty-shared` — passes
- [ ] `npx nx lint:diff-with-main twenty-server / twenty-front` — clean
- [ ] `npx jest index-metadata.service.spec` — green
- [ ] `npx jest from-index-manifest-to-universal-flat-index` — green
(new converter spec, 8 cases)
- [ ] `npx vitest run
src/sdk/define/indexes/__tests__/define-index.spec.ts` (twenty-sdk) —
green (6 cases)
- [ ] `npx vitest run --config vitest.integration.config.ts -t
"rich-app"` — green (rich-app app-dev integration exercises the new
manifest path with the PostCard.status index)
- [ ] Advanced mode → Settings → any object → Settings tab → Indexes
section is visible under Search
- [ ] Create a single-field BTREE index, confirm SQL index exists
(verify via `pg_indexes`)
- [ ] Create a composite-field index (`Address > City`) and confirm the
column is `addressAddressCity`
- [ ] Create an index spanning two columns; column order matches the
picker order
- [ ] Attempt to create an 11th custom index → button is disabled with
tooltip
- [ ] Delete a custom index → confirmation modal → row disappears, PG
index dropped
- [ ] System indexes have no trash icon and are hidden by default
# Introduction
Related https://github.com/twentyhq/twenty/issues/20879
More abstracted response error and cleaner integrity check before
performing any in database search
Nothing critical patched here
Also added integration coverage to the related endpoint
Fixed the stream on error throw that would have been bubbling up into
node process
## Next
Once this has been approved will re-apply to all the existing prone
file.getBy* methods and controllers endpoints
- update ci-breaking-changes.yaml so it check for api contrat breaks
- check fails properly when removing fix
https://github.com/twentyhq/twenty/pull/20825
- check it turns green again when adding fix back