---
name: project-project-dupe-merge-verify
description: S147 adversarial verify of Project whitespace-dupe merge plan (FLOCK 01/FLOCK01) — FK-in-comment ≠ physical FK; seed-durability forces survivor; INNER-join+filter = silent vanish
metadata:
  type: project
---

# Project whitespace-dupe merge — adversarial verify (2026-07-23)

Investigator plan: merge "FLOCK 01"/"FLOCK01" (+prod FLOCK 03, CAL 01) space-dupes → no-space canonical, retire space-variant, fix dupe-check root cause. Verified all 7 claims + stress i-v. Overall plan SOUND/safe-by-design; 2 terminology corrections; biggest residual = prod ref-distribution unverifiable from dev.

## Reusable lessons (load-bearing)
- **"FK Projects" in a code COMMENT ≠ physical FK.** `PurchaseEvaluation.cs:15` comment says "FK Projects" but `sys.foreign_keys` scan = ZERO FKs to Projects (68 total FKs in db, none ref Projects). All 3 refs (Contracts/PurchaseEvaluations/PeWorkItemBudgets) are **loose-Guid** (HasIndex only, no HasOne). Claims 1+2 inherited the wrong "FK" word. **Always verify FK reality via `sys.foreign_keys`, not the entity comment.**
- Loose-Guid (no FK) makes merge repoint SAFER re: ordering (no constraint breaks) but removes ALL DB safety net: orphan ProjectIds already possible, no cascade/restrict, a botched/incomplete repoint is NOT caught by the db.
- **Blast radius via `sys.columns LIKE '%ProjectId%'` = exactly 3 tables** (authoritative). No denormalized ProjectCode/MaDuAn column anywhere (PE resolves Code via JOIN `db.Projects on e.ProjectId equals p.Id`, DTO projects `x.p.Code`). Only PeWorkItemBudgets has unique index touching ProjectId (filtered `(ProjectId,WorkItemId) WHERE IsDeleted=0`) → sole merge-collision surface.
- **INNER join + HasQueryFilter(!IsDeleted) = silent vanish.** PE list (`PurchaseEvaluationFeatures.cs:579`) + Contract list (`ContractFeatures.cs:294`) INNER JOIN Projects. Soft-deleting a Project drops every still-referencing row from lists (no error). FORCES repoint-BEFORE-soft-delete + verify 0 live refs (count=0/0/0) before retiring. Detail path (`ContractFeatures.cs:435` FirstOrDefault + `?.Name ?? ""`) tolerates missing → list/detail asymmetry.
- **Seed-durability FORCES survivor choice (not ref-location).** Ungated `SeedRealMasterDataAsync` (`DbInitializer.cs:128`, outside `if(!demoSeedDisabled)`) emits no-space FLOCK01/FLOCK03/CAL01 every startup; idempotency = exact Code via `db.Projects.Select(Code)` which IS subject to query-filter (no IgnoreQueryFilters). ⇒ soft-deleted no-space canonical is invisible to Select → RE-CREATED (resurrection). Space-variant in NO seed → retire = stays dead. So survivor MUST be no-space **blanket**, even if prod space-variant holds the refs (refs just get repointed; loose-Guid unconstrained). App delete = soft (`AuditingInterceptor.cs:56-61` Deleted→Modified+IsDeleted=true).
- **MaHopDong irreversibility:** embeds project.Code at gen (`ContractCodeGenerator.cs:32-33`), immutable (`ContractWorkflowService.cs:166` if-null guard; no Update rewrites). Repoint ProjectId does NOT fix embedded space in contract number — accepted limitation.
- **Root cause:** manual Project Create/Update store Code raw (no trim) + dupe-check EXACT equality (`ProjectFeatures.cs:95,138` `x.Code==request.Code`). No Project import exists. Trim-only INSUFFICIENT (embedded space survives) — needs whitespace-collapse on BOTH paths, ideally + normalized-key unique index (app-check races under concurrency).

## Prod-access constraint (stress v)
`appsettings.Production.json`: `Server=localhost;...User Id=vrapp;Password=[đã che 4c3c67d5 — ROTATE REQUIRED]`. Prod SQL bound to localhost on VPS (not network-exposed) + secret creds + S134 SSH-crash gotcha ⇒ NO read-only path from dev. Prod ref-distribution (which row holds refs per pair) UNVERIFIABLE pre-execution — the plan's single biggest residual unknown.

## Dev DB snapshot (2026-07-23)
70 live projects, 0 soft-deleted. ONE whitespace anomaly `[FLOCK 01]` (D680DB3D, 0C/0PE/0budget). `[FLOCK01]` (F1B311EE) = 2C/2PE/0budget. ONE collision group (flock01). CAL01/FLOCK03 exist no-space only; FLOCK 03/CAL 01 space = prod-only.
