# Bug pattern catalog — 16 audit lenses

Every pattern below was derived from defects actually observed on Mengo Engine.
Each entry gives: the symptom as a tester reports it, the root-cause class, how
to detect it in code and in the UI, the fix pattern, and the regression test
that keeps it fixed.

Detection commands assume a Node/TypeScript backend with a job queue (BullMQ,
Agenda or SQS), an ORM (Prisma, Mongoose or TypeORM) and a React/Next frontend.
Where a command names one library, the equivalent for yours is given alongside
or is a direct substitution. Adjust paths to the repo, but keep the intent.

**Contents**

- [L1 Data lineage & propagation](#l1)
- [L2 AI grounding & hallucination](#l2)
- [L3 Job lifecycle & reliability](#l3)
- [L4 State & count integrity](#l4)
- [L5 Regeneration & feedback](#l5)
- [L6 Input contracts & validation](#l6)
- [L7 Selection & partial approval](#l7)
- [L8 Export & format fidelity](#l8)
- [L9 Brand & design fidelity](#l9)
- [L10 Content quality gate](#l10)
- [L11 Entitlement & quota](#l11)
- [L12 Labels, copy & wiring](#l12)
- [L13 Tenancy & security](#l13)
- [L14 Performance](#l14)
- [L15 Accessibility & responsive](#l15)
- [L16 Friction budget](#l16)

---

<a id="l1"></a>
## L1 — Data lineage & propagation

**The single biggest defect class on this platform.** The user enters something
once; a downstream module either re-asks for it or ignores it.

### Symptoms observed
- Visual Identity palette selected, but Stationery and HR Assets ignore it.
- Business card renders without the contact number that exists in Foundation.
- Offer letter shows placeholder contact info although a Founder is linked.
- HR assets don't fill in the selected employee's name.
- Brand values regenerated from scratch instead of read from Business Profile.
- Visual Identity wizard doesn't prefill from Business Profile / Brand Strategy.
- Generated website uses colours that aren't the tenant's visual identity.
- Company name changed in "My Company" doesn't reach already-generated assets.
- Empty-state copy claims profile data is used, but the form still demands it.

### Root-cause classes
1. **Re-collection** — the downstream module has its own form field instead of
   a read of the canonical record.
2. **Payload omission** — the record is read but never added to the generation
   payload or prompt.
3. **No fan-out on change** — upstream edits don't invalidate or re-render
   downstream artifacts, and the user is never told which assets are stale.
4. **Silent null** — the read returns null and generation proceeds with a
   default instead of failing loudly.

### Detect in code
```bash
# Build the map: what does each generator actually read?
grep -rn "class .*Generator\|Service\b" src/ --include=*.ts --include=*.js | sort

# For a given module, list the canonical records it reads
grep -rn "businessProfile\|visualIdentity\|founder\|employee" src/modules/stationery/

# Smell: generator building its payload from the request body only
grep -rn "req\.body\|@Body()\|dto\." src/ --include=*enerator*

# Smell: a default masking a missing upstream value
grep -rnE "\?\? *['\"]#|\|\| *['\"]#|\?\? *DEFAULT|\|\| *'default'" src/
```

### Detect in UI
Run the **lineage trace**: enter a uniquely identifiable value upstream
(company name `ZZTest-Alpha`, palette `#123456`, phone `70989 89191`), then open
every downstream module and search for that exact value in the rendered output
and the exported file. Any module that shows a different value, a placeholder,
or asks you to type it again is a finding.

### Fix pattern
- One canonical resolver per domain object: `getBrandContext(tenantId)` returns
  profile, identity, founder, palette, logo set, tone — typed, so a missing
  field is a compile error rather than an `undefined` at generation time. Every
  generator takes the context object, never a loose request body.
- Make missing upstream data a **hard failure with a named cause**
  ("Palette not set — set it in Visual Identity"), never a silent default.
- On upstream change, mark downstream artifacts `stale` and surface a
  "3 assets use the old name — update them?" prompt rather than silently
  diverging.

### Regression test
For each generator, a test asserting the payload carries the canonical values:
`expect(buildPayload(ctx)).toMatchObject({ palette: '#123456' })`.
One test per generator × per canonical field. These are cheap and they are the
tests that would have prevented most of the observed defect list.

---

<a id="l2"></a>
## L2 — AI grounding & hallucination

**The most expensive defect class**, because the output looks correct and
reaches customers.

### Symptoms observed
- A price was never entered; the model invented one and it propagated into five
  sales scripts and a landing page.
- A script cited "our research shows partners spend 14 hours a week" — no such
  research exists.
- Four membership prices generated after being explicitly told not to invent any.
- Figures produced in USD for an India-first business.
- Description asserts a "90-day plan" that appears in none of the source files.
- SEO record inferred the industry from the word "Scale" in the company name.
- Competitors requested for Pune and Nashik; returned Mumbai, Kochi and a
  Netherlands company; one listed competitor domain does not resolve.
- Testimonials generated with foreign names for an India-only product.
- Palette entries named after other companies' products.
- Tagline invented and reused six times across recovered content.
- SEO output for a deleted older product bleeding into a new project.

### Root-cause class
There is no validation gate between model output and persistence. The system
trusts the model, and prompt instructions ("don't invent prices") are treated as
a control. They are not — they are a request.

### Detect in code
```bash
# Is there any post-generation validation at all?
grep -rn "validateOutput\|groundingGate\|factCheck\|guardrail" src/

# Persistence points that accept raw model output
grep -rniE "\.(create|createMany|update|upsert|save|insertOne)\(" src/ \
  | grep -iE "completion|response|output|generated"

# Are prompts carrying the real record, or only a description of it?
grep -rln "systemPrompt\|buildPrompt\|messages: \[" src/
```

### Detect in output (run daily — this is step 4 of the loop)
Take every artifact generated today and extract:
- currency amounts and prices
- percentages and statistics
- named organisations, people, domains
- dates, durations, guarantees, legal or financial terms
- city / country / geography claims

For each, answer: **which stored record is this from?** No record = Sev-1.
Domains additionally get a liveness check.

### Fix pattern
Build a **grounding gate** that runs between generation and persistence:

| Class of claim | Rule |
| --- | --- |
| Price / currency | Must match a `products.price` record for this tenant, or be blocked and the field left empty with a "add pricing to fill this" note |
| Statistic / percentage | Must carry a source reference from a whitelisted dataset, or be stripped |
| Named entity (company, person, domain) | Must exist in the tenant's records or a verified external source; domains resolve-checked |
| Geography | Must intersect the tenant's stated market |
| Superlatives, guarantees, legal or medical claims | Blocked outright, flagged for human review |

Behaviour on violation: **block the save, surface the specific field, offer the
user the one-click fix** (enter the price, confirm the market). Never save and
warn — a saved invention will be copied out before anyone reads the warning.

Then reduce the need to invent: connect the tenant's own website, existing
social profiles, public filing data and prior content as retrieval sources, so
the model has real material for the fields it currently fills with guesses.

### Regression test
A fixture tenant with **no price on record**. Generate a sales script. Assert
the output contains no currency token. This test would have caught the most
damaging defect on the list.

---

<a id="l3"></a>
## L3 — Job lifecycle & reliability

### Symptoms observed
- Logo generation hangs indefinitely, no progress, no cancel, no timeout.
- Regeneration takes 20+ minutes; a second attempt yesterday never returned and
  today required re-entering every input from scratch.
- PPT generates correctly only if the user stays in the window, despite a popup
  saying they can navigate away.
- Jingle generated 30 minutes ago still won't play; one generated last week is
  now unavailable and regeneration doesn't work either.
- Video generation fails on both available models.
- Audio failures exposed internal diagnostics to the customer.
- No status or degradation indicator during outages.
- No fallback when the prompt-enhancement stage fails.
- Brand asset generation fails intermittently with no explanation.

### Root-cause classes
1. Work executed in the request lifecycle rather than a queue (explains the
   "must stay in the window" symptom exactly).
2. No terminal state: the status enum has `pending`/`processing` but nothing
   guarantees a job reaches `failed` or `completed`.
3. The queue's stalled-job lock shorter than the real job runtime — a BullMQ
   `lockDuration` of 30s on a job that takes 20 minutes means the worker
   declares it stalled and runs it again. This is the duplicate-record bug.
   Long jobs must either extend the lock or call `job.updateProgress()` /
   `job.extendLock()` while running.
4. No input persistence: a failed job discards the user's specification.
5. Artifacts on ephemeral storage or behind expiring URLs.
6. Raw exception surfaced to the user.

### Detect in code
```bash
# Highest-value check on this platform: is generation actually queued,
# or running inside the HTTP request?
grep -rn "new Queue(\|bullmq\|bull\|agenda\|@nestjs/bull\|SQS\|QUEUE_" src/ .env*
grep -rniE "await .*(generate|openai|anthropic|replicate|elevenlabs)" \
  src/controllers/ src/routes/ src/app/api/ 2>/dev/null

# Attempts, backoff and per-job timeout
grep -rn "attempts:\|backoff:\|timeout:\|removeOnFail" src/

# BullMQ: lockDuration / stalledInterval / maxStalledCount must exceed the
# real job runtime. If a 20-minute logo job has a 30-second lock, the worker
# is re-processing it — that is the duplicate-record bug.
grep -rn "lockDuration\|stalledInterval\|maxStalledCount" src/

# Jobs with no failure handler
grep -rn "on('failed'\|@OnQueueFailed\|worker.on" src/ 2>/dev/null

# Unbounded provider calls (no timeout = a job that can hang forever)
grep -rn "axios\.\|fetch(" src/ | grep -v "timeout\|AbortSignal"

# Error leakage to the client
grep -rniE "(err|error|e)\.(message|stack)" src/ | grep -iE "res\.|json\(|send\(|HttpException"
grep -rn "NODE_ENV" src/ .env*

# Artifact durability
grep -rn "getSignedUrl\|expiresIn\|os.tmpdir\|/tmp/" src/
```

### Detect in UI
- Start a generation, navigate away immediately, come back. Did it finish?
- Start a generation, close the tab, reopen. Is state recoverable?
- Start a generation, kill the network for 30s, restore.
- Force a failure (invalid model config in a staging tenant). What does the
  user see? It must be a plain-language message plus a retry, never a stack
  trace, never an internal model or provider name.
- Open an asset generated 7+ days ago in a fresh session.

### Fix pattern
- Every generation is a queued job with an explicit state machine:
  `queued → running → succeeded | failed | cancelled | timed_out`, with a
  watchdog that force-transitions anything exceeding its SLA.
- `attempts`, `backoff`, a per-job timeout, a `lockDuration` that exceeds the
  real runtime, and a `failed` handler that writes a user-facing reason code —
  never the raw error message. Every outbound provider call gets its own
  timeout and `AbortSignal`; an unbounded `fetch` is how a job hangs forever.
- **Persist the user's input before dispatching.** A failed job must leave the
  spec intact so retry is one click, not a retyped form.
- Progress + cancel on every job over 10 seconds; ETA from historical p50.
- Artifacts written to durable storage with permanent, tenant-scoped URLs.
- A degradation banner driven by provider health, and a documented fallback
  chain per capability (image, audio, video, text) so one provider outage
  degrades quality instead of breaking the feature.

### Regression test
Job-level tests asserting: timeout set, `failed()` writes a reason code, retry
reuses the persisted spec, and no code path returns `$e->getMessage()` to an
HTTP response.

---

<a id="l4"></a>
## L4 — State & count integrity

### Symptoms observed
- The same set of blogs counted as 8, 7, 6, 5, 9 and 11 on different screens.
- Retry created a duplicate blog instead of repairing the failed one (7 → 11).
- Deleting a failed blog removed only its title; the record reappeared.
- A blog record became invisible — unselectable, undeletable — while still
  blocking progress.
- Brand asset count mismatched between loading count, counter and delivered set.
- 30 competitors requested, 20 returned, with no explanation.
- Quota appears to count stored assets rather than generations used.

### Root-cause classes
1. Counts computed independently per screen instead of from one named scope.
2. Failed/soft-deleted/duplicate records included in some queries, excluded in
   others.
3. Delete implemented as a column mutation rather than a record + artifact
   deletion.
4. No unique constraint, so retry inserts instead of updating.
5. Records in a state with no UI representation — invisible but still counted
   by the blocker check.

### Detect in code
```bash
# The same thing counted more than one way
grep -rniE "\.count\(|countDocuments|_count|\.length" src/ | grep -i blog

# Soft-delete filtering (Prisma deletedAt, Mongoose plugin, TypeORM softRemove)
grep -rn "deletedAt\|isDeleted\|deleted_at\|softRemove" src/
# every query on that model must be explicit about deleted rows

# Retry semantics — must update the existing record, not insert a new one
grep -rniE "retry|regenerate" src/ | grep -iE "\.create\(|insertOne|new Blog"

# Missing uniqueness at the database level
grep -rn "@@unique\|@unique\|unique: true\|createIndex" \
  prisma/schema.prisma src/models/ src/entities/ 2>/dev/null
```

### Detect in UI
Generate a batch of N. Fail one deliberately. Then compare the number shown on:
the module dashboard, the list screen, the counter chip, the export, the quota
screen, and the DB. All six must agree. Delete one; re-check all six; refresh;
re-check again.

### Fix pattern
- One canonical query scope per countable entity
  (`visibleBlogs(projectId)` — one exported query helper), used by every
  screen, export and counter.
  Screens may not write their own count query.
- Retry mutates the existing record (same id, new attempt) — enforced by a
  unique constraint on `(project_id, slot, kind)`.
- Delete removes the record and its artifacts in one transaction.
- No state without a UI representation: every status enum value maps to a
  rendered row and a permitted action.

### Regression test
A "count coherence" test: seed a project with succeeded, failed, duplicate and
soft-deleted records, then assert the dashboard, list, counter and export all
return the same number.

---

<a id="l5"></a>
## L5 — Regeneration & feedback

### Symptoms observed
- Regenerating the Brand Positioning Statement returns identical output
  regardless of focus area or added instructions.
- Unique Value Proposition shows "Changed Successfully" while the text is
  unchanged.
- Brand Experience regenerate button has no effect.
- Letterhead feedback not incorporated after repeated attempts.
- "Even after multiple prompts it did not change the design."
- Several sections offer no regenerate or feedback control at all.
- The prompt is incomplete and not editable.
- Regenerating the tagline reworks every other section too.

### Root-cause classes
1. New instructions collected by the UI but never added to the prompt payload.
2. Response cached on a key that omits the new instructions.
3. Success toast fired on request acceptance, not on output change.
4. Regeneration scope too coarse — one field regenerates the whole document.
5. No feedback capture at all on some surfaces.

### Detect in code
```bash
# Do the extra instructions actually reach the payload?
grep -rn "additionalInstructions\|focusArea\|feedback\|userPrompt\|instructions" src/
# trace each one: request -> DTO -> payload -> provider call.
# A break anywhere in that chain is the bug.

# Cache keys that ignore the user's new input
grep -rn "cacheKey\|cache\.get\|redis\.set\|createHash" src/

# Toasts fired independently of the result
grep -rn "Changed Successfully\|Updated successfully\|regenerat" \
  src/components/ src/app/ app/ components/ 2>/dev/null
```

### Detect in UI
Regenerate the same field three times with three clearly different instructions
("make it playful", "make it formal", "mention Pune"). Diff the three outputs.
Identical output on differing input is a Sev-2 even if the text is good.

### Fix pattern
- Instructions are part of the prompt payload **and** part of the cache key.
- Compare output hash before and after; if unchanged, tell the user honestly
  ("the model returned the same result — try a more specific instruction")
  rather than claiming success.
- **Field-level regeneration**: regenerating the tagline touches the tagline.
  Anything wider requires explicit confirmation listing what will change.
- Every generated surface gets the same three controls: regenerate, edit,
  feedback. No exceptions — a surface without them is an incomplete feature.
- Store every edit the user makes. This is the compounding asset: an edit is a
  labelled example of what good output looks like for that industry. Today it is
  discarded.

### Regression test
Given the same seed and two different instruction strings, assert the two
outputs differ. Assert the success toast fires only when the output hash changes.

---

<a id="l6"></a>
## L6 — Input contracts & validation

### Symptoms observed
- Phone number pasted as `70989 89191` loses the last digit.
- Company name "Bizzfly" rejected as invalid; "Bizzfly Solutions" accepted.
- Valid URL rejected across every variation (http, https, www, bare); the record
  saves anyway. Reproduced on a second device and account.
- Business profile address rejected although correct.
- Field labelled optional but blocks save until filled (short description).
- Example shows `1.0` but the field accepts whole numbers only.
- CSV rejected in the Products upload; no Word/PDF option offered.
- Visual Identity accepts CSV but not JPG or PDF, which is what design files are.
- Upload says up to 5 documents at 20MB each; accepts one, and shows no filename.
- No multi-select on category and target audience.
- Mascot, jingle and founder-image fields mandatory when not applicable.
- Employee date of birth required with no explanation.
- Word-count cap on a free-text description field.
- City selector requires clicking into a separate search instead of type-ahead.
- Submit button below the fold, not sticky.
- Back-to-edit discards everything already entered.

### Root-cause classes
1. Validation written against the ideal input, not the input real users produce
   (pasted, spaced, partial, uppercase, with country codes).
2. Front-end and back-end validation maintained separately and drifting.
3. Required-ness driven by the generator's convenience rather than the user's
   reality.
4. Upload contracts (count, size, MIME) advertised in copy but not implemented.
5. No draft persistence on navigation.

### Detect in code
```bash
# The two sources of truth for every form — they drift
grep -rn "z\.object(\|Joi\.object(\|yup\.object(\|class .*Dto\|@IsNotEmpty" src/
grep -rn "required\|pattern=\|maxLength" src/components/ src/app/ app/ 2>/dev/null
# Any field where the two disagree is a finding.

# Over-strict patterns
grep -rn "\.regex(\|\.matches(\|new RegExp(" src/
# phone, url, name and address regexes are the usual offenders

# Upload limits vs advertised limits
grep -rn "limits:\|fileSize\|maxCount\|mimetype\|accept=" src/
grep -rn "20MB\|5 documents\|up to" src/components/ src/app/ app/ 2>/dev/null
```

### Detect in UI — the paste-and-abuse pass
For every text field: paste with spaces, with `+91`, with hyphens, with a
trailing space, with a newline, with emoji, with 500 characters, with a
single character, with `<script>`, with Devanagari. For every upload: the
advertised maximum count, the advertised maximum size, each advertised MIME
type, a zero-byte file, and a file with a very long name.

### Fix pattern
- **Normalise, then validate.** Strip whitespace and formatting from phone,
  URL, GST and address input before the rule runs. Never reject what you can
  safely repair; show the normalised value so the user can confirm.
- One schema, two consumers: generate the front-end rules from the back-end
  request class so they cannot drift.
- Required-ness is a product decision with a stated reason. If a field is
  required, the UI explains why in one line ("used for birthday campaigns").
  If it can't be justified, it isn't required.
- Upload contract implemented exactly as advertised: count, size, MIME set
  including PDF/DOCX/JPG where the artifact is a design or document; filename,
  size and remove-control shown per file.
- Autosave drafts on every step; back never destroys input.

### Regression test
A parameterised test per field with the realistic-input corpus above (the pasted
phone number with a space is case one). Plus a test asserting front-end and
back-end rule sets are generated from the same source.

---

<a id="l7"></a>
## L7 — Selection & partial approval

### Symptoms observed
- Three logo options generated; "Use This Logo" applies all three, and all three
  end up used across downstream assets.
- No way to pick one option and refine only that one.
- "Use This Logo" modal stuck on step 1; step 2 unreachable.
- Asked to keep the selected items and regenerate the rest — it regenerated
  everything.
- No multi-select where the user clearly needs it.
- The next phase auto-picks an option without asking.

### Root-cause class
The generation unit and the approval unit are the same object. The system
produces a *set* and approves a *set*, with no concept of "this one, refined".

### Detect in code
```bash
grep -rn "approve\|useThis\|select" src/modules/brand-assets/
# What is the approved entity — a batch, or a single variant?
grep -rn "batchId\|generationId\|variantId\|derivedFrom" \
  prisma/schema.prisma src/models/ src/entities/ 2>/dev/null
```

### Detect in UI
Generate N options in every module that offers options. Select exactly one.
Verify: only that one is marked approved; only that one propagates downstream;
"refine this one" exists and produces a variant derived from it, not a fresh
generation; and regenerating the others leaves the selection intact.

### Fix pattern
- Model `Variant` as a first-class entity with its own approval state and a
  `derived_from` parent. Approval targets a variant, never a batch.
- Every option set gets: select one · refine this one · lock this one and
  regenerate the rest · discard.
- Downstream consumers read `approved variant`, and fail loudly if none is
  approved rather than picking the first row.
- Test every multi-step modal to its final step in CI (the stuck-on-step-1 bug
  is a modal state machine bug, and it is testable).

### Regression test
Approve variant 2 of 3; assert every downstream generator receives variant 2's
asset id and that variants 1 and 3 appear nowhere in the output payloads.

---

<a id="l8"></a>
## L8 — Export & format fidelity

### Symptoms observed
- HR documents delivered as flat images inside PDFs — not editable or searchable.
- Business card delivered as HTML for printing; the customer needs a source
  design file.
- SEO export missing Page Title, URL and Canonical URL on every row.
- SEO export missing the Keywords column although keywords display as chips.
- SEO export missing Twitter Title/Description, AI Summary, Key Selling Points.
- Search Intent appears in the export but nowhere in the interface.
- Open Graph fields stored, exported and used, but not editable.
- Five finished articles unexportable in every format because the step bar
  bypassed the approval that unlocks export.
- Sales collateral generates content but not in its own format.
- Client presentation generated as content, no PPT produced.
- No Word download for offer letters, appreciation letters, internship letters.
- Book preview unavailable; "view module" returns 404.
- Manual edits to sales collateral fail to save.

### Root-cause classes
1. The export schema and the UI schema were written separately.
2. The deliverable format was decided by what was easy to render, not by what
   the customer does next with it.
3. Export gated behind a state the UI lets the user skip past.

### Detect in code
```bash
# Field-by-field diff of the three schemas
grep -rn "createObjectCsvWriter\|json2csv\|xlsx\.utils\|headers:\|columns:" src/
grep -rn "model Seo\|SeoSchema\|class Seo" \
  prisma/schema.prisma src/models/ src/entities/ 2>/dev/null
# plus the field list rendered by the form component.
# Any field present in one and absent in another is a finding.

# State gates on export
grep -rn "canExport\|isApproved\|status ===" src/
```

### Detect in UI
For each module: generate, then export in every offered format, then **open each
export in the tool the customer would use** — Word, Illustrator, Excel,
PowerPoint, a browser. Check that every field visible in the UI is present, that
text is selectable (not rasterised), and that the file opens without a repair
prompt. Then edit a record and re-export to confirm the edit propagated.

### Fix pattern
- One field registry per entity, consumed by the form, the API, and the export.
  Adding a field means adding it in one place.
- Deliverable format follows the customer's next action: documents as DOCX +
  PDF, design assets with an editable source (SVG/AI) plus rasterised previews,
  decks as PPTX, data as CSV/XLSX with every visible column.
- Any state that gates export must be reachable and visible in the step bar; if
  a step is skippable, its gate cannot block delivery.

### Regression test
Schema-parity test: assert the set of exported columns equals the set of UI
fields for each entity. Plus a "text is selectable" assertion on generated PDFs.

---

<a id="l9"></a>
## L9 — Brand & design fidelity

### Symptoms observed
- A different company's logo (Bizzfly) placed on a letterhead.
- Three logos used simultaneously on one asset.
- Light mark for dark backgrounds displayed on a white swatch.
- Dark mark rendered black instead of brand navy.
- Favicon is a full gradient mark, illegible at small size.
- Brand pattern tiles not seamless; visible seams when repeated.
- Brand pattern is a repeated logo rather than a motif derived from the mark.
- Mark corrupted in places within the pattern.
- Logo variants introduce marks absent from the approved primary.
- Collateral does not match design standards; no uniformity across the set.
- Same design produced every time; alignment issues throughout.
- ID card is a blank form with no brand mark.
- Card renders an empty panel when no logo is on file.
- Landing pages look templated; brand colours and fonts not applied.
- Green in the light theme is not readable.

### Root-cause classes
1. Asset selection by heuristic instead of by explicit approved-asset reference
   (this is how another tenant's logo can appear — check L13 immediately).
2. No design token layer: colour, type, spacing, logo variants are not a system
   the generators consume.
3. No automated rendering checks: contrast, tile seams, minimum legible size.
4. Empty state renders a broken artifact instead of blocking generation.

### Detect in code
```bash
grep -rniE "logo|mark" src/ | grep -iE "path|url|\[0\]|findFirst|glob|random"
# [0], findFirst, or a filesystem glob for logo selection is the smoking gun

grep -rnE "#[0-9a-fA-F]{6}" src/ --include=*.ts --include=*.tsx --include=*.js | head -50
# a hardcoded hex inside a generator means the token layer is being bypassed
```

### Detect in output
- **Contrast**: compute WCAG ratio for every text/background pair in generated
  assets and themes. Fail below 4.5:1 for body text, 3:1 for large text.
- **Seamlessness**: tile the pattern 3×3 and diff edge columns/rows.
- **Legibility**: render the favicon at 16px and check it is distinguishable.
- **Variant integrity**: structural diff of each variant against the approved
  primary — no new marks may appear.
- **Provenance**: assert every asset used carries this tenant's asset id.

### Fix pattern
- A per-tenant **design token set** (palette, type scale, spacing, logo variants
  with usage rules) generated once and consumed by every module, including the
  website builder.
- Asset resolution by explicit id from the approved variant record, never by
  search, glob, or "first available".
- Automated design QA in the pipeline: contrast, seam, minimum-size and
  provenance checks run before an asset is marked delivered.
- Curated layout templates per industry so output looks designed rather than
  generated; the model fills a good template instead of inventing a layout.
- No logo on file ⇒ block generation with a clear prompt, never render an empty
  panel.

### Regression test
Golden-image tests for each asset type against a fixture tenant, plus assertions
that no asset in the output references an id outside the tenant's asset set.

---

<a id="l10"></a>
## L10 — Content quality gate

### Symptoms observed
- 48 em dashes in one recovered set; en dashes elsewhere; em dashes reported
  across product descriptions, social posts and general content repeatedly.
- "mid-market" 30 times, "founder-led" 12 times, an invented tagline 6 times.
- Four grammar-breaking forced insertions of the exact phrase "revenue
  constraint B2B".
- Word count inflated at every stage: 800–1200 requested, 1378–2244 delivered.
- Interview prep content exceeded the allowed word limit.
- Every article auto-appends a promotional block that undermines an
  authority-first strategy.
- Spelling error saved into final output.
- Code of Conduct has mid-word line breaks.
- Generated output contradicts itself within a single run.
- "What It Is Not" business rules not enforced at generation time.
- Personality vocabulary differs between Visual Identity and Brand Strategy.
- ICP goals and challenges too generic to be useful.
- Jingle lyrics do not match the generated audio (reported three times).
- Presentations too generic; no recognised deck format applied.
- No US/UK dialect option; no multi-language support (Arabic requested for GCC).

### Root-cause class
Content is generated and stored with no post-processing gate. Style rules exist
in prompts, and prompts are not enforcement.

### Detect automatically (run on every generated text artifact)
```bash
# House style violations
grep -c "—" artifact.txt        # em dash
grep -c "–" artifact.txt        # en dash
# Repetition
tr ' ' '\n' < artifact.txt | sort | uniq -c | sort -rn | head -20
# Length
wc -w artifact.txt              # compare against the requested range
```

### Fix pattern
Add a **content gate** between generation and save, which:
- normalises punctuation (em/en dash → comma, colon or sentence break),
- enforces the requested word range with a hard re-ask, not a suggestion,
- flags any n-gram repeated above a threshold across the article set,
- runs a spell/grammar pass,
- checks self-consistency against the Brand Strategy record (vocabulary,
  "What It Is Not" rules, tagline — the tagline is a record, never regenerated),
- strips auto-appended promotional blocks unless the strategy asks for them,
- verifies cross-modal consistency: for audio, assert the transcript matches the
  approved lyrics before marking the job delivered.

Expose the gate's rules as a per-tenant **style profile** (dialect: en-IN /
en-GB / en-US, banned characters, banned phrases, length tolerance, promotional
block on/off). This also delivers the requested dialect and multi-language
options as configuration rather than a new feature each time.

### Regression test
Golden-content tests: generate with a fixture profile and assert zero em dashes,
word count inside range, no n-gram over threshold, tagline equal to the stored
tagline, and transcript-to-lyrics match for audio.

---

<a id="l11"></a>
## L11 — Entitlement & quota

### Symptoms observed
- No visibility into remaining quota before starting a batch.
- Limit-reached message repeated once per asset instead of once per job.
- No upgrade link or pricing shown with the limit message.
- Cap appears to apply to stored assets rather than generations used.

### Detect in code
```bash
grep -rniE "limit|quota|plan|entitle|credit" src/ | grep -iv "ratelimit" | head -40
# Confirm what is counted: rows in an assets table (wrong)
# or generation events (right)
```

### Fix pattern
- Count **generation events**, not stored rows. Deleting an asset must not
  refund quota, and storing one must not consume it.
- Pre-flight check before a batch: "This batch needs 12 credits; you have 7.
  Generate 7 now, or upgrade."
- One message per job, with the remaining balance and an upgrade link.
- Quota visible on every generation screen, not only at the point of failure.

### Regression test
Assert one limit message per job regardless of asset count, and that deleting an
asset leaves the consumed count unchanged.

---

<a id="l12"></a>
## L12 — Labels, copy & wiring

### Symptoms observed
- "Pen branding" heading shown above a corporate ID card.
- Internal kit name leaks into the document body.
- UI typo "Seo Style".
- Copy toast always says "Final blog" regardless of which article was copied.
- Mislabeled row numbering.
- "Default" and "Minimal" prompt variants show identical descriptions.
- Clicking a notification does not navigate to the referenced page.
- Publish action buried in an edit-form dropdown despite being a headline metric.
- Per-slot "Generate" opens a generic ~40-type picker and loses slot context.
- Prompt example does not change with the selected collateral type.
- SEO records labelled "AI Generated" while half their fields are blank.
- Four dashboard counters undefined; "Optimized" can never change.
- No dependency explanation on a module placed first but dependent on others.

### Root-cause class
Labels, actions and destinations hardcoded per screen instead of derived from
the entity being displayed. Cheap to fix, and disproportionately damaging —
these are what make a product feel unfinished.

### Detect
```bash
# Hardcoded user-facing strings that should be derived from the entity
grep -rn "Final blog\|Pen Branding\|Seo " src/components/ src/app/ app/ 2>/dev/null
# Notifications with no destination
grep -rni "notification" src/ | grep -viE "url|href|route|link|deepLink"
# Status labels not derived from field completeness
grep -rn "AI Generated" src/
```

### Fix pattern
- Every notification carries a deep link; a notification that cannot navigate is
  not shipped.
- Status labels derive from field completeness: `partial` when any field is
  blank, never `AI Generated`.
- Context-preserving actions: a per-slot Generate passes the slot; a prompt
  example is keyed to the selected type.
- Every counter on a dashboard has a defined source query and a path by which it
  can change. A metric that can never move gets removed.
- Copy review is part of the audit, not a polish phase — run a full-screen text
  sweep per module and check every string against what is on screen.

---

<a id="l13"></a>
## L13 — Tenancy, security & data isolation

**Treat everything in this lens as Sev-1 until disproved.**

### Symptoms observed
- SEO output for an older, deleted product appearing in a new project's output.
- Another company's logo used on a tenant's letterhead.
- Concern raised explicitly: agencies working on multiple projects will hit
  cross-contamination.

### Root-cause classes
1. Retrieval (search, embeddings, asset lookup) not scoped by tenant/project.
2. "Deleted" records still present in the retrieval index.
3. Caches keyed without the tenant id.
4. Signed/public artifact URLs guessable or not tenant-scoped.

### Detect in code
```bash
# Every query on a tenant-owned model must be scoped.
# Prisma:
grep -rnE "prisma\.[a-zA-Z]+\.(findMany|findFirst|findUnique|count|aggregate)\(" src/ \
  | grep -v "tenantId\|projectId\|companyId"
# Mongoose / TypeORM:
grep -rnE "\.(find|findOne|aggregate|createQueryBuilder)\(" src/ \
  | grep -v "tenantId\|projectId\|companyId"
# Any hit is a candidate leak. A Prisma client extension or a Mongoose plugin
# can enforce scoping globally — check whether one exists at all.
grep -rn "\$extends\|\.plugin(\|withTenant" src/

# Cache and index keys missing the tenant
grep -rn "redis\.set\|cache\.set\|createHash" src/ | grep -v "tenant\|project\|company"

# Retrieval / embedding index writes and queries
grep -rniE "embed|vector|pinecone|qdrant|weaviate|pgvector" src/ | head -30

# Publicly readable artifact URLs
grep -rn "public-read\|ACL:\|getSignedUrl\|CDN_URL" src/
```

### Detect in UI
Two tenants, deliberately similar (same industry, similar names). Generate in
both. Then in tenant B, search, generate and export, and look for any trace of
tenant A. Delete a product in tenant A, regenerate SEO, and confirm the deleted
product cannot appear.

### Fix pattern
- Global tenant scope on every model, with an explicit allow-list for the few
  places it may be bypassed.
- Retrieval and embedding indices partitioned by tenant; deletion removes the
  index entry in the same transaction.
- Tenant id in every cache key.
- Artifact URLs tenant-scoped and authorised on read, not merely unguessable.

### Regression test
A two-tenant fixture test asserting that no query, retrieval or export in tenant
B returns any record belonging to tenant A. Run it in CI on every build.

---

<a id="l14"></a>
## L14 — Performance

### Symptoms observed
- Slow platform response times overall.
- Logo regeneration exceeding 20 minutes.
- "Most sections need input from our end before anything generates" — perceived
  slowness compounds the friction problem in L16.

### Detect
```bash
# N+1 queries: log every query for one request and count per endpoint.
# Prisma: prisma.$on('query', ...) ; Mongoose: mongoose.set('debug', true)
grep -rn "include:\|select:\|\.populate(\|relations:" src/   # eager loading present?
grep -rniE "\.(findMany|find)\(\)" src/ | head -40            # unbounded reads

# Missing indices on the columns actually filtered
grep -rn "@@index\|@index\|createIndex\|index: true" \
  prisma/schema.prisma src/models/ src/entities/ 2>/dev/null
```
Set explicit budgets and measure against them: page TTFB p95 < 800ms,
interactive action p95 < 2s, generation job p50 within its published estimate.
Record actual p50/p95 per job type — the published estimate must come from
measured history, not a guess.

### Fix pattern
Eager loading, indices on filtered columns, cached aggregate counters,
pagination on every list, and a job-duration dashboard per capability so a
regression from 3 to 20 minutes is visible the day it happens rather than when a
tester reports it.

---

<a id="l15"></a>
## L15 — Accessibility & responsive

### Symptoms observed
- Green in the light theme not readable.
- Generated landing-page content not visible.
- Newsletter text sizes off.
- Submit button below the fold.

### Detect
- Contrast audit of both themes and every generated theme (4.5:1 body, 3:1 large).
- Keyboard-only pass: every action reachable, focus visible, modals trapped and
  escapable.
- Screen-reader pass on the primary flows; labels tied to inputs; errors
  announced.
- Responsive pass at 360, 768, 1024, 1440; primary action always reachable
  without hunting.
- Zoom to 200% without loss of function.

### Fix pattern
Contrast-safe token palettes (generate accessible variants rather than raw brand
colours for text), sticky primary actions, and an axe/Lighthouse check in CI so
the theme cannot regress silently.

---

<a id="l16"></a>
## L16 — Friction budget

### Symptoms observed
- Business Profile: 41 fields, 9 required.
- Employees: 25 fields including date of birth, unexplained.
- Founders: 28 fields, gender asked before anything useful.
- Press Release: three required fields hidden below the fold; button stays dead
  with no explanation.
- Products: 4 fields — reported as the best screen in the product. (Restated
  2026-09: the screen grew to 34 declared fields when the SaaS pricing and
  strategy/SEO layers were added additively. Restructured to the essentials
  first — 5 required fields plus the one price the chosen pricing model asks
  for — with the SaaS block appearing only under the SaaS model and the whole
  strategy/media layer behind a single "Add more details" toggle. The
  benchmark is now the visible-first-run count, not the declared count.)
- No way to import a business profile from an existing website; no LinkedIn
  import for founders and employees; no offline export/re-upload of the
  question set; no guided assistance while filling Foundation data.
- "Most sections need input before generating anything… friction and fatigue at
  every step."

### How to measure
Score every input screen out of 10 (1 = one click, 10 = long form with hidden
required fields below the fold). Record: total fields, required fields, hidden
required fields, and time-to-first-output.

### The standard
Products scores 2/10 and works. Press Release scores 7/10 and does the same kind
of job. The target is every screen at the Products standard — which is mostly
**deletion**, not new building. (What "Products standard" means since the 2026
restructure: what the user faces BEFORE the value — the visible essentials on
first open, 5 required — not the module's total declared field count.)

### Fix pattern, in priority order
1. **Import instead of ask**: pull the business profile from the tenant's own
   website; pull founder and employee data from LinkedIn or an uploaded PDF/DOCX
   with an editable review step. The user corrects ten things instead of typing
   forty-one.
2. **Progressive disclosure**: ask only what today's output needs; collect the
   rest when a module actually requires it.
3. **Justify or delete every required field.** If date of birth is needed,
   label it "for birthday campaigns". If a founder image isn't used in the
   output, it isn't required.
4. **Conversational collection** for long forms — a short guided sequence beats
   a 41-field wall, and it lets the system explain why it's asking.
5. **Never dead-end**: a disabled button always states what is missing and links
   to it.
6. **Autosave and resume** on every multi-step flow.

### Regression test
A friction budget check in review: any PR adding a required field must state the
output that consumes it. No consumer, no field.
