πŸ“ Domain Model & Business Rules

πŸ“‹ Summary

Midshift's business logic, meaning the rules about who may change what, how ad names tie copy, files and links together, when an approval stops counting and when files may be deleted, mostly lives in small, heavily commented TypeScript modules under src/lib/. Most of those modules are pure functions with their own unit tests. In that respect it is better organised than most products of its size. The authors work to a "one rule, one place" principle and write down the reason for each rule beside it.

The weaker part is where the rules meet the API routes. The permission checks are assembled separately in each route, and the test harness has its own copy of them. We found several places where routes disagree with each other or with the project's own documentation. Two examples: a re-imported copy deck can change carousel card wording without reopening an approval that a manual edit would reopen; and anyone on a job can remove any other member, including the brand manager. File deletion is guarded carefully, but one of the two dates that starts the deletion clock can be stamped by anyone who holds the public share link. None of these issues is hard to fix, but a new owner should fix them before relying on the approval and custody trail as a record.

🎯 Rating

🟑 ●●●○○ 3 / 5 β€” Amber (Adequate). Rules are clear and mostly centralised in tested pure functions (strong). The rating is held back by inconsistent composition in the routes: the permission logic is duplicated (in each route, and again in the QA store), and some rules contradict each other (approval reset, member removal, UTM tokens). Of the 75 catalogued rules, 29 have no unit test and 9 are only partly tested, and the API-layer composition has almost none (api-auth.ts at 3.6% coverage, src/app/api at 0.9%).

πŸ” Findings

Part A β€” Domain map

A.1 Entities

EntityTable(s)Role in the domainEvidence
Campaign / jobcampaignsRoot of nearly everything. kind ∈ season | concept | rollout | ooh (src/types/database.ts:575); status ∈ draft | active | paused | complete | archived (:5). A season groups jobs; a rollout reads a concept job's pack and video by reference (linked_campaign_id, season_id).[Observed] src/types/database.ts:5,575; src/lib/concept-cut.ts:177-188
Ad unitad_unitsThe core work item: copy fields, channel/format/objective/audience/phase (plan-owned), placement, destination URL + generated UTM, copy_status / design_status / live_status, final_file_id, concept_unit_id.[Observed] database.ts:30-32; ad-unit PATCH route
Framead_unit_framesCarousel cards or Journey states (seq, caption, per-card copy, image_url). Lowest seq image is the unit cover.[Observed] upload-previews/route.ts:79-181
Audience, phase, productaudiences, campaign_phases, campaign_productsPlan dimensions referenced by ad units. Audiences are created implicitly by deck import.[Observed] import-copy/route.ts:170-185
UserusersGlobal tier role ∈ admin | editor | reviewer, plus a default discipline.[Observed] database.ts:3
Membercampaign_membersPer-job tier role ∈ viewer | editor | admin, plus a per-job discipline (9 values). Unique per (campaign, user).[Observed] database.ts:36-42; baseline.sql:386
Brief / brief versionbriefs, brief_versionsBrief document, submit status, two approval stamps (am/client). Every submit writes a version; taken_at/by is the pick-up lock.[Observed] brief/route.ts:54-69,139-145; take/route.ts:35-45
Unit file / review roundunit_files, review_roundsVersioned review cuts and finals per unit; status ∈ in_review | changes_requested | approved; kind review | final.[Observed] review/files/[fileId]/route.ts; final-grid.ts:157-184
Unit commentunit_commentsNotes on a unit, tagged area (copy | artwork | link) and round.[Observed] unit-comments/[commentId]/route.ts
Share linkcampaign_share_linksBearer token for the public Final grid (/preview/<token>); expiry; old pw_ Present-wall rows kept.[Observed] src/lib/share-links.ts
Asset spec / format libraryasset_specsFormats, sizes, per-format copy limits, declared_at_booking, deprecated.[Observed] channel-fields.ts:161-185; final-grid.ts:87
Business unit / categorybusiness_units, campaign_categoriesOrganisational grouping with color, hosts_occasions, position.[Observed] CLAUDE.md 055/063/076, schema
Task / key datetasksCritical-path dates; status doubles as the "already announced overdue" marker.[Observed] overdue.ts:11-17
Activity logactivity_logAudit trail. It is also the only store of concept approvals.[Observed] concept.ts:30-57; brief/route.ts:105-108

A.2 Value objects

Value objectDefinitionEvidence
Ad name (join key)Audience_Product_Format_phN_Objective[_org]; tags aw/cons/conv (legacy con/act are read but never written). Compared through normalisation.plan-change.ts:35-58
UTM template + tokensAccount-level query-string template; tokens channel, campaign, ad_name, audience, phase; per-unit override columns win.utm-template.ts:24-36,120-127
Placementpaid | organic; with channel='ooh' it decides whether a unit owes a link.placement.ts:9-21
Discipline / Area9 disciplines Γ— 11 areas edit matrix; comment areas copy/artwork/link.discipline.ts:19-75,143-171
Channel / format / ad type / aspectEnumerations, with aspect β†’ channel compatibility for file routing.asset-match.ts:186-237
Approval stagecopy β†’ design β†’ live.approval-events.ts:44-72
Custody datesarchived_at, dam_archived_at (+ _by xor _label).retention.ts:55-63; custody.ts

A.3 Aggregates and consistency boundaries

  • Campaign aggregate: members, phases, audiences, products, tasks, brief and share link. Most writes are gated by membership checks against this root (api-auth.ts:112-137).
  • Ad-unit aggregate: unit, frames, comments, unit files. The PATCH route writes the unit and its cards in one request but not in one transaction. It compensates by undoing the reset itself ([unitId]/route.ts:195-202). The deck import deletes and then re-inserts frames, also non-transactionally (import-copy/route.ts:381-395). [Observed]
  • Cross-aggregate reference: a rollout unit points at a concept job's unit (concept_unit_id) and reads its files live (concept-cut.ts:10-28). [Inferred] Retention runs per campaign, so purging an archived concept job's unit_files would also remove the finals that live rollouts reference. Nothing in purge-review/route.ts:81-92 checks for inbound references across campaigns. It only checks shared storage paths.
  • No uniqueness invariant on the join key at the database level: ad_units has no unique constraint on (campaign_id, ad_name) (grep of baseline.sql for unique shows none on ad_units). Uniqueness is enforced only in application code (plan uniqueName, import match-by-name) and checked again at the exits (handover "twins" check, render-push). [Observed]

A.4 Domain services

ServiceModuleResponsibility
Copy-deck parsersrc/lib/copy-deck-parser.ts (828 lines)Two grammars (bracket and flexible); produces ParsedAdUnit[] + warnings
Copy import policysrc/lib/copy-import.ts, approval-events.tsWhat a deck may write onto an existing unit; when an approval reopens
Asset matchersrc/lib/asset-match.tsFilename β†’ unit(s), version and card index
Namingsrc/lib/plan-change.tsBuild, rename and de-duplicate ad names
UTM buildersrc/lib/utm-template.tsTemplate validation and link construction
Approval eventssrc/lib/approval-events.tsNext stage and its activity name
Permissionssrc/lib/api-auth.ts, discipline.tsTier Γ— discipline Γ— campaign status
Retention / custodysrc/lib/retention.ts, custody.ts, cron/purge-reviewWhen files may be deleted; who confirmed ingest
Final grid / handoversrc/lib/final-grid.tsWhat counts as final; checks; channel CSVs; zip tree
Concept cutsrc/lib/concept-cut.tsRollout β†’ concept video, by reference
For-you worklistsrc/lib/for-you.ts (forYouItems CCN 109)Per-person derived to-do list
Overduesrc/lib/overdue.ts, cron/overdueLate detection and one-time notification
Briefs waitingsrc/lib/briefs-waiting.tsOversight queue for untaken brief sends

A.5 Bounded contexts (sub-domains)

ContextCore / supportingOwnsTalks to
Planning & namingCorePlan rows β†’ ad units, ad namesCopy (names are the join key), Assets, UTMs
CopyCoreCopy fields, deck import, copy approval, copy roundsNaming (match by name), Approvals
Artwork & reviewCoreUnit files, versions, finals, design approval, frames' imagesNaming (filename matcher), Handover
Trafficking (links)CoreDestination, UTM, placement, build trackerSettings (template), Handover
Handover & custodyCoreFinal grid, share link, downloads, ingest, retentionArtwork, Archive (external DAM)
Briefing & conceptSupportingBrief, versions, concept approval, season familyJobs (seats takers), Activity log
Access & rolesGenericTier, discipline, membership, protected accountsEvery context
Notifications & oversightSupportingBell, overdue sweep, for-you, briefs waitingEvery context (reads)

The contexts share a single Postgres schema and the ad_units row. They are logical modules, not deployable boundaries. Interaction is synchronous, through shared tables. [Observed]

A.6 Domain map (diagram)

(Syntax validated with the Mermaid validator: valid: true, type flowchart.)

Part B β€” Business rules catalogue

Legend: Type S = structural, B = behavioural, C = constraint, D = derivation. Test: Yes = a vitest case exercises the rule directly; Partial = the helper is tested but the route composition is not; No = no unit test (journey specs run against the QA harness store, which reimplements the routes; see F-9). Line numbers verified against the working tree.

B.1 Naming & matching

#RuleLocationTypeTriggerTest
N1An ad name is built as Audience_Product_Format_phN_Objective[_org]. Audience spaces are removed; product words are joined with _ and any phN word is dropped; only the format's first word is used; tags are aw/cons/conv.src/lib/plan-change.ts:35-58S/DPlan Confirm creates unitsβœ… Yes (plan-change.test.ts)
N2Legacy objective tags con/act are read (rename, matcher) but never written, and a name is never renamed for its spelling alone.plan-change.ts:36-40,87-95; asset-match.ts:239-249CPlan row edit; file drop◐ Partial
N3A plan-row edit renames its units by segment (audience, phase, objective, format, product, _org). A name off the pattern keeps its own name and is reported as kept.plan-change.ts:74-133BPlan row changeβœ… Yes
N4A collision gets _2, _3… (uniqueName). The plan route passes its own inline normaliser.plan-change.ts:135-141; plan/route.ts:53,183,220CPlan Confirm / renameβœ… Yes (plan-change.test.ts:84)
N5Matching is case- and separator-insensitive. Three normalisers exist: normalize (also folds en/em dashes and filesystem-illegal characters, asset-match.ts:16-18), normalizeAdName (copy-import.ts:33-35) and the plan route's inline norm (plan/route.ts:53). Two names can be "different" to the importer yet "twins" to the handover.as listedDEvery name comparison◐ Partial (each tested alone; divergence untested)
N6Drop matching tries the exact name first: aspect suffix, then version suffix, then card index, then the descriptive strip. A trailing _NN counts as a card index only on the fallback.asset-match.ts:276-294 (dropTarget)BAsset dropβœ… Yes (98% line cov.)
N7A review file fans out to several units only if: it carries an aspect token; the content stem is β‰₯ 4 characters; it shares β‰₯ 2 tokens with the unit name (or 1 for a single-token name); the unit's channel suits the aspect; and paid/organic agree. More than 8 hits means refuse (the file goes to the unmatched tray).asset-match.ts:320-373, aspect map :192-200B/CReview uploadβœ… Yes
N8Version of a review drop: an explicit _vNN wins; otherwise newest+1, except that a carousel card joins the newest version when that version already holds a sibling card.asset-match.ts:167-178DReview uploadβœ… Yes
N9Two ready units whose names normalise the same block the handover pack ("twins"). Render-push refuses the same pair.final-grid.ts:334-339; render-push/route.ts:159-166CZip download; render pushβœ… Yes (handover-tree.test.ts)

B.2 Copy import, approvals & review

#RuleLocationTypeTriggerTest
C1Deck grammar is detected automatically: ## [ β†’ bracket parser; any line that is a flexible header β†’ flexible parser; otherwise bracket.copy-deck-parser.ts:824-828DImportβœ… Yes
C2A section banner (SOCIAL CONSIDERATION META) sets objective and channel, but only after a blank line (freshStart).copy-deck-parser.ts:536-548BParsingβœ… Yes (baseline 22 units)
C3A bare-name unit starts only when: a channel section is active; no pipe unit is open; the line is not a [bracket] or a label; and the next non-empty line is a field.copy-deck-parser.ts:557-572BParsingβœ… Yes
C4Copy on image:, Image(s), Brand, Moving are skipped (designer instructions); bracketed placeholders are stripped from values.copy-deck-parser.ts:411-432CParsingβœ… Yes
C5Audience boilerplate ((…), "duplicated", "from consideration") is stripped. Only a segment containing an audience keyword counts.copy-deck-parser.ts:389-409DParsingβœ… Yes
C6Card set: β‰₯ 2 bare ALL-CAPS markers followed by a field label (and not preceded by one). A multi-line headline (or CTA, or Journey body) with exactly one line per card is distributed across the cards.copy-deck-parser.ts:626-656,673-678BCarousel/Journey parsingβœ… Yes
C7Defaults: objective = consideration unless the name says awareness/conversion. With no format tag, Google and TikTok become video.copy-deck-parser.ts:123-128,133-138DParsing a new unit◐ Partial
C8On a unit that exists, a deck writes only the six copy fields. Plan facts (channel, type, objective, format, audience, phase) are kept. A deck name with no match inserts a new unit using the header's tags (including C7's defaults).copy-import.ts:21-30; import-copy/route.ts:255-275,294-318CImport commitβœ… Yes (helper); route No
C9Import reopens approved copy (β†’ pending_review, copy_round+1) only when the wording differs after trimming.approval-events.ts:40-42; import-copy/route.ts:83-84,262-272BImport commitβœ… Yes (helper)
C10A manual edit reopens approved copy when it touches a copy field on the unit or on any card (card caption included). If no copy actually landed, the reopen is rolled back with a conditional write.[unitId]/fields.ts:44-58; [unitId]/route.ts:153-159,195-202BAd-unit PATCH❌ No
C11Re-import replaces a unit's cards (delete, then insert) and carries image_url across by seq.import-copy/route.ts:362-395BImport with frames❌ No
C12Approval advances exactly one stage, copy β†’ design β†’ live. Only admin or brand manager may advance it. There is no precondition (copy present, artwork final) and no campaign-status gate.approval-events.ts:61-72; approve/route.ts:22-51BPOST approveβœ… Yes (helper); gate No
C13Copy and design approvals notify; going live does not.approve/route.ts:66-78BApprove❌ No
C14Approving a review file needs canApprove; it is refused (409) if a newer version exists. It sets final_file_id, design_status=approved and production_stage=approved.review/files/[fileId]/route.ts:108-127,186-197BReview approve❌ No
C15Taking back a signed-off final needs canApprove and is refused on complete or archived jobs. Any member may set changes_requested or in_review on a file that has not been signed off.review/files/[fileId]/route.ts:55,94-106CReview status change❌ No
C16"Final" means: approved, on the unit, and at the version of the row final_file_id names. An approved row below that version is superseded.final-grid.ts:160-184DEvery handover readβœ… Yes
C17Resolving the last open note at or above the note's round moves draft copy to pending_review and notifies the approvers.unit-comments/[commentId]/route.ts:76-120BComment resolved❌ No
C18Character limits: the editor resolves a per-format limit from asset_specs (fieldSpecFor); the Final grid check uses the channel default (copyFields). Limits are advisory: no write path refuses over-length copy.channel-fields.ts:161-185 vs final-grid.ts:90-102C/DEdit; Final grid◐ Partial

B.3 Permissions & disciplines

#RuleLocationTypeTriggerTest
P1Staff = global admin only.api-auth.ts:23-25SAll gates❌ No
P2An editor of a job is: not on a closed job; and global admin, or (global role β‰  reviewer and an accepted member with job role editor or admin).api-auth.ts:112-137CEvery write gate❌ No (api-auth 3.6% cov.)
P3canEditCampaign closes archived jobs; canEditOpenCampaign closes archived and complete jobs.api-auth.ts:67-75,104-110CWrites❌ No
P4Discipline Γ— area matrix. PM and traffic share one set; brand manager has brief/plan/season/notes; proofreader edits nothing; no discipline = every area.discipline.ts:58-81S/CcanEditArea, PATCHβœ… Yes (discipline.test.ts)
P5canEditArea = tier gate (optionally the open-job gate) and (staff or mayEdit).api-auth.ts:174-187C20 route files❌ No
P6canApprove = staff, or an accepted member whose discipline (membership, else user default) is brand_manager. No campaign-status gate.api-auth.ts:146-162,193-202CApprovals (4 route files)◐ Partial (mayApprove tested)
P7Ad-unit PATCH refuses the whole save, naming the fields, if any field (unit or card) lies outside the caller's areas. Unmapped fields count as notes.discipline.ts:89-118; [unitId]/route.ts:58-67CPATCH◐ Partial
P8A comment is resolved by the discipline that makes the change (copy β†’ copywriter, artwork β†’ designer, link β†’ media buyer), or by staff, or by a member with no discipline.discipline.ts:150-171; unit-comments/[commentId]/route.ts:33-37CResolveβœ… Yes (helper)
P9Card add, move and remove is open to copy or artwork disciplines.discipline.ts:130-135CFrames routesβœ… Yes
P10Adding a member needs the people area; only a global admin may grant job-role admin; a global reviewer is always seated as viewer.members/route.ts:18,26-39CPOST member❌ No
P11Changing a member's discipline or rights needs people. Removing a member needs only canEditCampaign (any discipline). There is no self-check on discipline changes.members/[memberId]/route.ts:22-49,53-69,75-97CPATCH/DELETE member❌ No
P12Campaign PATCH is admin-only, except for a body that is only brief_text (any editor). status may be set to any of the five values; there is no transition rule.campaigns/[id]/route.ts:33-53CCampaign edit❌ No
P13Owner and ghost accounts cannot be demoted or deleted. Env vars fall back to placeholder emails.protected-accounts.ts:22-35CAdmin user edits❌ No
P14Deleting an ad unit is admin-only and has no campaign-status gate.api/ad-units/[id]/route.ts:16-24CDELETE unit❌ No

B.4 UTMs & placement

#RuleLocationTypeTriggerTest
U1A unit carries a tracking link unless channel='ooh' or placement='organic'. Every screen reads this one rule.placement.ts:19-21DUTMs page, checks, PATCHβœ… Yes
U2Free text containing "organic" means organic; anything else is paid.placement.ts:14-16DPlan inputβœ… Yes
U3A template is valid only if non-empty, starts with ?, yields β‰₯ 1 parameter and contains β‰₯ 1 known token.utm-template.ts:86-95CAdmin saves templateβœ… Yes
U4Token values are slugged (lowercase, _); literal text is left alone; an empty token resolves to ” and its parameter is dropped.utm-template.ts:44-68DLink buildβœ… Yes
U5A per-unit utm_* value overrides the template's parameter; null means "follow the template".utm-template.ts:97-127BLink buildβœ… Yes
U6No link until the destination parses as a URL.utm-template.ts:133-148CLink buildβœ… Yes
U7When any link-source field changes on a link-carrying unit, the PATCH rebuilds generated_utm from the template. generated_utm itself is also in EDITABLE_FIELDS, so a client can write an arbitrary link when no source field changes.[unitId]/fields.ts:9-18,34-36; [unitId]/route.ts:135-144BPATCH❌ No
U8The context passed to the template carries only channel, campaign, ad_name. The {{audience}} and {{phase}} tokens are accepted by U3 but always resolve empty.utm-template.ts:36,156-158DLink build❌ No

B.5 Retention, custody & archive

#RuleLocationTypeTriggerTest
R1Superseded review cuts (kind review, not approved, below the unit's max version) are purged once created more than 14 days ago. The clock runs from the cut's creation, not from when it was superseded, so the documented fortnight does not hold.retention.ts:10-15; purge-review/route.ts:37,41-72BDaily cron 03:00◐ Partial (constant tested; query No)
R2An archived job's files are purgeable only when both archived_at and dam_archived_at are set and the later one is more than 30 days old. Unparseable dates mean keep.retention.ts:55-63; purge-review/route.ts:81-92B/CDaily cronβœ… Yes (retention.test.ts, 11 cases)
R3Purge removes storage objects before rows, skips paths still referenced by surviving rows, and deletes nothing if a reference check fails.purge-review/route.ts:94-147CDaily cron❌ No
R4Retention covers unit_files only. Concept decks (campaign_files), frame images and reference images are outside it.purge-review/route.ts:85-91Sβ€”[Inferred]
R5Archiving expires all share and supplier links now and stamps archived_at on the first archive only.campaigns/[id]/route.ts:103-111BStatus β†’ archived❌ No
R6A signed-in archive-ingest confirmation needs a tier-only editor check (no discipline, no status gate), clears any link-typed label and may be cleared again (done:false).api-auth.ts:96-102; dam-archive/route.ts:26-41CPOST dam-archive❌ No
R7Ingest can also be confirmed through the public share link with a typed name (≀ 60 characters, required). It is refused if already confirmed (first writer wins).custody.ts:16-27; preview/[token]/custody/route.ts:51,96-121BPublic link POSTβœ… Yes (helper)
R8Both crons fail closed without CRON_SECRET.purge-review/route.ts:30-34; overdue/route.ts:27-31CCron call❌ No

B.6 Briefs & concept

#RuleLocationTypeTriggerTest
B1The oversight list is for admins and for users whose default discipline is traffic or project manager.briefs-waiting.ts:25-28CHomeβœ… Yes
B2Each job's latest send is listed while untaken, oldest first. Complete and archived jobs are hidden.briefs-waiting.ts:38-45,77-78DHomeβœ… Yes (helper)
B3"Take it" is one conditional update (taken_at IS NULL); a loser gets 409 naming the holder. A non-member taker is seated as job editor with their default discipline. The route does not check that the version is the latest or that the job is open.take/route.ts:33-53B/CPOST take❌ No
B4Submit stamps in_review and inserts version n+1 (unique per campaign). A document edit after submit returns the brief to draft.brief/route.ts:47-69; baseline.sql:2196BBrief PATCH❌ No
B5Brief approvals am and client are both granted by canApprove (one person can stamp both) and are never cleared by an edit or a resubmit.brief/route.ts:33-35,139-145BBrief approve❌ No
B6Concept approval is stored only as an activity_log event carrying a file_id that is not validated. It counts only while that file is the newest brief deck.brief/route.ts:105-108; concept.ts:52-57DApprove concept❌ No
B7A rollout unit may point (concept_unit_id) only at a unit of the concept job it reads: its direct link, else the earliest concept job in its season. Only kind='rollout' may hold the pointer.[unitId]/route.ts:83-110; concept-cut.ts:177-188CPATCH◐ Partial (conceptCutOf tested)
#RuleLocationTypeTriggerTest
S1Link expiry options are 7/30/90/180 days; any unknown value means 30; an explicit null means never expires.share-links.ts:26-35CMint link❌ No (no share-links.test.ts)
S2Any job editor (no discipline check) mints a link; minting inserts the new link, then revokes older grid links (pw_ rows untouched); revoke is counted (404 if none).share-link/route.ts:19-45,59-67BPOST/DELETE share-link❌ No
S3pw_ (old Present-wall) tokens open nothing on the custody route.share-links.ts:15-21; preview/[token]/custody/route.ts:51CPublic request❌ No
F1Final grid checks: text fits, button chosen (if the channel has a CTA), link added and checked (only if U1 applies), artwork approved.final-grid.ts:94-109DFinal gridβœ… Yes
F2Channel CSV: the CTA is coerced to LEARN_MORE / SIGN_UP / SHOP_NOW (default for anything else); Meta rows are exported PAUSED; one row per placement or size.final-grid.ts:254-272DCSV exportβœ… Yes
F3Custody (download / backed-up) is per unit, not per file. The code records this as a known hole.final-grid.ts:64-69Sβ€”n/a
O1A task is overdue when its due day is before today (UTC todayIso) and it is not complete.overdue.ts:35-38; dates.ts:62DCron, for-youβœ… Yes
O2The sweep rings once: it marks status overdue and notifies; it releases to pending when the task is no longer late. Live jobs only.overdue.ts:41-51; overdue/route.ts:36-68BCron 07:00βœ… Yes (helper)
O3Recipients: the assignee if there is one, else every accepted member of the job.overdue/route.ts:70-86BCron❌ No
O4A second, different lateness rule: missed() also counts a date ticked after it was due. It is used on the Dates page.dates.ts:65-68DDates viewβœ… Yes (dates.test.ts)
O5For-you shows approval items to staff and brand managers only; proofreaders and reviewers see late dates as information only.for-you.ts:196-215DHome/bell◐ Partial (10 tests; forYouItems CCN 109)

Total: 75 rules catalogued (9 naming, 18 copy/approval/review, 14 permission, 8 UTM, 8 retention, 7 brief, 11 share-link/handover/notification). The brief asked for about 35–50; the extra rows came from verifying each area rather than sampling it.

B.8 Contradictions, duplications and dead rules

IDFindingEvidenceClass
F-1The approval reset is inconsistent between edit and import. A manual card edit (including caption) reopens approved copy (C10). A deck re-import rewrites the cards (C11) but checks for reopening against the unit's five fields only (C9), so changed carousel card headlines, which is where carousel headlines live, leave the copy approval standing.fields.ts:51 vs import-copy/route.ts:261-272,381-395Contradiction
F-2There are two paths to design approval. The approve endpoint sets design_status=approved with no file present (C12); approving a review file sets it and final_file_id (C14). The first produces "design approved" with no final, which the Final grid then shows as fa.approve/route.ts:36-47; review/files/[fileId]/route.ts:186-197; final-grid.ts:101Duplication
F-3Member removal is weaker than member addition. DELETE uses canEditCampaign, while POST/PATCH need the people area, so a copywriter can remove the brand manager. Discipline PATCH has no self-check, so a PM or traffic member can set their own discipline to brand_manager (gaining approval) or to null (gaining every area).members/[memberId]/route.ts:22-49,75-97; api-auth.ts:193-202Contradiction / privilege escalation
F-4The brief take route skips the list's own filters. Closed jobs and superseded versions are hidden on Home (B2) but are takeable by direct call (B3), and taking one seats the caller as an editor on that job.briefs-waiting.ts:77-78 vs take/route.ts:37-53Contradiction
F-5A bearer link can start the deletion clock. R7 lets anyone holding the share link (which may be set to never expire, S1) stamp dam_archived_at, one of the two dates R2 needs before purging every unit_files row, finals included. Conversely, archiving expires the link (R5), so the outside archive team that custody.ts:1-12 describes can no longer confirm ingest after the job closes.custody route:96-121; retention.ts:55-63; campaigns/[id]/route.ts:103-107Contradiction
F-6UTM template tokens {{audience}} and {{phase}} are accepted but never filled (U8).utm-template.ts:36,156-158Dead / defect
F-7generated_utm is client-writable, which contradicts the documented rule that a link is "only ever built with buildUtm" (CLAUDE.md Conventions).fields.ts:12Contradiction
F-8Three ad-name normalisers (N5) and two lateness rules (O1 vs O4).as citedDuplication
F-9Permission composition is duplicated in the QA store. canEditCampaign / editorOf / canEditArea / canApprove are reimplemented at src/app/qa/store.ts:761-822, so the journey suite proves the store's copy, not api-auth.ts. In addition, 19 route files check role === 'admin' inline rather than through isStaff (11 files).grep counts in EvidenceDuplication
F-10Approvals bypass the status gate. canApprove and canReviewCampaign ignore campaign status, so copy and design approvals and new final sign-offs are accepted on archived jobs. This contradicts canEditCampaign's "archived is closed for everyone" (api-auth.ts:72-73). Only the reopen of a final restates the gate (C15).api-auth.ts:193-226Inconsistency
F-11The approver notification list differs from the approver rule. ready_for_review notifies job-role admin members (unit-comments/[commentId]/route.ts:110), but canApprove does not let a job-role admin approve.as citedInconsistency
F-12Doc drift (CLAUDE.md vs code): (a) dam-archive "gates on canEditArea(…,'artwork',{closed:true})". The code uses canConfirmArchiveIngest, and canEditArea has no closed option, although its docstring at api-auth.ts:169-172 describes one. (b) "A deck writes copy only onto a unit that exists": the import still inserts unmatched units (C8). (c) "Brief / review / sign-off pipeline… orphaned": briefs and review_rounds are actively read and written.as citedDoc drift
F-13Dead or legacy rules kept: the approvals and utm_log tables have no writers in src outside the QA store; studio_crits / studio_crit_comments have 0 references; is_creative_approver appears only in types and QA fixtures; the checklist/build pages under src/app/(app)/campaign/[id]/{checklist,build} still exist though documented "to be deleted"; there is an empty if block at copy-deck-parser.ts:783-786; agency_notes_parts is never filled in the flexible path (:781,801).grep (Evidence)Dead
F-14Brief approvals are never voided (B5) and concept approval lives only in the activity log (B6), whose writer logActivity swallows its own errors (CLAUDE.md, src/lib/activity.ts). A failed log write loses an approval while the API answers OK.as citedFragile derivation
F-15The join key has no database guarantee: there is no unique index on ad_units(campaign_id, ad_name) (A.3).baseline.sqlMissing invariant
F-16Reference images are stored as one-year signed URLs in ad_units.reference_image_url, so they stop working silently after a year.upload-previews/route.ts:21,145Derivation risk

🧾 Evidence

Files read in full or in the relevant parts: src/lib/{api-auth,discipline,placement,retention,custody,copy-import,approval-events,plan-change,overdue,briefs-waiting,utm-template,asset-match,final-grid,concept-cut,share-links,protected-accounts,concept,brief}.ts, src/lib/copy-deck-parser.ts (lines 79-150, 382-700, 770-828), src/lib/for-you.ts (1-110, 196-216), src/lib/channel-fields.ts (outline); routes campaigns/[id]/ad-units/[unitId]/{route,fields}.ts, …/approve, campaigns/[id]/{dam-archive,share-link,brief,import-copy,members,members/[memberId],route,upload-previews}, ad-units/[id], cron/{overdue,purge-review}, briefs/waiting/[versionId]/take, unit-comments/[commentId], preview/[token]/custody, review/files/[fileId].

Key commands and outputs:

$ grep -n -i "unique" supabase/baseline.sql   # no unique on ad_units(campaign_id, ad_name)
118 users.email unique Β· 386 unique(campaign_id,user_id) Β· 2196 unique(campaign_id, version) …

$ grep -rln "role !== 'admin'\|role === 'admin'" --include=route.ts src/app/api | wc -l   β†’ 19
$ grep -rln "isStaff("        --include=route.ts src/app/api | wc -l                   β†’ 11
$ grep -rln "canEditArea"     --include=route.ts src/app/api | wc -l                   β†’ 20
$ grep -rln "canEditCampaign" --include=route.ts src/app/api | wc -l                   β†’ 13
$ grep -rln "canApprove"      --include=route.ts src/app/api | wc -l                   β†’ 4

$ grep -rn studio_crits src            β†’ 0 hits
$ grep -rn is_creative_approver src    β†’ database.ts:51 + src/app/qa/store.ts fixtures only
$ grep -rn "from('approvals')\|from('utm_log" src (excluding qa) β†’ 0 hits
$ grep -n "mayEdit\|canApprove\|function editorOf" src/app/qa/store.ts β†’ reimplementation at 761-822

Test presence (it/test count): discipline 25, approval-events 21, utm-template 27, asset-match 76,
retention 11, overdue 15, plan-change 15, final-grid 35, concept-cut 15, custody 12, for-you 10,
copy-import 4, briefs-waiting 4, placement 2, copy-deck-parser 3 + baseline 8.
No test file for: api-auth.ts, share-links.ts, [unitId]/fields.ts (copyTouched / framePatches),
any route handler.

Coverage figures (brief, measured earlier): src/lib 67.1%, src/app/api 0.9%, api-auth.ts 3.6%, copy-deck-parser 85%, asset-match 98%, utm-template 100%.

⚠️ Risks

#RiskSeverityEffort
R-1Changed carousel card wording can arrive through a deck re-import while the copy approval stays standing (F-1). The product's core promise ("an approval names who signed off this wording") is broken for carousels.🟠 HighS
R-2Within a job, a PM or traffic member can make themselves brand manager (approval rights), and any editor can remove members, including the approver (F-3). The approval trail can be manufactured or disrupted from inside the team.🟠 HighS
R-3A bearer share link (possibly non-expiring) can stamp the archive ingest date that starts the 30-day deletion of all finals on an archived job (F-5). Retention also ignores cross-job references from rollouts (A.3).🟠 HighM
R-4Permission logic is assembled in each route and copied into the QA store, with near-zero unit coverage of api-auth.ts. Regressions in gates would pass CI (F-9).🟑 MediumM
R-5Approvals are accepted on archived jobs; there are two design-approval paths; brief approvals are never voided; concept approval exists only in a log whose writes can fail silently (F-2, F-10, F-14).🟑 MediumM
R-6The brief take route seats callers on closed jobs or on stale versions (F-4).🟑 MediumS
R-7The join key (ad name) has no database uniqueness, and three normalisers disagree. Duplicates surface late, at handover (F-8, F-15).🟑 MediumM
R-8UTM tokens {{audience}} / {{phase}} silently resolve empty; generated_utm is client-writable. Wrong tracking links mean mis-attributed media spend (F-6, F-7).🟑 MediumS
R-9The superseded-cut clock counts from creation, not supersession (R1), so a cut can be purged the day it is replaced.βšͺ LowS
R-10Doc drift in CLAUDE.md (F-12) misleads maintainers who treat it as the specification; dead tables and pages add noise (F-13).βšͺ LowS
R-11Reference images stored as one-year signed URLs go blank after a year (F-16).βšͺ LowS

βœ… Recommendations

#RecommendationPriorityEffort
1Make the import's reopen check include card copy (CARD_COPY_FIELDS against the prior frames), sharing one helper with copyTouched. Add vitest cases for fields.ts and the import route.P1S
2Gate member DELETE with canEditArea('people'). Forbid changing one's own discipline, or changing anyone to/from brand_manager, unless the caller is admin. Log both.P1S
3Restrict ingest-by-link: require the link to have an expiry, record a confirmation but require a signed-in countersignature before it counts towards R2, or keep the link able to confirm only for an archived job within a short window. Exclude concept units referenced by live rollouts from the purge.P1M
4Give api-auth.ts a test suite against a fake Supabase client, and make src/app/qa/store.ts import a shared pure "decide" function (status, role, membership, discipline β†’ bool) instead of reimplementing it. Replace inline role === 'admin' checks with isStaff.P1M
5Decide on one design-approval path (via a final file only), add a campaign-status gate to canApprove, void brief approvals on resubmit, and persist concept approval in a table rather than in activity_log.P2M
6Apply the latestWaiting and closed-job filters inside the take route.P2S
7Add a unique index on (campaign_id, lower(normalised ad_name)) (via a generated column) after a de-duplication check, and collapse the three normalisers into asset-match.normalize.P2M
8Pass audience and phase into utmContext, or remove them from TOKENS; drop generated_utm from EDITABLE_FIELDS.P2S
9Base the superseded-cut clock on the newer version's created_at.P3S
10Correct CLAUDE.md (F-12); delete the checklist/build pages, the dead parser block, and the writer-less tables via a decided migration. Store storage paths rather than signed URLs for reference images.P3S