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
Entity
Table(s)
Role in the domain
Evidence
Campaign / job
campaigns
Root 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).
paid | organic; with channel='ooh' it decides whether a unit owes a link.
placement.ts:9-21
Discipline / Area
9 disciplines Γ 11 areas edit matrix; comment areas copy/artwork/link.
discipline.ts:19-75,143-171
Channel / format / ad type / aspect
Enumerations, with aspect β channel compatibility for file routing.
asset-match.ts:186-237
Approval stage
copy β design β live.
approval-events.ts:44-72
Custody dates
archived_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
Service
Module
Responsibility
Copy-deck parser
src/lib/copy-deck-parser.ts (828 lines)
Two grammars (bracket and flexible); produces ParsedAdUnit[] + warnings
Copy import policy
src/lib/copy-import.ts, approval-events.ts
What a deck may write onto an existing unit; when an approval reopens
Unit files, versions, finals, design approval, frames' images
Naming (filename matcher), Handover
Trafficking (links)
Core
Destination, UTM, placement, build tracker
Settings (template), Handover
Handover & custody
Core
Final grid, share link, downloads, ingest, retention
Artwork, Archive (external DAM)
Briefing & concept
Supporting
Brief, versions, concept approval, season family
Jobs (seats takers), Activity log
Access & roles
Generic
Tier, discipline, membership, protected accounts
Every context
Notifications & oversight
Supporting
Bell, overdue sweep, for-you, briefs waiting
Every 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
#
Rule
Location
Type
Trigger
Test
N1
An 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-58
S/D
Plan Confirm creates units
β Yes (plan-change.test.ts)
N2
Legacy objective tags con/act are read (rename, matcher) but never written, and a name is never renamed for its spelling alone.
A 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-133
B
Plan row change
β Yes
N4
A collision gets _2, _3β¦ (uniqueName). The plan route passes its own inline normaliser.
plan-change.ts:135-141; plan/route.ts:53,183,220
C
Plan Confirm / rename
β Yes (plan-change.test.ts:84)
N5
Matching 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.
Drop 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)
B
Asset drop
β Yes (98% line cov.)
N7
A 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-200
B/C
Review upload
β Yes
N8
Version 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-178
D
Review upload
β Yes
N9
Two ready units whose names normalise the same block the handover pack ("twins"). Render-push refuses the same pair.
Deck grammar is detected automatically: ## [ β bracket parser; any line that is a flexible header β flexible parser; otherwise bracket.
copy-deck-parser.ts:824-828
D
Import
β Yes
C2
A section banner (SOCIAL CONSIDERATION META) sets objective and channel, but only after a blank line (freshStart).
copy-deck-parser.ts:536-548
B
Parsing
β Yes (baseline 22 units)
C3
A 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-572
B
Parsing
β Yes
C4
Copy on image:, Image(s), Brand, Moving are skipped (designer instructions); bracketed placeholders are stripped from values.
copy-deck-parser.ts:411-432
C
Parsing
β Yes
C5
Audience boilerplate ((β¦), "duplicated", "from consideration") is stripped. Only a segment containing an audience keyword counts.
copy-deck-parser.ts:389-409
D
Parsing
β Yes
C6
Card 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-678
B
Carousel/Journey parsing
β Yes
C7
Defaults: objective = consideration unless the name says awareness/conversion. With no format tag, Google and TikTok become video.
copy-deck-parser.ts:123-128,133-138
D
Parsing a new unit
β Partial
C8
On 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).
A 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.
Re-import replaces a unit's cards (delete, then insert) and carries image_url across by seq.
import-copy/route.ts:362-395
B
Import with frames
β No
C12
Approval 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-51
B
POST approve
β Yes (helper); gate No
C13
Copy and design approvals notify; going live does not.
approve/route.ts:66-78
B
Approve
β No
C14
Approving 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-197
B
Review approve
β No
C15
Taking 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-106
C
Review 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-184
D
Every handover read
β Yes
C17
Resolving 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-120
B
Comment resolved
β No
C18
Character 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-102
C/D
Edit; Final grid
β Partial
B.3 Permissions & disciplines
#
Rule
Location
Type
Trigger
Test
P1
Staff = global admin only.
api-auth.ts:23-25
S
All gates
β No
P2
An 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-137
C
Every write gate
β No (api-auth 3.6% cov.)
P3
canEditCampaign closes archived jobs; canEditOpenCampaign closes archived and complete jobs.
api-auth.ts:67-75,104-110
C
Writes
β No
P4
Discipline Γ 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-81
S/C
canEditArea, PATCH
β Yes (discipline.test.ts)
P5
canEditArea = tier gate (optionally the open-job gate) and (staff or mayEdit).
api-auth.ts:174-187
C
20 route files
β No
P6
canApprove = staff, or an accepted member whose discipline (membership, else user default) is brand_manager. No campaign-status gate.
api-auth.ts:146-162,193-202
C
Approvals (4 route files)
β Partial (mayApprove tested)
P7
Ad-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-67
C
PATCH
β Partial
P8
A 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.
Card add, move and remove is open to copy or artwork disciplines.
discipline.ts:130-135
C
Frames routes
β Yes
P10
Adding 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-39
C
POST member
β No
P11
Changing 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-97
C
PATCH/DELETE member
β No
P12
Campaign 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-53
C
Campaign edit
β No
P13
Owner and ghost accounts cannot be demoted or deleted. Env vars fall back to placeholder emails.
protected-accounts.ts:22-35
C
Admin user edits
β No
P14
Deleting an ad unit is admin-only and has no campaign-status gate.
api/ad-units/[id]/route.ts:16-24
C
DELETE unit
β No
B.4 UTMs & placement
#
Rule
Location
Type
Trigger
Test
U1
A unit carries a tracking link unless channel='ooh' or placement='organic'. Every screen reads this one rule.
placement.ts:19-21
D
UTMs page, checks, PATCH
β Yes
U2
Free text containing "organic" means organic; anything else is paid.
placement.ts:14-16
D
Plan input
β Yes
U3
A template is valid only if non-empty, starts with ?, yields β₯ 1 parameter and contains β₯ 1 known token.
utm-template.ts:86-95
C
Admin saves template
β Yes
U4
Token values are slugged (lowercase, _); literal text is left alone; an empty token resolves to β and its parameter is dropped.
utm-template.ts:44-68
D
Link build
β Yes
U5
A per-unit utm_* value overrides the template's parameter; null means "follow the template".
utm-template.ts:97-127
B
Link build
β Yes
U6
No link until the destination parses as a URL.
utm-template.ts:133-148
C
Link build
β Yes
U7
When 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.
The 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-158
D
Link build
β No
B.5 Retention, custody & archive
#
Rule
Location
Type
Trigger
Test
R1
Superseded 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.
An archived job's files are purgeable only when botharchived_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-92
B/C
Daily cron
β Yes (retention.test.ts, 11 cases)
R3
Purge 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-147
C
Daily cron
β No
R4
Retention covers unit_files only. Concept decks (campaign_files), frame images and reference images are outside it.
purge-review/route.ts:85-91
S
β
[Inferred]
R5
Archiving expires all share and supplier links now and stamps archived_at on the first archive only.
campaigns/[id]/route.ts:103-111
B
Status β archived
β No
R6
A 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-41
C
POST dam-archive
β No
R7
Ingest 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).
The oversight list is for admins and for users whose default discipline is traffic or project manager.
briefs-waiting.ts:25-28
C
Home
β Yes
B2
Each job's latest send is listed while untaken, oldest first. Complete and archived jobs are hidden.
briefs-waiting.ts:38-45,77-78
D
Home
β 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-53
B/C
POST take
β No
B4
Submit 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:2196
B
Brief PATCH
β No
B5
Brief 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-145
B
Brief approve
β No
B6
Concept 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-57
D
Approve concept
β No
B7
A 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-188
C
PATCH
β Partial (conceptCutOf tested)
B.7 Share links, handover & notifications
#
Rule
Location
Type
Trigger
Test
S1
Link expiry options are 7/30/90/180 days; any unknown value means 30; an explicit null means never expires.
share-links.ts:26-35
C
Mint link
β No (no share-links.test.ts)
S2
Any 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-67
B
POST/DELETE share-link
β No
S3
pw_ (old Present-wall) tokens open nothing on the custody route.
Final 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-109
D
Final grid
β Yes
F2
Channel 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-272
D
CSV export
β Yes
F3
Custody (download / backed-up) is per unit, not per file. The code records this as a known hole.
final-grid.ts:64-69
S
β
n/a
O1
A task is overdue when its due day is before today (UTC todayIso) and it is not complete.
overdue.ts:35-38; dates.ts:62
D
Cron, for-you
β Yes
O2
The 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-68
B
Cron 07:00
β Yes (helper)
O3
Recipients: the assignee if there is one, else every accepted member of the job.
overdue/route.ts:70-86
B
Cron
β No
O4
A second, different lateness rule: missed() also counts a date ticked after it was due. It is used on the Dates page.
dates.ts:65-68
D
Dates view
β Yes (dates.test.ts)
O5
For-you shows approval items to staff and brand managers only; proofreaders and reviewers see late dates as information only.
for-you.ts:196-215
D
Home/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
ID
Finding
Evidence
Class
F-1
The 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-395
Contradiction
F-2
There 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.
Member 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).
The 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-53
Contradiction
F-5
A 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.
UTM template tokens {{audience}} and {{phase}} are accepted but never filled (U8).
utm-template.ts:36,156-158
Dead / defect
F-7
generated_utm is client-writable, which contradicts the documented rule that a link is "only ever built with buildUtm" (CLAUDE.md Conventions).
fields.ts:12
Contradiction
F-8
Three ad-name normalisers (N5) and two lateness rules (O1 vs O4).
as cited
Duplication
F-9
Permission 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 Evidence
Duplication
F-10
Approvals 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-226
Inconsistency
F-11
The 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 cited
Inconsistency
F-12
Doc 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 cited
Doc drift
F-13
Dead 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-14
Brief 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 cited
Fragile derivation
F-15
The join key has no database guarantee: there is no unique index on ad_units(campaign_id, ad_name) (A.3).
baseline.sql
Missing invariant
F-16
Reference 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,145
Derivation 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].
Changed 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.
π High
S
R-2
Within 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.
π High
S
R-3
A 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).
π High
M
R-4
Permission 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).
π‘ Medium
M
R-5
Approvals 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).
π‘ Medium
M
R-6
The brief take route seats callers on closed jobs or on stale versions (F-4).
π‘ Medium
S
R-7
The join key (ad name) has no database uniqueness, and three normalisers disagree. Duplicates surface late, at handover (F-8, F-15).
π‘ Medium
M
R-8
UTM tokens {{audience}} / {{phase}} silently resolve empty; generated_utm is client-writable. Wrong tracking links mean mis-attributed media spend (F-6, F-7).
π‘ Medium
S
R-9
The superseded-cut clock counts from creation, not supersession (R1), so a cut can be purged the day it is replaced.
βͺ Low
S
R-10
Doc drift in CLAUDE.md (F-12) misleads maintainers who treat it as the specification; dead tables and pages add noise (F-13).
βͺ Low
S
R-11
Reference images stored as one-year signed URLs go blank after a year (F-16).
βͺ Low
S
β Recommendations
#
Recommendation
Priority
Effort
1
Make 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.
P1
S
2
Gate 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.
P1
S
3
Restrict 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.
P1
M
4
Give 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.
P1
M
5
Decide 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.
P2
M
6
Apply the latestWaiting and closed-job filters inside the take route.
P2
S
7
Add 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.
P2
M
8
Pass audience and phase into utmContext, or remove them from TOKENS; drop generated_utm from EDITABLE_FIELDS.
P2
S
9
Base the superseded-cut clock on the newer version's created_at.
P3
S
10
Correct 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.