Data Normalization
Overview
This skill defines 8 normalization patterns that can be applied to any ForemanOS Project Brain to fix common data quality issues. Each pattern is idempotent (safe to re-run) and produces a repair plan showing what will change before executing.
The patterns complement the detection layer (data-integrity-watchdog agent, conflict-detection-agent) by providing the fix side of the data health stack.
Prerequisites
Before applying any pattern, the AI must:
- Read the target JSON file(s) in full
- Read
${CLAUDE_PLUGIN_ROOT}/skills/project-data/SKILL.md for canonical key names
- Generate a repair plan listing every proposed change (field path, before value, after value)
- Present the repair plan to the user for confirmation
- Apply changes via a single atomic Python script that reads → modifies → writes all affected files
- Validate the output (re-read and confirm changes applied)
Repair Plan Format
Before any changes, output a table:
| # | File | Path | Before | After | Pattern |
|---|------|------|--------|-------|---------|
| 1 | submittal-log.json | submittal_log[1].status | "submitted" | "SUBMITTED" | N1 |
| 2 | cost-data.json | summary.contingency_remaining | (missing) | 43225 | N6 |
Only proceed after user confirmation.
Pattern N1: Status Standardization
Problem: Mixed case and wording in status fields across different JSON files.
Logic: Each file type has canonical status enums. Normalize all status fields to match.
Canonical Status Values
| File |
Field |
Valid Values |
submittal-log.json |
submittal_log[].status |
APPROVED, SUBMITTED, REVIEW_PENDING, REJECTED, RESUBMIT, APPROVED_AS_NOTED |
submittal-log.json |
submittal_log[].compliance_matrix.compliance_status |
COMPLIANT, NON_COMPLIANT, REVIEW_PENDING, PARTIAL |
inspection-log.json |
inspection_log[].result |
pass, fail, conditional, pending |
risk-register.json |
risks[].status |
open, closed, monitoring, mitigated |
procurement-log.json |
procurement_log[].status |
quoted, executed, executed_with_co, delivered, cancelled |
rfi-log.json |
rfi_log[].status |
Draft, Submitted, Responded, Closed |
change-order-log.json |
change_order_log[].status |
pending, approved, rejected, executed |
punch-list.json |
punch_list[].status |
open, in_progress, completed, verified |
action-items.json |
items[].status |
open, in_progress, resolved, deferred |
project-config.json |
documents_loaded[].status |
current, extracted, received, finalized, filed, template, submitted, executed, superseded, superseded_by_conformance, logged |
Normalization Rules
- Case-insensitive match to canonical value (e.g., "submitted" -> "SUBMITTED" for submittals)
- Synonym mapping:
- "approved" / "APPROVED" / "Approved" ->
APPROVED
- "review_pending" / "Review Pending" / "REVIEW PENDING" / "pending_review" ->
REVIEW_PENDING
- "assumed_passed" ->
pass (for inspections)
- "in progress" / "In Progress" / "IN_PROGRESS" ->
in_progress
- If no match found, flag for manual review (do not auto-fix)
Application
# Pseudocode
for entry in submittal_log:
normalized = normalize_status(entry["status"], SUBMITTAL_STATUSES)
if normalized != entry["status"]:
plan.append(change(entry, "status", entry["status"], normalized, "N1"))
Pattern N2: Counter Reconciliation
Problem: Stored scalar counts drift from actual array lengths after edits.
Logic: Recount from source arrays, compare to stored scalar, fix if mismatched.
Known Counter Fields
| File |
Scalar Field |
Source Array |
Expected |
project-config.json |
documents_loaded_count |
documents_loaded[] |
len(documents_loaded) |
project-config.json |
total_documents |
documents_loaded[] |
len(documents_loaded) |
plans-spatial.json |
room_count (if present) |
room_schedule[] |
len(room_schedule) |
delay-log.json |
total_weather_days (if present) |
delay_events[] where type=Weather |
Sum of days_impact |
safety-log.json |
osha_300_log.total_hours |
labor-tracking.json labor_entries |
Sum of hours |
cost-data.json |
summary.subtotal_direct |
budget_by_division[] |
Sum of total |
pay-app-log.json |
schedule_of_values.total_contract |
schedule_of_values.line_items[] |
Sum of scheduled_value |
Application
actual = len(data["documents_loaded"])
stored = data.get("documents_loaded_count", None)
if stored != actual:
plan.append(change("documents_loaded_count", stored, actual, "N2"))
Pattern N3: Cross-Reference Linking
Problem: Missing bidirectional references between related records across files.
Logic: Match by vendor name, spec section, activity ID, or other shared identifiers. Add link fields.
Link Definitions
| Source File |
Source Field |
Target File |
Target Field |
Match On |
submittal-log.json |
submittal_log[].procurement_link |
procurement-log.json |
procurement_log[].po_number |
Vendor name match |
procurement-log.json |
procurement_log[].submittal_ids[] |
submittal-log.json |
submittal_log[].submittal_id |
Vendor name match |
rfi-log.json |
rfi_log[].related_submittals[] |
submittal-log.json |
submittal_log[].submittal_id |
Spec section match |
schedule.json |
activities[].linked_submittals[] |
submittal-log.json |
submittal_log[].submittal_id |
Activity ID match |
change-order-log.json |
change_order_log[].procurement_link |
procurement-log.json |
procurement_log[].po_number |
Description/vendor match |
inspection-log.json |
inspection_log[].linked_activity |
schedule.json |
activities[].id |
Activity match |
quality-data.json |
inspections[].submittal_ref |
submittal-log.json |
submittal_log[].submittal_id |
Spec section match |
Matching Rules
- Vendor match: Case-insensitive substring match on supplier/vendor name. "Schiller" matches "Schiller Architectural Hardware".
- Spec section match: Normalize to format "XX XX XX" (spaces, no dashes). "08 11 13" matches "081113".
- Activity ID match: Exact match on
linked_schedule_activity_id or activity_id.
- Only add links — never remove existing links. If a link field already exists and has a value, skip.
Application
# Match submittals to procurement by vendor
for sub in submittal_log:
if "procurement_link" not in sub or not sub["procurement_link"]:
for po in procurement_log:
if vendor_match(sub.get("vendor",""), po.get("supplier","")):
sub["procurement_link"] = po["po_number"]
po.setdefault("submittal_ids", [])
if sub["submittal_id"] not in po["submittal_ids"]:
po["submittal_ids"].append(sub["submittal_id"])
break
Pattern N4: Field Backfill
Problem: Array entries missing required fields (status, id, type, dates).
Logic: Apply defaults based on existing data context.
Required Fields by File
| File |
Array |
Required Fields |
Default Logic |
project-config.json |
documents_loaded[] |
id, status |
id: "DOC-{index+1:03d}". status: "extracted" if has extraction_data, "received" if has filename only |
submittal-log.json |
submittal_log[] |
submittal_id, status, spec_section |
submittal_id: "SUB-{index+1:03d}". status: "SUBMITTED" if date exists |
procurement-log.json |
procurement_log[] |
po_number, status, supplier |
status: "quoted" if has quote_date, "executed" if has executed_date |
rfi-log.json |
rfi_log[] |
id, status, date_created |
id: "RFI-{index+1:03d}". status: "Draft" if no response |
inspection-log.json |
inspection_log[] |
id, result, date |
result: "pending" if no result recorded |
labor-tracking.json |
labor_entries[] |
id, date, company |
id: "LBR-{index+1:03d}" |
risk-register.json |
risks[] |
id, status, probability, impact |
status: "open" if no status |
Application
for i, doc in enumerate(documents_loaded):
if "id" not in doc:
doc["id"] = f"DOC-{i+1:03d}"
plan.append(change(f"documents_loaded[{i}].id", "(missing)", doc["id"], "N4"))
if "status" not in doc:
status = infer_status(doc)
doc["status"] = status
plan.append(change(f"documents_loaded[{i}].status", "(missing)", status, "N4"))
Pattern N5: Key Schema Compliance
Problem: Wrong key names or missing required top-level keys.
Logic: Check against canonical keys from the project-data skill. Rename or add missing keys.
Canonical Top-Level Keys
Reference: foremanos-core/skills/project-data/references/json-schema-reference.md
| File |
Required Top-Level Keys |
schedule.json |
activities, milestones, critical_path, weather_sensitive_activities |
delay-log.json |
delay_events |
rfi-log.json |
rfi_log |
meeting-log.json |
meeting_log |
inspection-log.json |
inspection_log, permit_log |
Common Renames
| Wrong Key |
Correct Key |
File |
construction_activities |
activities |
schedule.json |
delays |
delay_events |
delay-log.json |
rfis |
rfi_log |
rfi-log.json |
meetings |
meeting_log |
meeting-log.json |
Application
if "construction_activities" in schedule and "activities" not in schedule:
schedule["activities"] = schedule.pop("construction_activities")
plan.append(rename("construction_activities", "activities", "N5"))
Pattern N6: Computed Totals
Problem: Missing aggregated values that should be computed from child records.
Logic: Sum from child records, write to parent field.
Computed Fields
| File |
Computed Field |
Formula |
cost-data.json |
summary.subtotal_direct |
sum(budget_by_division[].total) |
cost-data.json |
summary.contingency_used |
sum(change_orders where source=contingency) or from CO log |
cost-data.json |
summary.contingency_remaining |
contingency - contingency_used |
cost-data.json |
summary.total_committed |
sum(budget_by_division[].committed_costs) |
cost-data.json |
budget_by_division[].current_amount |
total + sum(applied_cos[].amount) |
risk-register.json |
total_exposure |
sum(risks[].exposure) |
pay-app-log.json |
current_retainage |
Computed from pay app percentages |
labor-tracking.json |
total_hours |
sum(labor_entries[].hours) |
delay-log.json |
total_delay_days |
sum(delay_events[].days_impact) |
Application
used = sum(co["amount"] for co in change_orders if co.get("source") == "contingency")
# Or from known COs:
used = 6775 # Nucor CO#1
remaining = summary["contingency"] - used
if "contingency_used" not in summary:
summary["contingency_used"] = used
plan.append(change("summary.contingency_used", "(missing)", used, "N6"))
if "contingency_remaining" not in summary:
summary["contingency_remaining"] = remaining
plan.append(change("summary.contingency_remaining", "(missing)", remaining, "N6"))
Pattern N7: Date/Format Normalization
Problem: Inconsistent date formats and number types across files.
Logic: Standardize all dates to ISO 8601, all numeric values as numbers (not strings).
Rules
Dates: All date fields must be ISO 8601 format: YYYY-MM-DD
- "01/22/2026" -> "2026-01-22"
- "Jan 22, 2026" -> "2026-01-22"
- "1/22/26" -> "2026-01-22"
- If ambiguous (e.g., "02/03/26"), prefer US format (MM/DD/YY)
Numbers: Numeric fields must be actual numbers, not strings
"184500" -> 184500
"$184,500" -> 184500
"15.5%" -> 0.155 (for rate fields) or 15.5 (for display fields — context-dependent)
Booleans: Boolean fields must be actual booleans
"true" / "yes" / "Y" -> true
"false" / "no" / "N" -> false
Date Fields to Check
| File |
Date Fields |
project-config.json |
documents_loaded[].date, version_history[].date |
submittal-log.json |
submittal_log[].date, [].review_date, [].must_submit_by_date |
procurement-log.json |
procurement_log[].quote_date, [].executed_date, [].delivery_schedule.* |
schedule.json |
milestones[].date, activities[].start, [].finish |
rfi-log.json |
rfi_log[].date_created, [].date_responded |
change-order-log.json |
change_order_log[].date, [].approved_date |
inspection-log.json |
inspection_log[].date, [].next_date |
Pattern N8: Deduplication
Problem: Duplicate entries in arrays from multiple extraction passes or manual entry.
Logic: Detect by ID field or fuzzy match on description + date. Merge or flag.
Detection Rules
- Exact ID match: Two entries with same
id / submittal_id / po_number -> merge (keep the one with more fields populated)
- Fuzzy match: Same
vendor + same spec_section + dates within 7 days -> flag for review
- Content match: Same
description (case-insensitive, trimmed) + same date -> merge
Merge Strategy
When merging duplicates:
- Keep the entry with more populated fields
- Copy any non-null fields from the less-complete entry that are missing in the keeper
- Record the merge in a
_merge_note field: "Merged from duplicate entry on {date}"
- Never delete — mark the duplicate with
_duplicate_of: "{keeper_id}" and remove from the main array
Application
seen = {}
for entry in submittal_log:
key = entry.get("submittal_id")
if key in seen:
# Merge: keep entry with more fields
keeper = seen[key] if len(seen[key]) >= len(entry) else entry
donor = entry if keeper is seen[key] else seen[key]
for k, v in donor.items():
if k not in keeper or keeper[k] is None:
keeper[k] = v
keeper["_merge_note"] = f"Merged duplicate {donor.get('submittal_id')}"
plan.append(merge(key, "N8"))
else:
seen[key] = entry
Execution Order
When running all 8 patterns on a Project Brain:
- N5 (Schema Compliance) — Fix key names first so other patterns find data
- N7 (Date/Format) — Normalize types before comparisons
- N8 (Deduplication) — Remove duplicates before counting
- N1 (Status Standardization) — Normalize status values
- N4 (Field Backfill) — Fill missing required fields
- N2 (Counter Reconciliation) — Recount after dedup and backfill
- N3 (Cross-Reference Linking) — Link after all entries are clean
- N6 (Computed Totals) — Compute aggregates last
Integration with /data-health Command
The /data-health command in the foremanos-compliance plugin orchestrates these patterns:
/data-health scan — Runs detection only (finds issues that N1-N8 would fix)
/data-health fix — Applies patterns with confirmation
/data-health report — Generates health score including normalization coverage
Constraints
- Never delete data — Only add, rename, or modify fields
- Always confirm — Present repair plan before any changes
- Atomic writes — All changes to a file happen in one write operation
- Preserve formatting — Use
json.dumps(data, indent=2, ensure_ascii=False) for output
- Log changes — After applying, add a
_normalization_log entry to project-config.json version_history:{
"date": "2026-02-26",
"action": "data_normalization",
"patterns_applied": ["N1", "N3", "N6"],
"changes_count": 12,
"files_modified": ["submittal-log.json", "procurement-log.json", "cost-data.json"]
}
1---2name: data-normalization3description: Project-agnostic data normalization patterns for the 28-file Project Brain. Defines 8 repeatable patterns (N1-N8) that fix common data quality issues: status standardization, counter reconciliation, cross-reference linking, field backfill, schema compliance, computed totals, date/format normalization, and deduplication. Safe to re-run (idempotent). Outputs a repair plan before executing.4---56# Data Normalization78## Overview910This skill defines **8 normalization patterns** that can be applied to any ForemanOS Project Brain to fix common data quality issues. Each pattern is idempotent (safe to re-run) and produces a **repair plan** showing what will change before executing.1112The patterns complement the detection layer (data-integrity-watchdog agent, conflict-detection-agent) by providing the **fix** side of the data health stack.1314## Prerequisites1516Before applying any pattern, the AI must:171. Read the target JSON file(s) in full182. Read `${CLAUDE_PLUGIN_ROOT}/skills/project-data/SKILL.md` for canonical key names193. Generate a repair plan listing every proposed change (field path, before value, after value)204. Present the repair plan to the user for confirmation215. Apply changes via a single atomic Python script that reads → modifies → writes all affected files226. Validate the output (re-read and confirm changes applied)2324## Repair Plan Format2526Before any changes, output a table:2728```29| # | File | Path | Before | After | Pattern |30|---|------|------|--------|-------|---------|31| 1 | submittal-log.json | submittal_log[1].status | "submitted" | "SUBMITTED" | N1 |32| 2 | cost-data.json | summary.contingency_remaining | (missing) | 43225 | N6 |33```3435Only proceed after user confirmation.3637---3839## Pattern N1: Status Standardization4041**Problem**: Mixed case and wording in status fields across different JSON files.4243**Logic**: Each file type has canonical status enums. Normalize all status fields to match.4445### Canonical Status Values4647| File | Field | Valid Values |48|------|-------|-------------|49| `submittal-log.json` | `submittal_log[].status` | `APPROVED`, `SUBMITTED`, `REVIEW_PENDING`, `REJECTED`, `RESUBMIT`, `APPROVED_AS_NOTED` |50| `submittal-log.json` | `submittal_log[].compliance_matrix.compliance_status` | `COMPLIANT`, `NON_COMPLIANT`, `REVIEW_PENDING`, `PARTIAL` |51| `inspection-log.json` | `inspection_log[].result` | `pass`, `fail`, `conditional`, `pending` |52| `risk-register.json` | `risks[].status` | `open`, `closed`, `monitoring`, `mitigated` |53| `procurement-log.json` | `procurement_log[].status` | `quoted`, `executed`, `executed_with_co`, `delivered`, `cancelled` |54| `rfi-log.json` | `rfi_log[].status` | `Draft`, `Submitted`, `Responded`, `Closed` |55| `change-order-log.json` | `change_order_log[].status` | `pending`, `approved`, `rejected`, `executed` |56| `punch-list.json` | `punch_list[].status` | `open`, `in_progress`, `completed`, `verified` |57| `action-items.json` | `items[].status` | `open`, `in_progress`, `resolved`, `deferred` |58| `project-config.json` | `documents_loaded[].status` | `current`, `extracted`, `received`, `finalized`, `filed`, `template`, `submitted`, `executed`, `superseded`, `superseded_by_conformance`, `logged` |5960### Normalization Rules61621. Case-insensitive match to canonical value (e.g., "submitted" -> "SUBMITTED" for submittals)632. Synonym mapping:64 - "approved" / "APPROVED" / "Approved" -> `APPROVED`65 - "review_pending" / "Review Pending" / "REVIEW PENDING" / "pending_review" -> `REVIEW_PENDING`66 - "assumed_passed" -> `pass` (for inspections)67 - "in progress" / "In Progress" / "IN_PROGRESS" -> `in_progress`683. If no match found, flag for manual review (do not auto-fix)6970### Application7172```python73# Pseudocode74for entry in submittal_log:75 normalized = normalize_status(entry["status"], SUBMITTAL_STATUSES)76 if normalized != entry["status"]:77 plan.append(change(entry, "status", entry["status"], normalized, "N1"))78```7980---8182## Pattern N2: Counter Reconciliation8384**Problem**: Stored scalar counts drift from actual array lengths after edits.8586**Logic**: Recount from source arrays, compare to stored scalar, fix if mismatched.8788### Known Counter Fields8990| File | Scalar Field | Source Array | Expected |91|------|-------------|-------------|----------|92| `project-config.json` | `documents_loaded_count` | `documents_loaded[]` | `len(documents_loaded)` |93| `project-config.json` | `total_documents` | `documents_loaded[]` | `len(documents_loaded)` |94| `plans-spatial.json` | `room_count` (if present) | `room_schedule[]` | `len(room_schedule)` |95| `delay-log.json` | `total_weather_days` (if present) | `delay_events[]` where type=Weather | Sum of `days_impact` |96| `safety-log.json` | `osha_300_log.total_hours` | `labor-tracking.json` labor_entries | Sum of `hours` |97| `cost-data.json` | `summary.subtotal_direct` | `budget_by_division[]` | Sum of `total` |98| `pay-app-log.json` | `schedule_of_values.total_contract` | `schedule_of_values.line_items[]` | Sum of `scheduled_value` |99100### Application101102```python103actual = len(data["documents_loaded"])104stored = data.get("documents_loaded_count", None)105if stored != actual:106 plan.append(change("documents_loaded_count", stored, actual, "N2"))107```108109---110111## Pattern N3: Cross-Reference Linking112113**Problem**: Missing bidirectional references between related records across files.114115**Logic**: Match by vendor name, spec section, activity ID, or other shared identifiers. Add link fields.116117### Link Definitions118119| Source File | Source Field | Target File | Target Field | Match On |120|------------|-------------|-------------|-------------|----------|121| `submittal-log.json` | `submittal_log[].procurement_link` | `procurement-log.json` | `procurement_log[].po_number` | Vendor name match |122| `procurement-log.json` | `procurement_log[].submittal_ids[]` | `submittal-log.json` | `submittal_log[].submittal_id` | Vendor name match |123| `rfi-log.json` | `rfi_log[].related_submittals[]` | `submittal-log.json` | `submittal_log[].submittal_id` | Spec section match |124| `schedule.json` | `activities[].linked_submittals[]` | `submittal-log.json` | `submittal_log[].submittal_id` | Activity ID match |125| `change-order-log.json` | `change_order_log[].procurement_link` | `procurement-log.json` | `procurement_log[].po_number` | Description/vendor match |126| `inspection-log.json` | `inspection_log[].linked_activity` | `schedule.json` | `activities[].id` | Activity match |127| `quality-data.json` | `inspections[].submittal_ref` | `submittal-log.json` | `submittal_log[].submittal_id` | Spec section match |128129### Matching Rules1301311. **Vendor match**: Case-insensitive substring match on supplier/vendor name. "Schiller" matches "Schiller Architectural Hardware".1322. **Spec section match**: Normalize to format "XX XX XX" (spaces, no dashes). "08 11 13" matches "081113".1333. **Activity ID match**: Exact match on `linked_schedule_activity_id` or `activity_id`.1344. **Only add links** — never remove existing links. If a link field already exists and has a value, skip.135136### Application137138```python139# Match submittals to procurement by vendor140for sub in submittal_log:141 if "procurement_link" not in sub or not sub["procurement_link"]:142 for po in procurement_log:143 if vendor_match(sub.get("vendor",""), po.get("supplier","")):144 sub["procurement_link"] = po["po_number"]145 po.setdefault("submittal_ids", [])146 if sub["submittal_id"] not in po["submittal_ids"]:147 po["submittal_ids"].append(sub["submittal_id"])148 break149```150151---152153## Pattern N4: Field Backfill154155**Problem**: Array entries missing required fields (status, id, type, dates).156157**Logic**: Apply defaults based on existing data context.158159### Required Fields by File160161| File | Array | Required Fields | Default Logic |162|------|-------|----------------|---------------|163| `project-config.json` | `documents_loaded[]` | `id`, `status` | `id`: "DOC-{index+1:03d}". `status`: "extracted" if has extraction_data, "received" if has filename only |164| `submittal-log.json` | `submittal_log[]` | `submittal_id`, `status`, `spec_section` | `submittal_id`: "SUB-{index+1:03d}". `status`: "SUBMITTED" if date exists |165| `procurement-log.json` | `procurement_log[]` | `po_number`, `status`, `supplier` | `status`: "quoted" if has quote_date, "executed" if has executed_date |166| `rfi-log.json` | `rfi_log[]` | `id`, `status`, `date_created` | `id`: "RFI-{index+1:03d}". `status`: "Draft" if no response |167| `inspection-log.json` | `inspection_log[]` | `id`, `result`, `date` | `result`: "pending" if no result recorded |168| `labor-tracking.json` | `labor_entries[]` | `id`, `date`, `company` | `id`: "LBR-{index+1:03d}" |169| `risk-register.json` | `risks[]` | `id`, `status`, `probability`, `impact` | `status`: "open" if no status |170171### Application172173```python174for i, doc in enumerate(documents_loaded):175 if "id" not in doc:176 doc["id"] = f"DOC-{i+1:03d}"177 plan.append(change(f"documents_loaded[{i}].id", "(missing)", doc["id"], "N4"))178 if "status" not in doc:179 status = infer_status(doc)180 doc["status"] = status181 plan.append(change(f"documents_loaded[{i}].status", "(missing)", status, "N4"))182```183184---185186## Pattern N5: Key Schema Compliance187188**Problem**: Wrong key names or missing required top-level keys.189190**Logic**: Check against canonical keys from the project-data skill. Rename or add missing keys.191192### Canonical Top-Level Keys193194Reference: `foremanos-core/skills/project-data/references/json-schema-reference.md`195196| File | Required Top-Level Keys |197|------|------------------------|198| `schedule.json` | `activities`, `milestones`, `critical_path`, `weather_sensitive_activities` |199| `delay-log.json` | `delay_events` |200| `rfi-log.json` | `rfi_log` |201| `meeting-log.json` | `meeting_log` |202| `inspection-log.json` | `inspection_log`, `permit_log` |203204### Common Renames205206| Wrong Key | Correct Key | File |207|-----------|------------|------|208| `construction_activities` | `activities` | schedule.json |209| `delays` | `delay_events` | delay-log.json |210| `rfis` | `rfi_log` | rfi-log.json |211| `meetings` | `meeting_log` | meeting-log.json |212213### Application214215```python216if "construction_activities" in schedule and "activities" not in schedule:217 schedule["activities"] = schedule.pop("construction_activities")218 plan.append(rename("construction_activities", "activities", "N5"))219```220221---222223## Pattern N6: Computed Totals224225**Problem**: Missing aggregated values that should be computed from child records.226227**Logic**: Sum from child records, write to parent field.228229### Computed Fields230231| File | Computed Field | Formula |232|------|---------------|---------|233| `cost-data.json` | `summary.subtotal_direct` | `sum(budget_by_division[].total)` |234| `cost-data.json` | `summary.contingency_used` | `sum(change_orders where source=contingency)` or from CO log |235| `cost-data.json` | `summary.contingency_remaining` | `contingency - contingency_used` |236| `cost-data.json` | `summary.total_committed` | `sum(budget_by_division[].committed_costs)` |237| `cost-data.json` | `budget_by_division[].current_amount` | `total + sum(applied_cos[].amount)` |238| `risk-register.json` | `total_exposure` | `sum(risks[].exposure)` |239| `pay-app-log.json` | `current_retainage` | Computed from pay app percentages |240| `labor-tracking.json` | `total_hours` | `sum(labor_entries[].hours)` |241| `delay-log.json` | `total_delay_days` | `sum(delay_events[].days_impact)` |242243### Application244245```python246used = sum(co["amount"] for co in change_orders if co.get("source") == "contingency")247# Or from known COs:248used = 6775 # Nucor CO#1249remaining = summary["contingency"] - used250if "contingency_used" not in summary:251 summary["contingency_used"] = used252 plan.append(change("summary.contingency_used", "(missing)", used, "N6"))253if "contingency_remaining" not in summary:254 summary["contingency_remaining"] = remaining255 plan.append(change("summary.contingency_remaining", "(missing)", remaining, "N6"))256```257258---259260## Pattern N7: Date/Format Normalization261262**Problem**: Inconsistent date formats and number types across files.263264**Logic**: Standardize all dates to ISO 8601, all numeric values as numbers (not strings).265266### Rules2672681. **Dates**: All date fields must be ISO 8601 format: `YYYY-MM-DD`269 - "01/22/2026" -> "2026-01-22"270 - "Jan 22, 2026" -> "2026-01-22"271 - "1/22/26" -> "2026-01-22"272 - If ambiguous (e.g., "02/03/26"), prefer US format (MM/DD/YY)2732742. **Numbers**: Numeric fields must be actual numbers, not strings275 - `"184500"` -> `184500`276 - `"$184,500"` -> `184500`277 - `"15.5%"` -> `0.155` (for rate fields) or `15.5` (for display fields — context-dependent)2782793. **Booleans**: Boolean fields must be actual booleans280 - `"true"` / `"yes"` / `"Y"` -> `true`281 - `"false"` / `"no"` / `"N"` -> `false`282283### Date Fields to Check284285| File | Date Fields |286|------|------------|287| `project-config.json` | `documents_loaded[].date`, `version_history[].date` |288| `submittal-log.json` | `submittal_log[].date`, `[].review_date`, `[].must_submit_by_date` |289| `procurement-log.json` | `procurement_log[].quote_date`, `[].executed_date`, `[].delivery_schedule.*` |290| `schedule.json` | `milestones[].date`, `activities[].start`, `[].finish` |291| `rfi-log.json` | `rfi_log[].date_created`, `[].date_responded` |292| `change-order-log.json` | `change_order_log[].date`, `[].approved_date` |293| `inspection-log.json` | `inspection_log[].date`, `[].next_date` |294295---296297## Pattern N8: Deduplication298299**Problem**: Duplicate entries in arrays from multiple extraction passes or manual entry.300301**Logic**: Detect by ID field or fuzzy match on description + date. Merge or flag.302303### Detection Rules3043051. **Exact ID match**: Two entries with same `id` / `submittal_id` / `po_number` -> merge (keep the one with more fields populated)3062. **Fuzzy match**: Same `vendor` + same `spec_section` + dates within 7 days -> flag for review3073. **Content match**: Same `description` (case-insensitive, trimmed) + same `date` -> merge308309### Merge Strategy310311When merging duplicates:3121. Keep the entry with more populated fields3132. Copy any non-null fields from the less-complete entry that are missing in the keeper3143. Record the merge in a `_merge_note` field: "Merged from duplicate entry on {date}"3154. Never delete — mark the duplicate with `_duplicate_of: "{keeper_id}"` and remove from the main array316317### Application318319```python320seen = {}321for entry in submittal_log:322 key = entry.get("submittal_id")323 if key in seen:324 # Merge: keep entry with more fields325 keeper = seen[key] if len(seen[key]) >= len(entry) else entry326 donor = entry if keeper is seen[key] else seen[key]327 for k, v in donor.items():328 if k not in keeper or keeper[k] is None:329 keeper[k] = v330 keeper["_merge_note"] = f"Merged duplicate {donor.get('submittal_id')}"331 plan.append(merge(key, "N8"))332 else:333 seen[key] = entry334```335336---337338## Execution Order339340When running all 8 patterns on a Project Brain:3413421. **N5** (Schema Compliance) — Fix key names first so other patterns find data3432. **N7** (Date/Format) — Normalize types before comparisons3443. **N8** (Deduplication) — Remove duplicates before counting3454. **N1** (Status Standardization) — Normalize status values3465. **N4** (Field Backfill) — Fill missing required fields3476. **N2** (Counter Reconciliation) — Recount after dedup and backfill3487. **N3** (Cross-Reference Linking) — Link after all entries are clean3498. **N6** (Computed Totals) — Compute aggregates last350351## Integration with `/data-health` Command352353The `/data-health` command in the `foremanos-compliance` plugin orchestrates these patterns:354- `/data-health scan` — Runs detection only (finds issues that N1-N8 would fix)355- `/data-health fix` — Applies patterns with confirmation356- `/data-health report` — Generates health score including normalization coverage357358## Constraints359360- **Never delete data** — Only add, rename, or modify fields361- **Always confirm** — Present repair plan before any changes362- **Atomic writes** — All changes to a file happen in one write operation363- **Preserve formatting** — Use `json.dumps(data, indent=2, ensure_ascii=False)` for output364- **Log changes** — After applying, add a `_normalization_log` entry to `project-config.json` version_history:365 ```json366 {367 "date": "2026-02-26",368 "action": "data_normalization",369 "patterns_applied": ["N1", "N3", "N6"],370 "changes_count": 12,371 "files_modified": ["submittal-log.json", "procurement-log.json", "cost-data.json"]372 }373 ```