Skip to content

Project Transfer

Linear issues:

Source decisions:

  • Supplier Authentication v1.3 (Ben, 09/04/26) — email-based linking workflow, forwardable invitations (§6d)
  • Supplier Authentication v1.2 (Ben, 07/04/26) — earlier draft with additional context on prod-co-initiated flows
  • D2D-11 comments: Ben’s proposal to unify XML and integration-lite supplier linking (09/04/26)
  • D2D-11 comments: broadcaster preferences (Lumo: verification, Sky: magic link, WBD: XML with verification backup)
  • CDN x TEP weekly catch-up (13/04/26): auto-generated episode IDs on linking

Working docs (engineering): 02-supplier-authentication.md, 09-id-strategy-and-scenarios.md

Dependencies:

  • Build: XML - External ID Restructuring (externalIds on screenProject needed for bulk transfer query)
  • Field Locking (lock-attribution rewrite on accept; the multiEpisode and numberOfEpisodes fields whose UI writes recurse into episode CRUD need their lock owner to follow the project’s new owner)

When a broadcaster sends a new project to TEP (via XML or the TEP web interface), the system needs to link it to the correct production company. For every broadcaster–supplier pairing after the first, this happens automatically — TEP already knows which production company sits behind the broadcaster’s supplier ID. The challenge is the very first project for any given pairing.

Supplier IDs are broadcaster-specific internal identifiers. They may be predictable, so allowing production companies to self-declare their IDs would create a security risk — a bad actor could claim an ID that doesn’t belong to them and receive confidential data about another company’s projects.

The Solution: Funder Owns Until Transferred

Section titled “The Solution: Funder Owns Until Transferred”
  1. Project arrives with unknown supplier ID — TEP creates the project under the broadcaster’s own company account (status = DRAFT). The project appears on the broadcaster’s dashboard as a project they own, awaiting transfer to a production company.

  2. Broadcaster initiates transfer — The broadcaster provides a contact name and email for someone at the production company. TEP creates a ProjectTransferInvitation with a cryptographically-secure token and emails the link.

  3. Recipient accepts (or rejects) — The contact follows the link, logs in to or creates a TEP account, picks the company that should receive the project, and accepts. The project’s companyId is rewritten to the production company’s id, every transferred episode’s denormalised companyId follows, and lock attribution for episode-coupled project fields is rewritten to the new owner (see Field Locking). The broadcaster becomes a funder of the project. A supplierMappings[supplierId] = prodCoCompanyId entry is added to the broadcaster’s company document so future projects from this broadcaster using the same supplier ID link automatically.

  4. Bulk transfer — All other existing projects from the same broadcaster with the same supplier ID are transferred atomically in the same operation.

This workflow applies to both XML and integration-lite broadcasters. The only difference is how the project is initially created.

When a production company creates a project via the TEP web interface and selects a broadcaster as funder, no supplier ID is involved on the create path. The relationship is established directly within TEP and the broadcaster then verifies the project through the standard approval workflow.

If the same broadcaster later sends XML for a different project from the same production company, the supplier-linking workflow handles it as a new broadcaster–supplier pairing (a duplicate is possible — see Edge Cases).

Per CDN Supplier Auth v1.3 §6d, the invitation token is not bound to the original recipient’s email. The recipient mailbox may forward the link to whoever is best placed to action it — for example, an inbox shared with a finance team. The token’s entropy + expiry + atomic claim provide the token-side gate; company membership is the actor-side gate: the caller must be a member of the targetCompanyId they accept the invitation into, so a leaked forwarded link cannot be accepted into an unrelated company.


  • When XML arrives with an unknown supplier ID, the project is created under the funder’s company (companyId = funderCompanyId, status = DRAFT)
  • The supplier ID is stored in the project’s externalIds array with funderCompanyId = <originator>
  • The project appears on the broadcaster’s dashboard as an owned project
  • Episodes created at this stage carry external IDs from the XML
  • Multiple projects from the same unknown supplier are all created under the funder (not one-at-a-time)
  • A broadcaster admin can initiate a transfer for a project owned by their company
  • Transfer initiation requires: contact name and contact email
  • A ProjectTransferInvitation record is created with a cryptographically-secure token and an expiresAt timestamp
  • TEP sends an invitation email via the existing communication system
  • The invitation row is the source of truth for the email address the link was sent to (recipientEmail); on input the field is named contactEmail
  • The originator can re-send the invitation; resend refreshes lastSentAt and generates a fresh token + expiresAt
  • The originator can cancel a pending invitation (the row is preserved with status: CANCELED for audit)
  • The recipient can create a new TEP account and accept
  • The recipient can log in to an existing TEP account and accept
  • The recipient must be a member of the targetCompanyId they specify on accept (email-match is not required — invitations are forwardable per Supplier Auth v1.3 §6d)
  • On acceptance, atomically within a single MongoDB transaction:
    • The invitation row is claimed (only if status === PENDING and not expired)
    • Every project owned by the originator company with the same externalIds[funderCompanyId].supplierId is bulk-updated: companyId → targetCompanyId, status → APPROVED
    • Every episode of every transferred project has its denormalised companyId synced to targetCompanyId (otherwise company-scoped episode queries / CASL read rules / reporting break)
    • Lock attribution on fieldMetadata.multiEpisode.setBy and fieldMetadata.numberOfEpisodes.setBy (where present) is rewritten to targetCompanyId — these are the project fields whose UI writes recurse into episode-collection CRUD, and the lock owner must follow the underlying episode ownership (Field Locking explains why)
    • The originator’s fundedProjectIds gains every transferred project id ($addToSet)
    • The originator’s supplierMappings[supplierId] = targetCompanyId is set
  • After the transaction commits, episode IDs are generated for each transferred project (bounded concurrency = 5; failures are isolated per project and logged but do not roll back the transfer)
  • After the transaction commits, an acceptance notification email is sent to the originator (best-effort; failures are logged)
  • Tokens are cryptographically-secure (high entropy, single-use)
  • Invitations are forwardable — the link is not tied to the original recipient mailbox (Supplier Auth v1.3 §6d). The actor-side check is company membership of the targetCompanyId.
  • Token claim is atomic: a findOneAndUpdate with a status: PENDING and expiresAt > now gate prevents reuse and double-claim
  • Expired or invalid tokens return a specific error code (TRANSFER_INVITATION_NOT_FOUND, TRANSFER_ALREADY_COMPLETED, or TRANSFER_INVITATION_EXPIRED) so the client can show a tailored message
  • A supplier ID containing . or $ is rejected on accept rather than silently corrupting the dot-notation supplierMappings key
  • After a supplier ID is linked, new XML projects from the same broadcaster with that supplier ID are automatically assigned to the prod co
  • Auto-linked projects are created with status: APPROVED (known supplier = known prod co = safe to approve) — behaviour matches the post-transfer state, so the create- and accept-paths produce equivalent end states
  • Auto-linking uses supplierMappings on the funder company document
  • The recipient can reject the invitation via the same forwardable link
  • On rejection, the invitation row is marked status: REJECTED and no project is transferred
  • TEP notifies the originator of the rejection
  • The originator company admin can cancel a PENDING invitation
  • The cancelled row is preserved with status: CANCELED for audit (not deleted)
  • Invitations that are already terminal (accepted / rejected / expired) cannot be cancelled
  • When a prod-co user selects a funder that has XML integration enabled, a warning is shown: “This broadcaster uses automated XML integration. Creating a project manually may result in duplicates. Please coordinate with the broadcaster.”
  • XML integration status is exposed as a computed field on the company (hasXmlIntegration: Boolean), determined by whether the company has an entry in the XML ingestion funder configuration
  • The whole project-transfer surface — nav link, transfer mutations / queries, the pendingTransferInvitation field on ScreenProject, supplier-id-on-create input — is gated client-side on NEXT_PUBLIC_FEATURE_PROJECT_TRANSFER_ENABLED=1. Flag-off builds omit the feature entirely so the prelive deploy can ship without the broadcaster-facing surface until the API rollout completes.

The schema for this feature lives in src/schema/projectTransfer/schema.graphql. The shape below matches what’s deployed; minor doc-comments are paraphrased to keep the spec terse.

Status enum. EXPIRED is computed at read time: rows are stored in MongoDB as PENDING and annotated as EXPIRED by the resolver when expiresAt is in the past, so a missed cron sweep can never leave stale rows. CANCELED is set when the originator explicitly cancels a pending invitation.

enum ProjectTransferInvitationStatusEnum {
PENDING
ACCEPTED
REJECTED
EXPIRED
CANCELED
}
type ProjectTransferInvitation {
id: ObjectID!
projectId: ObjectID!
# The company that initiated the transfer. Named generically (rather than
# `funderCompanyId`) so the type can be reused for company-to-company
# transfers in the future. For the current funder→supplier flow this is
# the funder/broadcaster company.
originatorCompanyId: ObjectID!
supplierId: String!
contactName: String!
# Email address the invitation was sent to (output field on the invitation
# type). On `initiateProjectTransfer` the input field is named
# `contactEmail` — the lifecycle rename reflects the persisted/output role.
recipientEmail: String!
status: ProjectTransferInvitationStatusEnum!
createdAt: DateTime!
lastSentAt: DateTime! # refreshed on every resend
expiresAt: DateTime!
}

ScreenProject field addition. Powers the broadcaster dashboard chip column and the Transfer-project action gating in the client.

extend type ScreenProject {
pendingTransferInvitation: ProjectTransferInvitation
}

Queries. A single broadcaster-dashboard query, paginated to bound the response on broadcasters with large backlogs. The caller must be a member of companyId.

extend type Query {
projectTransferInvitations(
companyId: ObjectID!
limit: Int = 50 # 1–200
offset: Int = 0
): [ProjectTransferInvitation!]
}

Mutations. All mutations use the standard *Input! payload pattern.

input InitiateProjectTransferInput {
projectId: ID!
contactName: String!
contactEmail: String!
}
input AcceptProjectTransferInput {
token: String!
# The production company that should receive ownership. The caller
# must be a member of this company — TransferAcceptor enforces this in
# `assertUserIsMemberOfCompany`. There is no "create new company" flag;
# account / company creation is handled out-of-band before acceptance.
targetCompanyId: ID!
}
input RejectProjectTransferInput { token: String! }
input ResendProjectTransferInvitationInput {
invitationId: ID!
alternateEmail: String # optional — send to a different recipient
}
input CancelProjectTransferInvitationInput {
invitationId: ID!
}
extend type Mutation {
initiateProjectTransfer(input: InitiateProjectTransferInput!): InitiateProjectTransferPayload
acceptProjectTransfer(input: AcceptProjectTransferInput!): AcceptProjectTransferPayload
rejectProjectTransfer(input: RejectProjectTransferInput!): RejectProjectTransferPayload
resendProjectTransferInvitation(input: ResendProjectTransferInvitationInput!): InitiateProjectTransferPayload
cancelProjectTransferInvitation(input: CancelProjectTransferInvitationInput!): CancelProjectTransferInvitationPayload
}

acceptProjectTransfer and rejectProjectTransfer are deliberately not gated by CASL — the token is the auth gate. cancel* / resend* / initiate* and the projectTransferInvitations query require the caller to be an admin / member of the originator company.

Payload shapes. Standard Payload interface with errors. The accept payload additionally returns the IDs of every transferred project (the bulk-transfer case). RejectProjectTransferPayload and CancelProjectTransferInvitationPayload carry a success: Boolean flag.

src/schema/projectTransfer/serviceLogic/TransferAcceptor.ts is the single source of truth. The acceptance flow runs as a single MongoDB transaction so an interrupted accept never leaves the project, the supplier mapping, the episode companyId sync, the lock-attribution rewrite, or the invitation in inconsistent states. After the transaction commits, episode-ID generation and the originator email notification run as best-effort post-steps — a failure in either does not roll back the ownership transfer.

async function acceptTransfer(
input: { token: string; targetCompanyId: string },
caller: AuthenticatedUser,
): Promise<{ transferredProjectIds: ObjectId[] }> {
// 0. Validate input (Zod).
// 1. Actor gate — caller must be a member of targetCompanyId. The token's
// entropy / expiry / atomic claim are the token-side gates; there is
// *no* email-match check because invitations are forwardable per
// Supplier Auth v1.3 §6d.
assertUserIsMemberOfCompany(caller, targetCompanyId);
return await executeWithTransactionSecure(async (session) => {
// 2. Atomic claim — succeeds only if status === 'pending' AND
// expiresAt > now. Token reuse is prevented by the findOneAndUpdate
// gate; double-claim is impossible inside the same transaction.
const claimed = await invitations.claimAsAccepted(
token, now, targetCompanyId, session,
);
if (!claimed) return null; // resolved to a specific error code below
const { originatorCompanyId, supplierId, projectId } = claimed;
// 3. Bulk-find every project owned by the originator with this
// supplierId. (ScreenProject.externalIds[].funderCompanyId is the
// same value as originatorCompanyId — different name in different
// scopes.)
const projects = await screenProjects
.findProjectsByFunderAndSupplierId(originatorCompanyId, supplierId, session);
const projectIds = projects.map((p) => p._id);
if (projectIds.length > 0) {
// 4. Bulk transfer ownership + promote to APPROVED.
await updateMany('screenProject',
{ _id: { $in: projectIds } },
{ $set: { companyId: targetCompanyId, status: 'APPROVED', updatedAt: now } },
session,
);
// 5. Sync the denormalised companyId on every transferred episode.
// Episode.companyId is denormalised from ScreenProject.companyId;
// leaving the old value in place would break company-scoped
// episode queries, CASL read rules, and audit reporting.
await updateMany('episode',
{ projectId: { $in: projectIds } },
{ $set: { companyId: targetCompanyId, updatedAt: now } },
session,
);
// 6. Rewrite lock attribution for the episode-coupled project fields
// (currently `multiEpisode` and `numberOfEpisodes` — see
// EPISODE_COUPLED_FIELD_NAMES in ProjectAccessMatrix.ts for the
// canonical list). These are the only project fields whose UI
// writes create or delete documents in the episode collection;
// post-transfer the episodes belong to the new owner, so the
// project-side lock owner must follow ownership too — otherwise
// the original funder retains a paper write-grant whose
// downstream episode CRUD lands on producer-owned episodes and
// is rejected by the CASL company-ownership gate. The filter
// `$exists: true` is critical: a $set on a missing dot-path
// would materialise an empty parent object. Other project locks
// (projectName, shortProjectName, genre.ofcom*, ...) have no
// episode-CRUD side-effects and keep their original `setBy`,
// so the original lock-holder retains write access via the
// own-lock matrix-bypass rule. See Field Locking spec.
for (const fieldName of EPISODE_COUPLED_FIELD_NAMES) {
await updateMany('screenProject',
{
_id: { $in: projectIds },
[`fieldMetadata.${fieldName}`]: { $exists: true },
},
{
$set: {
[`fieldMetadata.${fieldName}.setBy`]: targetCompanyId,
updatedAt: now,
},
},
session,
);
}
// 7. Add all transferred IDs to the originator's fundedProjectIds.
// The Company collection field stays funder-named because it's
// the broader project-funder concept, not transfer-specific.
await companies.pushWithIdSecure(
originatorCompanyId,
{ fundedProjectIds: { $each: projectIds } },
session,
/* $addToSet */ true,
);
// 8. Defensive guard: supplierId containing '.' or '$' would corrupt
// the dot-notation `supplierMappings.<key>` update key. Input
// validation on CreateScreenProject already blocks new records;
// this guards legacy / XML-ingested ones.
if (/[.$]/.test(supplierId)) throw new Error('Invalid supplierId');
// 9. Persist the supplier → production-company mapping on the
// originator so future XML or web-UI projects on this pair
// auto-link.
await updateOne('company',
{ _id: originatorCompanyId },
{ $set: { [`supplierMappings.${supplierId}`]: targetCompanyId, updatedAt: now } },
session,
);
}
auditLogger.info({
event: 'PROJECT_TRANSFER_ACCEPTED',
invitationId: claimed._id,
originatorCompanyId,
targetCompanyId,
supplierId,
transferredProjectCount: projectIds.length,
transferredProjectIds: projectIds,
});
return { invId: claimed._id, projectId, originatorCompanyId, supplierId, projectIds };
})
.then(async (txResult) => {
// Disambiguate the null branch (atomic claim missed). One read outside
// the transaction tells the user-facing layer whether the token was
// not found, already completed, or expired — distinct error codes per
// case so the client can render a tailored message.
if (txResult === null) throw resolveClaimMissReason(token);
const { projectIds, originatorCompanyId, supplierId } = txResult;
if (projectIds.length === 0) return txResult;
// 10. Post-commit, best-effort: generate episode IDs with bounded
// concurrency (5 in-flight) so a large bulk transfer can't
// overwhelm the episode service. Failures are isolated per
// project and logged via availabilityLogger — the ownership
// transfer is already committed and must not be rolled back by a
// downstream failure here.
await generateEpisodeIdsBoundedConcurrency(
projectIds, originatorCompanyId, supplierId, /* concurrency */ 5,
);
// 11. Notify the originator of the acceptance. Email failures are
// logged but do not affect the transaction result.
await sendAcceptanceEmailToOriginator(txResult);
return txResult;
});
}
{
"_id": "ObjectId",
"originatorCompanyId": "ObjectId", // company that created the invitation
"projectId": "ObjectId",
"supplierId": "supplier123",
"contactName": "Jane Smith",
"recipientEmail": "jane@prodco.com", // input field is `contactEmail`
"token": "cryptographically-secure-token",
"status": "pending", // EXPIRED is computed at read time —
// rows are never written as EXPIRED;
// status transitions to ACCEPTED /
// REJECTED / CANCELED only.
"createdAt": "2026-...",
"lastSentAt": "2026-...", // refreshed on every resend
"expiresAt": "2026-...",
"acceptedAt": null,
"acceptedByCompanyId": null,
"rejectedAt": null,
"canceledAt": null
}

src/modules/xmlIngestion/services/ProjectIdentificationServiceDom.ts:

  1. Look up supplierMappings[supplierId] on the funder company document
  2. If found → use the mapped prodCoCompanyId, proceed to project matching. New projects are created with status = APPROVED (known supplier = known prod co = safe to approve)
  3. If not found → create the project under the funder company (companyId = funderCompanyId, status = DRAFT), store the supplier ID + project ID in externalIds. Status remains DRAFT until transfer.

The acceptance flow disambiguates a claim miss into three specific error codes so the client can render a tailored message:

Error codeMeaning
TRANSFER_INVITATION_NOT_FOUNDNo invitation row matches the token. Possibly a probe — threatLogger.warn fires with a token prefix.
TRANSFER_ALREADY_COMPLETEDToken exists but the invitation is no longer PENDING (already accepted, rejected, or canceled).
TRANSFER_INVITATION_EXPIREDToken exists and is still PENDING, but expiresAt < now.

The client surface is gated end-to-end on the NEXT_PUBLIC_FEATURE_PROJECT_TRANSFER_ENABLED flag. With the flag off, the nav link, mutations / queries, the pendingTransferInvitation selection set, and the supplier-ID input on create are all omitted at build time.

GraphQL hooks (src/services/graphQL/projectTransfer/):

HookPurpose
useInitiateProjectTransferMutationBroadcaster initiates a new transfer invitation
useResendProjectTransferInvitationMutationRefresh token + expiresAt and re-send email (optionally to an alternate address)
useAcceptProjectTransferMutationRecipient accepts via token + targetCompanyId
useRejectProjectTransferMutationRecipient rejects via token
useCancelProjectTransferInvitationMutationOriginator cancels a PENDING invitation
useProjectTransferInvitationsQueryBroadcaster dashboard list (paginated)

PENDING_TRANSFER_INVITATION_FIELDS is a shared GraphQL fragment used by both the projects list query (chip column) and the broadcaster dashboard query, so the two queries cannot drift on shape.

Components (src/components/projects/transfer/):

ComponentRole
TransferInitiationFormModal form for “Transfer project to a production company” — collects contact name + email and fires the initiate mutation
PendingTransferChipThe status chip rendered in the projects list when pendingTransferInvitation is non-null on a ScreenProject row; includes resend action
TransferInvitationListThe broadcaster dashboard list — renders all invitations, with cancel and resend actions
TransferStatusBadgeLocalised badge for PENDING / ACCEPTED / REJECTED / EXPIRED / CANCELED
useHandleResendShared callback consumed by PendingTransferChip and TransferInvitationList so resend semantics live in one place

Accept route (src/app/[locale]/(auth)/project-transfer/page.tsx): handles the click-through from the invitation email. The route is in the (auth) group so an unauthenticated recipient is redirected to log in / sign up first; on return the page uses useMeOrgQuery to populate the company-picker (a recipient with one admin company auto-selects; a recipient with multiple admin companies picks before accept). The page does NOT short-circuit if selectedCompany is set but isn’t in companiesAsAdmin — the membership check happens server-side on the chosen targetCompanyId.

Implicit company-switch on accept: if the caller’s currently selected company in me.selectedCompany doesn’t match the chosen targetCompanyId, the page calls updateOrgUserProfileDetails to switch their selected company immediately after a successful accept so the next screen reflects the transferred project.

After transfer accept, the lock-attribution rewrite (step 6 in the acceptance flow above) reattributes the lock on multiEpisode and numberOfEpisodes to the new owner. The combined effect, given the lock-owner-overrides-matrix rule in Field Locking:

  • Producer (new owner) sees editable: true on multiEpisode / numberOfEpisodes (own lock) and FIELD_LOCKED, lockedBy: <originator> on every other project field the broadcaster set pre-transfer (foreign lock keeps the matrix owner: rw from granting write).
  • Originator (now a funder) sees FIELD_LOCKED, lockedBy: <new owner> on multiEpisode / numberOfEpisodes (foreign lock after the rewrite) and editable: true on every other field they set pre-transfer (own lock + matrix-bypass) — they can still tweak the project name, short title, ofcom genre, etc. on a project they originated.

Per-episode fieldMetadata is not rewritten on transfer. The episode access matrix has funder: 'r' on every UI-writable episode field and lock acquisition is tier-gated to COMMISSIONER, so no funder-owned UI-channel episode setBy entries can exist. XML ingestion writes its own channel: 'xml' locks and is the deliberate escape hatch for that path.


  1. Duplicate project from mixed UI + XML paths — If a prod co creates a project via UI and connects to an XML broadcaster, and the broadcaster later sends XML for the same production, a duplicate is created. Post-launch merge tool handles this (see 02-supplier-authentication.md (engineering working docs), “Duplicate Project Reconciliation”).

  2. Invitation ignored — If the prod co never responds, the project stays under the funder. The broadcaster can resend (refreshing lastSentAt and expiresAt) or send to a different email via alternateEmail.

  3. Multiple supplier IDs for the same prod co — Each supplier ID is linked independently. The same prod co can have multiple supplier IDs from the same broadcaster (e.g. post-merger).

  4. Bulk load at go-live — For initial deployment, existing supplier ID mappings need to be pre-loaded. Approach: funder uploads a CSV with supplier_id, company_name, contact_email, TEP matches or queues invitations. (See 02-supplier-authentication.md (engineering working docs), “Bulk Load at Go-Live”.)

  5. Bulk transfer with no matching projects — If the token is valid but no projects match (originatorCompanyId, supplierId) (e.g. the original project was deleted), the transaction still succeeds and the invitation transitions to ACCEPTED; the result is transferredProjectIds: [] and a PROJECT_TRANSFER_NO_MATCHING_PROJECTS warning is logged. The supplier mapping is not written in this case.

  6. supplierId containing . or $ — Rejected on accept with an explicit error rather than silently corrupting supplierMappings (which uses dot-notation keys). New records are blocked at input validation; this guards legacy / XML-ingested records.

  7. Project deletion between create and accept — The acceptance flow tolerates a missing project record; the acceptance email falls back to the project ID string when the project document can’t be fetched at email-construction time.


  • Unit tests for TransferAcceptor.accept: single project, bulk transfer, supplier-mapping creation, episode companyId sync, episode-coupled setBy rewrite, no-matching-projects branch
  • Unit tests for invitation token validation: valid / not-found / already-completed / expired (each maps to a distinct error code)
  • Unit tests for the supplierId . / $ guard
  • Integration test: full XML → unknown supplier → project under funder → invitation → accept → project transferred + supplier mapping written + episode companyIds synced + lock setBy rewritten on episode-coupled fields
  • Integration test: second XML with same supplier ID → auto-linked, status APPROVED
  • Integration test: bulk transfer — multiple projects with the same (funder, supplierId) pair all transferred in one operation
  • E2E test via test harness: scenarios 3, 6, 7 from working-doc 09
  • Security test: invitation token cannot be reused after acceptance; forwarded token cannot be accepted into a company the caller is not a member of
  • Completeness test pinning EPISODE_COUPLED_FIELD_NAMES membership so a future addition (e.g. a hypothetical season-count field with episode-CRUD side-effects) is forced through the constant rather than silently re-introducing the post-transfer authorization gap