Field Locking
References
Section titled “References”Linear issues:
- D2D-116: Discussion of Prod. Co. Lock relating to XML integration
- D2D-2: Design: broadcaster integration - direct XML
- TEP-426 (lock-on-create) / TEP-427 (data access matrix)
Source decisions:
- D2D-116, call 02/03/26: locking rules agreed (Ben’s summary comment 03/03/26)
- D2D-116, Dan’s
removeproposal (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.
Overview
Section titled “Overview”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:
-
Access matrix — per-field role-based access (
owner: 'rw',funder: 'r', …) defined inPROJECT_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. -
Field-lock overlay —
fieldMetadata.<field>.setByrecords which company “claimed” that field. The overlay applies on top of the matrix:- Foreign lock (
setBy !== caller) → downgrade toeditable: false, reason: FIELD_LOCKED, lockedBy: <setter name>. Foreign locks are more restrictive than the matrix: even anowner: 'rw'field becomes read-only. - Own lock (
setBy === caller) → upgrade toeditable: trueif the matrix had said'r'. Own locks are less restrictive than the matrix: the company that set a field can always edit it.
- Foreign lock (
-
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 becomeeditable: false, reason: TIME_LOCKED. (SeecomputeTimeLockedintep-client.) The API serves this asProjectAccess.timeLocked: Booleanso 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.
Locking Rules (as implemented)
Section titled “Locking Rules (as implemented)”- 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
lockedAtandchannelbut does not changesetBy. 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) —
setFieldWithLockreturnslocked: falseand the caller does NOT write the value. - Ancestor / descendant locks compose: a lock on
genreblocks writes togenre.ofcom(ancestor) and a lock ongenre.ofcomblocks writes togenre(descendant). This prevents bypassing first-funder-wins via parent/child paths. The check is symmetric infindFieldLockEntry,assertFieldsEditable, andsetFieldWithLock. - 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
companyIdis 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.setByandfieldMetadata.numberOfEpisodes.setByare 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 theEPISODE_COUPLED_FIELD_NAMESconstant. - Removing a funder from a project releases their locks: when a funder is removed via
updateScreenProjectFormData, everyfieldMetadataentry whosesetBymatches the removed funder is deleted and the associatedformDatavalue 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 XMLremoveattribute clears the document’sfieldMetadataentirely 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.
Multi-Funder Projects
Section titled “Multi-Funder Projects”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'.
What Users See
Section titled “What Users See”useFieldAccess derives helpers from the myProjectAccess response:
isEditable(field)— true when the API sayseditable: true. Drives inputdisabledprops in the form.isVisible(field)— true when the API saysvisible: true. Drives whether a row renders at all.getLockInfo(field)— returns{ reason, lockedBy }whenFIELD_LOCKED, or a synthesised tooltip forROLE_RESTRICTEDfields 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.
Relationship to RBAC
Section titled “Relationship to RBAC”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.
Acceptance Criteria
Section titled “Acceptance Criteria”Locking Rules
Section titled “Locking Rules”- 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
lockedAtbut does not changesetBy - 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:403andProjectEpisodeUpdater.ts:234) - An ancestor lock (e.g.
genre) blocks writes to descendant fields (genre.ofcom); a descendant lock blocks writes to its ancestor
Multi-Funder
Section titled “Multi-Funder”- 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
setByis unchanged
Lock-owner override
Section titled “Lock-owner override”- When the caller’s company is the
setByof a field’s lock entry, the access overlay returnseditable: trueregardless 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
Lock-on-create
Section titled “Lock-on-create”-
ProjectCreator.tsacquires 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
Transfer accept
Section titled “Transfer accept”-
TransferAcceptorrewritesfieldMetadata.<name>.setByto the new owner for every name inEPISODE_COUPLED_FIELD_NAMESwhere an entry exists. Other lock entries keep their originalsetBy. (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_NAMESso a future schema addition with episode-CRUD side-effects is forced through the constant
Funder removal
Section titled “Funder removal”- Removing a funder from a project (via
updateScreenProjectFormData.funders) deletes everyfieldMetadataentry whosesetBymatches the removed funder - The associated
formDatavalue is cleared when a funder-owned lock is released -
removeFieldMetadataByCompanyis the single source of truth for this cleanup
fieldMetadata structure
Section titled “fieldMetadata structure”-
fieldMetadatais 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) -
fieldMetadatais not present on publication events (post-TX data is broadcaster-only; no locking needed)
Read-side exposure (myProjectAccess)
Section titled “Read-side exposure (myProjectAccess)”-
myProjectAccess(projectId)returns the caller’s per-field access:{ projectFields, episodeFields, contributorFields, privateCompanyDataFields, timeLocked } - Each
FieldAccesscarries{ field, visible, editable, reason?, lockedBy? } -
reasonis set only wheneditable: false. Values:ROLE_RESTRICTED(matrix denied),TIME_LOCKED(delivery-date + 30d expired and project APPROVED),FIELD_LOCKED(foreign lock) -
lockedByis the name of the locking company, set only whenreason === FIELD_LOCKED. Own-locks never setlockedBy(the field is editable). -
privateCompanyDataFieldsis empty for non-funders
Write-side enforcement
Section titled “Write-side enforcement”-
ProjectFormUpdater.updateperforms 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_INPUTvalidation error with codeFIELD_LOCKED_BY_BROADCASTER, including the blocking lock path inparams
Producer UI integration
Section titled “Producer UI integration”- 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_RESTRICTEDfields with nolockedByfall back to the project’s owning company name in the tooltip, suppressed when the viewer is the owner
Technical Specification
Section titled “Technical Specification”fieldMetadata structure
Section titled “fieldMetadata structure”{ "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 = hiddenMost 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: nullwhich flattens togenre) → writable iff at least one child path would be writable for the same roles - No entry and no children → uncontrolled, treated as writable
Lock acquisition (setFieldWithLock)
Section titled “Lock acquisition (setFieldWithLock)”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
lockedAtandchannel, returnlocked: 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.
Lock check (assertFieldsEditable)
Section titled “Lock check (assertFieldsEditable)”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:
- Exact match —
field === lockKey - Ancestor lock —
lockKeyis a parent offield(e.g. lockgenreblocks write togenre.ofcom) - Descendant lock —
lockKeyis a child offield(e.g. lockgenre.ofcomblocks write togenre)
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:
-
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 raisesUNAUTHORIZED_FIELD_ACCESS. -
Lock check —
assertFieldsEditable(existingMetadata, changedDotPaths, userCompany). Foreign locks raiseFIELD_LOCKED_BY_BROADCASTER. -
Lock acquisition — if
tier === COMMISSIONER, every actually-changed dot-path is fed throughsetFieldWithLock. 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.
Access overlay (applyFieldLockOverlay)
Section titled “Access overlay (applyFieldLockOverlay)”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.
XML deletion (remove="true")
Section titled “XML deletion (remove="true")”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.
GraphQL surface
Section titled “GraphQL surface”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 }],});Client integration (tep-client)
Section titled “Client integration (tep-client)”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:
accessnull / undefined (API not deployed) → permissive (visible / editable default totrue)queryError === true(transient network failure) → restrictive (editabledefaults tofalse)
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) } FieldRendererrenders a lock-icon adornment whenlockInfois set, and disables the underlying Joy UI input whendisabled: true- Hidden fields are filtered out of the section before render, so the row doesn’t show at all
Field coverage (current matrices)
Section titled “Field coverage (current matrices)”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.
Not locked
Section titled “Not locked”- All publication-event fields (post-TX data is broadcaster-only — no producer surface to lock against)
- System-managed identifiers:
externalEpisodeIds,makerIds, the project_id
Testing Requirements
Section titled “Testing Requirements”- 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 upgradeseditable: true, foreign-lock setsFIELD_LOCKEDwithlockedBy, 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_BROADCASTERwhen 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
fieldMetadatafor 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
fieldMetadataentry whosesetBymatches the removed funder; associatedformDatavalues are cleared - Integration test:
remove="true"on an XML Project element clears allfieldMetadataalong with the underlying document - Integration test: transfer accept rewrites
fieldMetadata.multiEpisode.setByandfieldMetadata.numberOfEpisodes.setByto 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 returnseditable: trueon every locked field they set.