Skip to content

Field Locking

Linear issues:

Source decisions:

  • D2D-116, call 02/03/26: locking rules agreed (Ben’s summary comment 03/03/26)
  • D2D-116, Dan’s remove proposal (23/02/26): deletion unlocks fields
  • D2D-2, Dan comment (23/02/26): “if XML provides a value, even empty string, producer cannot edit”
  • Data Access Matrix (10/02/26): defines which fields are editable by broadcaster, prod co, or both
  • TEP-426 product-owner clarification (13/05/26): “COMMISSIONER tier ⇒ ofcom-writable; STANDARD tier ⇒ read-only” (unified across owner / funder roles)
  • TEP-424 follow-on (13/05/26): the company that holds the lock on a field can always edit it, even when the role-based matrix would deny — the funder-creates-and-transfers flow depends on this

Working docs (engineering): 05-field-locking.md, 01-data-model-redesign.md

Dependencies: Build: XML - Episode Data Model — episode data model, fieldMetadata lives on episode documents. Project Transfer — transfer accept rewrites lock attribution on episode-coupled project fields.


Field locking is one layer of TEP’s three-layer field access model. Each project and episode document carries a fieldMetadata object recording which company set each field, when, and via what channel. The combined enforcement and exposure is delivered by the access overlay (ProjectAccessComputer), which composes role-based matrix access with the lock state and the time-lock, and surfaces the result through the myProjectAccess GraphQL query.

The three composing layers:

  1. Access matrix — per-field role-based access (owner: 'rw', funder: 'r', …) defined in PROJECT_FIELDS_MATRIX / EPISODE_FIELDS_MATRIX / CONTRIBUTOR_FIELDS_MATRIX / PRIVATE_COMPANY_DATA_FIELDS_MATRIX. The matrix is the default access for any caller, derived from the caller’s relationships to the project (owner, funder, systemAdmin, industryAdmin) and — for ofcom genre — the project owner’s tier.

  2. Field-lock overlayfieldMetadata.<field>.setBy records which company “claimed” that field. The overlay applies on top of the matrix:

    • Foreign lock (setBy !== caller) → downgrade to editable: false, reason: FIELD_LOCKED, lockedBy: <setter name>. Foreign locks are more restrictive than the matrix: even an owner: 'rw' field becomes read-only.
    • Own lock (setBy === caller) → upgrade to editable: true if the matrix had said 'r'. Own locks are less restrictive than the matrix: the company that set a field can always edit it.
  3. Time-lock overlay — when the project’s delivery date is more than 30 days in the past and the status is APPROVED, all otherwise-editable fields become editable: false, reason: TIME_LOCKED. (See computeTimeLocked in tep-client.) The API serves this as ProjectAccess.timeLocked: Boolean so the client can render a single banner rather than per-field tooltips.

The combined result is exposed as FieldAccess { field, visible, editable, reason, lockedBy } for each field. The client consumes this via the useFieldAccess hook and renders disabled inputs, lock icons, and “locked by …” tooltips directly from it.

  • A field provided by a broadcaster (XML or UI) is locked under that broadcaster’s company id. The lock acquisition path is gated on tier === COMMISSIONER — STANDARD-tier producers do not acquire locks via the UI channel. This means: producers don’t lock the fields they set, but commissioners (broadcasters) do.
  • An empty string is a deliberate value — it sets the field to empty AND locks it.
  • An omitted field does not change the existing value or lock status. Locks are acquired only for fields actually written.
  • Refresh-on-own-lock: re-sending a field whose lock the caller already owns updates lockedAt and channel but does not change setBy. The field stays locked under the same company.
  • First-funder-wins on conflict: when a different broadcaster already holds the lock, the new write is silently skipped (the value is not changed) — setFieldWithLock returns locked: false and the caller does NOT write the value.
  • Ancestor / descendant locks compose: a lock on genre blocks writes to genre.ofcom (ancestor) and a lock on genre.ofcom blocks writes to genre (descendant). This prevents bypassing first-funder-wins via parent/child paths. The check is symmetric in findFieldLockEntry, assertFieldsEditable, and setFieldWithLock.
  • Lock-owner overrides matrix: the company that holds the lock on a field can always edit it regardless of role. This is what makes the funder-creates-and-transfers flow work — after the project’s companyId is rewritten to the recipient, the original setter (now a funder, not the owner) keeps write access on the fields they set.
  • Transfer rewrites the lock owner for episode-coupled fields: on transfer accept, fieldMetadata.multiEpisode.setBy and fieldMetadata.numberOfEpisodes.setBy are rewritten to the new owner. These two fields’ UI writes recurse into episode-collection CRUD; post-transfer the underlying episodes belong to the new owner, so the lock owner must follow ownership. See Project Transfer for the rationale and the EPISODE_COUPLED_FIELD_NAMES constant.
  • Removing a funder from a project releases their locks: when a funder is removed via updateScreenProjectFormData, every fieldMetadata entry whose setBy matches the removed funder is deleted and the associated formData value is cleared. This avoids stranded locks for an unrelated company.
  • remove="true" on XML elements unlocks via deletion: deleting a project or episode via the XML remove attribute clears the document’s fieldMetadata entirely along with the underlying data. There is no field-granular unlock — to release a single field’s lock the broadcaster must delete and re-create the containing record.

If multiple broadcasters fund the same project, first-funder-wins applies per-field via setFieldWithLock. A second broadcaster’s XML for the same field is silently rejected. The owning company can edit a field only if no broadcaster has locked it AND the matrix grants them 'rw'.

useFieldAccess derives helpers from the myProjectAccess response:

  • isEditable(field) — true when the API says editable: true. Drives input disabled props in the form.
  • isVisible(field) — true when the API says visible: true. Drives whether a row renders at all.
  • getLockInfo(field) — returns { reason, lockedBy } when FIELD_LOCKED, or a synthesised tooltip for ROLE_RESTRICTED fields naming the project’s owning company (suppressed when the viewer’s own company is the owner). Drives the lock icon and “locked by …” tooltip.

The producer dashboard shows locked fields disabled with a lock icon naming the broadcaster. The broadcaster’s view (where the lock was acquired by them) shows the field as editable — own locks are not surfaced as “locked” in the UI.

fieldMetadata records lock state (who set the field, when, via what channel). The combined enforcement — matrix + overlay + time-lock — lives in ProjectAccessComputer.computeAccess and is surfaced via myProjectAccess. Mutation-side enforcement lives in service-layer code (ProjectFormUpdater, ProjectEpisodeUpdater, ProjectCreator) which re-applies the matrix check and the lock check via assertFieldsEditable. CASL provides the outer auth perimeter (subject-level read / update / delete rules); field-level decisions are made by the access overlay, not by CASL.


  • A field provided by a commissioner-tier broadcaster via XML is locked from producer editing
  • A field provided by a commissioner-tier broadcaster via the TEP UI is locked from producer editing (source-agnostic at storage; channel: 'xml' | 'ui' distinguishes write path)
  • An empty string value from a broadcaster locks the field (empty is a deliberate value)
  • An omitted field does not change the existing value or lock status
  • Re-sending a field in a later push refreshes lockedAt but does not change setBy
  • Omitting a previously-sent field in a later push does NOT unlock it
  • STANDARD-tier producers do NOT acquire locks via the UI channel (the gate at ProjectFormUpdater.ts:403 and ProjectEpisodeUpdater.ts:234)
  • An ancestor lock (e.g. genre) blocks writes to descendant fields (genre.ofcom); a descendant lock blocks writes to its ancestor
  • First funder to lock a field wins — their company ID is recorded in fieldMetadata
  • A second funder’s write attempt is silently skipped (value not changed)
  • Producer can only edit if no funder has locked the field AND the matrix grants them 'rw'
  • The locking funder can refresh the field value in subsequent pushes; their setBy is unchanged
  • When the caller’s company is the setBy of a field’s lock entry, the access overlay returns editable: true regardless of the matrix’s role-based level
  • The mutation-side per-field RBAC check (ProjectFormUpdater, ProjectCreator) skips the restriction for fields where the caller is the lock-owner
  • Hidden fields (visible: false) stay hidden even with an own-lock — the lock does not promote visibility
  • ProjectCreator.ts acquires UI-channel locks on every field a COMMISSIONER-tier caller actually sets at create time (gated to commissioner; matrix-rw-only — see lock gates below)
  • STANDARD-tier creators do not acquire any locks
  • Empty-shaped values (null, undefined, '', [], 0, false) are filtered before the lock loop so a UI default echo doesn’t accidentally lock a field the user never set
  • TransferAcceptor rewrites fieldMetadata.<name>.setBy to the new owner for every name in EPISODE_COUPLED_FIELD_NAMES where an entry exists. Other lock entries keep their original setBy. (See Project Transfer for the canonical flow.)
  • The lock-attribution rewrite is filter-scoped to entries that exist ($exists: true) so missing entries are not silently materialised
  • A completeness test pins membership of EPISODE_COUPLED_FIELD_NAMES so a future schema addition with episode-CRUD side-effects is forced through the constant
  • Removing a funder from a project (via updateScreenProjectFormData.funders) deletes every fieldMetadata entry whose setBy matches the removed funder
  • The associated formData value is cleared when a funder-owned lock is released
  • removeFieldMetadataByCompany is the single source of truth for this cleanup
  • fieldMetadata is stored inline on project and episode documents
  • Each entry contains: source (“broadcaster”), channel ("xml" | "ui"), setBy (ObjectId of the company that holds the lock), lockedAt (timestamp)
  • fieldMetadata is not present on publication events (post-TX data is broadcaster-only; no locking needed)
  • myProjectAccess(projectId) returns the caller’s per-field access: { projectFields, episodeFields, contributorFields, privateCompanyDataFields, timeLocked }
  • Each FieldAccess carries { field, visible, editable, reason?, lockedBy? }
  • reason is set only when editable: false. Values: ROLE_RESTRICTED (matrix denied), TIME_LOCKED (delivery-date + 30d expired and project APPROVED), FIELD_LOCKED (foreign lock)
  • lockedBy is the name of the locking company, set only when reason === FIELD_LOCKED. Own-locks never set lockedBy (the field is editable).
  • privateCompanyDataFields is empty for non-funders
  • ProjectFormUpdater.update performs per-field RBAC (matrix) then lock check (assertFieldsEditable) before the underlying DB write
  • Matrix-restricted fields that are unchanged from the existing value are silently stripped (the client echoes the full form; the server collapses no-op writes); a real change attempt raises UNAUTHORIZED_FIELD_ACCESS
  • A lock conflict raises a BAD_USER_INPUT validation error with code FIELD_LOCKED_BY_BROADCASTER, including the blocking lock path in params
  • Locked fields render with a lock icon and “locked by ” tooltip
  • Producer mutations that attempt to modify a locked field are rejected by the API (defence in depth — the client disables the input via useFieldAccess)
  • Hidden fields (visible: false) are omitted from the form entirely
  • ROLE_RESTRICTED fields with no lockedBy fall back to the project’s owning company name in the tooltip, suppressed when the viewer is the owner

{
"fieldMetadata": {
"projectName": {
"source": "broadcaster",
"channel": "xml",
"setBy": "<funderCompanyId>",
"lockedAt": "2026-04-15T10:30:00Z"
},
"genre.ofcom": {
"source": "broadcaster",
"channel": "ui",
"setBy": "<funderCompanyId>",
"lockedAt": "2026-04-15T10:30:00Z"
}
}
}

Field paths. Dot-notation for nested fields (genre.ofcom, genre.ofcomSuper, projectGenre.commissioner). Top-level fields use their plain name (projectName, multiEpisode, numberOfEpisodes).

The field-path layer inside fieldMetadata is the short form (no formData. prefix). The matrix and the access response use the full form (formData.projectName). applyFieldLockOverlay strips the first dot-segment of the matrix path to align with the metadata key.

Access Matrix (relationship × field × tier)

Section titled “Access Matrix (relationship × field × tier)”

PROJECT_FIELDS_MATRIX, EPISODE_FIELDS_MATRIX, CONTRIBUTOR_FIELDS_MATRIX, and PRIVATE_COMPANY_DATA_FIELDS_MATRIX live in src/schema/screenProject/serviceLogic/ProjectAccessMatrix.ts. Each entry has the shape:

type FieldMatrixEntry = Record<MatrixRelationship, AccessLevel> & {
ownerTierOverride?: Partial<Record<CompanyTier, AccessLevel>>;
};
type MatrixRelationship = 'owner' | 'funder' | 'systemAdmin' | 'industryAdmin';
type AccessLevel = 'rw' | 'r' | null; // null = hidden

Most fields are owner: 'rw', funder: 'r', systemAdmin: 'rw', industryAdmin: 'r' — producer-authored, funders read-only, system admin unrestricted, industry admin read-only.

Ofcom genre is the only matrix entry with ownerTierOverride. The base entry is owner: 'r', funder: 'rw' (funders write, owners read-only by default), with ownerTierOverride: { [COMMISSIONER]: 'rw' }. This encodes the unified rule “COMMISSIONER tier ⇒ ofcom-writable; STANDARD tier ⇒ read-only” without splitting the funder column. A commissioner-tier owner (broadcaster-as-owner, XML-lite supporting flow) can write ofcom; a standard-tier owner (production company) cannot. Funders are commissioner-tier by definition, so they always get 'rw'.

Hidden fields use null for the relationship, producing { visible: false, editable: false } so the client drops the row entirely. Example: formData.productionNationality is null for funders.

effectiveAccessLevel(entry, role, ownerTier) is the resolver — for role === 'owner' and a matching ownerTier it returns the override; otherwise it returns the base entry. matrixHasOwnerTierOverride(matrix) lets callers skip the owner-tier resolution path (one DB read) when no entry in the matrix can fire the override.

isFieldWritablePerMatrix is the writability helper used by the mutation-side per-field RBAC strip:

  • Exact match in the matrix → writable iff at least one role grants 'rw'
  • No exact match but the path is a registered parent (e.g. the client sends genre: null which flattens to genre) → writable iff at least one child path would be writable for the same roles
  • No entry and no children → uncontrolled, treated as writable

src/schema/utils/fieldLocking/setFieldWithLock.ts — pure function, immutable input.

function setFieldWithLock(
existingMetadata: FieldMetadata,
fieldName: string,
funderCompanyId: ObjectId | string,
channel: 'xml' | 'ui',
): { metadata: FieldMetadata; locked: boolean };

Semantics (first-funder-wins):

  • No existing entry, no conflicting ancestor / descendant → create the lock, return locked: true (caller SHOULD write the value)
  • Existing entry, same funder owns it → refresh lockedAt and channel, return locked: true
  • Existing entry, different funder → no change, return locked: false (caller MUST skip the value)
  • No exact match, but an ancestor or descendant lock owned by a different funder → return locked: false

The ancestor / descendant scan prevents bypassing first-funder-wins via parent / child paths (legacy genre lock vs new genre.ofcom dot-path key, etc.).

Lock acquisition is gated on tier === COMMISSIONER at both call sites (ProjectFormUpdater.ts:403, ProjectEpisodeUpdater.ts:234, ProjectCreator.ts:378). STANDARD-tier callers never acquire UI-channel locks.

src/schema/utils/fieldLocking/assertFieldsEditable.ts — throws BAD_USER_INPUT (FIELD_LOCKED_BY_BROADCASTER) when any of the named fields conflicts with an existing lock owned by a different company:

function assertFieldsEditable(
fieldMetadata: FieldMetadata | undefined,
fieldNames: string[],
userCompanyId: { toString(): string } | string,
): void;

The check covers three relationships per (field, lockKey) pair, all symmetric:

  1. Exact matchfield === lockKey
  2. Ancestor locklockKey is a parent of field (e.g. lock genre blocks write to genre.ofcom)
  3. Descendant locklockKey is a child of field (e.g. lock genre.ofcom blocks write to genre)

Own-locks (entry.setBy === userCompanyId) pass all three cases (refresh semantics). The error includes the blocking lock path in params.lockPath so the client can surface which lock is the obstruction.

Lock-on-create / lock-on-update (ProjectCreator, ProjectFormUpdater, ProjectEpisodeUpdater)

Section titled “Lock-on-create / lock-on-update (ProjectCreator, ProjectFormUpdater, ProjectEpisodeUpdater)”

Each write path runs in this order:

  1. Per-field RBAC strip — for each dot-path the caller is writing, check isFieldWritablePerMatrix. If the matrix denies and the field is the lock-owner of the caller’s company, the matrix restriction is bypassed (lock-owner override). Otherwise, if the incoming value is effectively equal to the existing value (the form echoes unchanged fields the caller couldn’t edit anyway), the field is silently stripped. A real change attempt raises UNAUTHORIZED_FIELD_ACCESS.

  2. Lock checkassertFieldsEditable(existingMetadata, changedDotPaths, userCompany). Foreign locks raise FIELD_LOCKED_BY_BROADCASTER.

  3. Lock acquisition — if tier === COMMISSIONER, every actually-changed dot-path is fed through setFieldWithLock. The accumulated metadata is persisted alongside the data write.

The “actually changed” filter uses valuesAreEffectivelyEqualForRbacStrip (in effectivelyEqual.ts): values are treated as equal when (a) their JSON serialisations match exactly, or (b) both are “empty defaults” (null / undefined / '' / [] / 0 / false). This lets the tep-client safely echo full form state — including default-shaped values on read-only fields — without tripping RBAC or lock-conflict errors on no-op writes.

Funder removal cleanup (removeFieldMetadataByCompany)

Section titled “Funder removal cleanup (removeFieldMetadataByCompany)”

src/schema/utils/fieldLocking/removeFieldMetadataByCompany.ts returns a new FieldMetadata map with every entry whose setBy matches the removed company stripped, plus the list of removed keys. ProjectFormUpdater calls it when the funders array changes, then clears the associated formData values so the project doesn’t carry stale data the removed funder set.

src/schema/screenProject/serviceLogic/ProjectAccessComputer.ts composes matrix → lock overlay → time-lock overlay. The lock branch:

function applyFieldLockOverlay(
fields: FieldAccess[],
fieldMetadata: FieldMetadata,
userCompanyId: string,
companyNameMap: Map<string, string>,
): FieldAccess[] {
return fields.map((f) => {
const shortFieldName = stripFirstSegment(f.field);
const lockEntry = findFieldLockEntry(fieldMetadata, shortFieldName, userCompanyId);
if (!lockEntry) return f;
if (!f.visible) return f; // hidden fields stay hidden — don't leak existence
const setByStr = lockEntry.setBy.toString();
// Own-company lock overrides the matrix: the company that set the field
// can always edit it. Required for the funder-creates-and-transfers flow
// (lock-owner-overrides-matrix rule).
if (setByStr === userCompanyId) {
if (f.editable) return f;
return { field: f.field, visible: f.visible, editable: true };
}
// Foreign lock: lock the field even if matrix said `rw`, upgrade the
// reason to FIELD_LOCKED, attach the locker's company name.
const lockedBy = companyNameMap.get(setByStr);
return {
field: f.field, visible: f.visible, editable: false,
reason: 'FIELD_LOCKED', ...(lockedBy ? { lockedBy } : {}),
};
});
}

findFieldLockEntry does an exact-match scan first, then walks ancestor / descendant relationships. When multiple sub-fields are locked by different companies (e.g. genre.ofcom by funder A and genre.commissioner by the caller), a foreign lock takes precedence — the parent field shows as locked if any child is foreign-locked.

companyNameMap is built once per access request from the unique setBy IDs in the metadata, deduplicating DB lookups across many locked fields.

src/modules/xmlIngestion/processing/core/FileProcessor.ts handles remove="true" on Project, Episode, and Publication elements. The deletion path clears the entire underlying document, which by definition clears its fieldMetadata. There is no field-granular unlock — to release a single field the broadcaster must delete and re-create the containing record. This was deliberate per CDN: in practice, data ownership is unambiguous from the start.

FieldAccess and ProjectAccess (src/schema/screenProject/schema.graphql):

enum FieldAccessReasonEnum {
"The user's role grants no write access to this field."
ROLE_RESTRICTED
"The project is past its delivery date + 30 days and is APPROVED."
TIME_LOCKED
"A different company has locked this field via fieldMetadata."
FIELD_LOCKED
}
type FieldAccess {
field: String! # dot-separated, e.g. "formData.projectName"
visible: Boolean!
editable: Boolean!
reason: FieldAccessReasonEnum # only set when editable: false
lockedBy: String # only set when reason === FIELD_LOCKED;
# the locking company's name
}
type EpisodeAccess {
episodeId: ObjectID!
fields: [FieldAccess!]!
}
type ProjectAccess {
timeLocked: Boolean!
projectFields: [FieldAccess!]!
episodeFields: [EpisodeAccess!]! # one per episode, each with its own lock overlay
contributorFields: [FieldAccess!]!
privateCompanyDataFields: [FieldAccess!]! # empty for non-funders
}
extend type Query {
myProjectAccess(projectId: ObjectID!): ProjectAccess
}

The query is the single read-side surface for field-level access. The client doesn’t read fieldMetadata directly — myProjectAccess is the precomputed, per-caller view.

Mutation error code for lock conflicts:

throwBadUserInputError('input', {
validationErrors: [{
field: 'projectName',
code: 'FIELD_LOCKED_BY_BROADCASTER',
params: { lockPath: 'projectName' }, // may be ancestor / descendant key
}],
});

useFieldAccess (src/hooks/useFieldAccess.ts) is the read-side helper that derives ergonomic predicates from a ProjectAccess payload:

const { isVisible, isEditable, getLockInfo, hasPrivateAccess } =
useFieldAccess(access, queryError, { companyName, companyId, viewerCompanyId });

Graceful degradation:

  • access null / undefined (API not deployed) → permissive (visible / editable default to true)
  • queryError === true (transient network failure) → restrictive (editable defaults to false)

The hook’s ROLE_RESTRICTED fallback synthesises a tooltip pointing at the project’s owning company when the caller’s matrix-derived reason is ROLE_RESTRICTED but no explicit lockedBy was returned. This is suppressed when the viewer’s own company is the project owner — a producer viewing their own project on a role-restricted field (e.g. ofcom for a standard-tier owner) would otherwise see a misleading “locked by [your own company]” tooltip.

Form rendering (ProjectFormContent, ProjectDetailsForm, FormRender, FieldRenderer):

  • Map each config field to { ...field, disabled: !editable || !fieldAccess.isEditable(field.name), lockInfo: fieldAccess.getLockInfo(field.name) }
  • FieldRenderer renders a lock-icon adornment when lockInfo is set, and disables the underlying Joy UI input when disabled: true
  • Hidden fields are filtered out of the section before render, so the row doesn’t show at all

The matrices live in ProjectAccessMatrix.ts. The current lists (subject to change as the spec evolves):

PROJECT_FIELDS_MATRIX covers formData.* paths: projectName, shortProjectName, contentForm, contentType, contentGenre, genre.ofcom, genre.ofcomSuper, medium, multiEpisode, numberOfEpisodes, coProduction, productionNationality, funders, ambassador, nonConsentingContributors, startDate, deliveryDate, seriesIdentifier, budgetRange, averageRunningTime, primaryReleasePlatform, emailMessage, otherFunders, sizeOfPostProductionCrew.

EPISODE_FIELDS_MATRIX covers episode.title, episode.number, episode.slotLength, episode.releaseDate. Every UI-writable episode field has funder: 'r' — funders cannot UI-write episode data. (XML ingestion uses a separate channel and bypasses this matrix.)

CONTRIBUTOR_FIELDS_MATRIX covers contributor.name, contributor.email, contributor.role, contributor.type, contributor.character. Funders see name/email as null (hidden) and the rest as 'r' (visible-but-not-editable).

PRIVATE_COMPANY_DATA_FIELDS_MATRIX covers per-funder data fields (the privateCompanyData array on ScreenProject). Each entry is per-funder and only the funder it belongs to has access.

EPISODE_COUPLED_FIELD_NAMES is a separate constant (not a matrix) enumerating the project-level fields whose UI writes create or delete documents in the episode collection. Today: ['multiEpisode', 'numberOfEpisodes']. This is the canonical list consumed by TransferAcceptor for the post-transfer setBy rewrite.

  • All publication-event fields (post-TX data is broadcaster-only — no producer surface to lock against)
  • System-managed identifiers: externalEpisodeIds, makerIds, the project _id

  • Unit tests for setFieldWithLock: no-entry / same-funder refresh / different-funder skip / ancestor conflict / descendant conflict
  • Unit tests for assertFieldsEditable: exact / ancestor / descendant blocks; own-lock refresh passes; missing metadata is a no-op
  • Unit tests for removeFieldMetadataByCompany: removes only entries matching the company id, preserves the rest
  • Unit tests for effectiveAccessLevel + matrixHasOwnerTierOverride: ofcom override fires for COMMISSIONER owner; non-owner roles ignore the override
  • Unit tests for isFieldWritablePerMatrix: exact entry / parent-with-children / uncontrolled
  • Unit tests for applyFieldLockOverlay: own-lock upgrades editable: true, foreign-lock sets FIELD_LOCKED with lockedBy, hidden field stays hidden
  • Unit tests for findFieldLockEntry: foreign lock wins over own match when both are ancestor / descendant; exact wins over compound
  • Integration test: producer mutation rejected with FIELD_LOCKED_BY_BROADCASTER when a foreign lock exists
  • Integration test: funder A locks via UI, funder B’s UI write is silently skipped (value not changed, no error to the caller)
  • Integration test: lock-on-create writes fieldMetadata for every actually-set field on a COMMISSIONER-tier creation; no metadata is written for a STANDARD-tier creation
  • Integration test: removing a funder from the project clears every fieldMetadata entry whose setBy matches the removed funder; associated formData values are cleared
  • Integration test: remove="true" on an XML Project element clears all fieldMetadata along with the underlying document
  • Integration test: transfer accept rewrites fieldMetadata.multiEpisode.setBy and fieldMetadata.numberOfEpisodes.setBy to the new owner; other locks are untouched (see Project Transfer)
  • E2E test: COMMISSIONER tier creates a project, sets fields; the producer-side access overlay returns FIELD_LOCKED, lockedBy: <broadcaster> for those fields. Caller’s own view returns editable: true on every locked field they set.