= {}
+ for (const inv of domainInvariants) {
+ if (inv?.id) domainById[inv.id] = inv.invariant || inv.id
+ }
+
+ // Flat-fallback detection: if NO entry in either array has a recognised
+ // domain_role, render the original flat layout unchanged.
+ const allLaws = [...domainInvariants, ...derivedInvariants]
+ const useGrouped = allLaws.some(
+ (inv) => inv && VALID_DOMAIN_ROLES.includes(inv.domain_role as DomainRole),
+ )
+
+ const hasObserved = domainInvariants.length > 0
+ const hasDerived = derivedInvariants.length > 0
+
+ return (
+
+
+
+ {useGrouped ? (
+ // ── GROUPED LAYOUT ─────────────────────────────────────────────────
+ // Split observed + derived laws into role buckets, then render each
+ // non-empty bucket under its own subheading in canonical order.
+ ROLE_GROUPS.map(({ role, label }) => {
+ const observed = domainInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ const derived = derivedInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ if (observed.length === 0 && derived.length === 0) return null
+ return (
+
+ {/* Role-group subheading */}
+
+
{label}
+
+ Grounded
+
+
+
+ {observed.length > 0 && (
+
+
Observed
+
+ {observed.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {derived.length > 0 && (
+
+
Derived
+
+ {derived.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ )
+ })
+ ) : (
+ // ── FLAT LAYOUT (original) ──────────────────────────────────────────
+ // Rendered when no law carries a domain_role — preserves existing
+ // behaviour for blueprints scanned before the field was introduced.
+ <>
+ {hasObserved && (
+
+
+
Observed Laws
+
+ Grounded
+
+
+
+ {domainInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {hasDerived && (
+
+
+
Derived Laws
+
+ Inferred from premises
+
+
+
+ {derivedInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Unverified Invariants — UNGROUNDED gap list. Visually distinct from the
+// grounded tier: amber/warning surface + a prominent "Unverified" badge.
+// Shows searched[] so a reader can falsify "nothing enforces it."
+// ---------------------------------------------------------------------------
+
+export function UnverifiedInvariantsSection({ unenforcedInvariants }: { unenforcedInvariants: any[] }) {
+ return (
+
+ {/* Warning header — makes the epistemic tier unmissable */}
+
+
+ {/* One-line banner reinforcing the epistemic status */}
+
+
+
+ These are inferred and may not be true. Each item is a law the analysis expected to find enforced but could not locate. Verify with the listed search targets before acting.
+
+
+
+
+ {unenforcedInvariants.map((inv: any, i: number) => {
+ const searched: string[] = Array.isArray(inv.searched) ? inv.searched : []
+ return (
+
+ {/* Unverified badge + category/entity metadata */}
+
+
+ {inv.category && (
+
+ {inv.category}
+
+ )}
+ {inv.entity && (
+
+ {inv.entity}
+
+ )}
+ {inv.confidence && (
+
+ {inv.confidence}
+
+ )}
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+ {/* The expected law */}
+
+
+
+
+ {/* Why it was expected */}
+ {inv.why_expected && (
+
+ )}
+
+ {/* Risk of the gap */}
+ {inv.risk && (
+
+ )}
+
+ {/* Searched targets — key for falsifiability */}
+ {searched.length > 0 && (
+
+
+ Searched (nothing found)
+
+
+ {searched.map((loc, j) => (
+
+ ))}
+
+
+ )}
+
+ {inv.found_enforcement && inv.found_enforcement !== 'none' && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/archie/assets/viewer/src/lib/fixPrompt.ts b/archie/assets/viewer/src/lib/fixPrompt.ts
index e98a6e0a..f863d630 100644
--- a/archie/assets/viewer/src/lib/fixPrompt.ts
+++ b/archie/assets/viewer/src/lib/fixPrompt.ts
@@ -28,6 +28,10 @@ export type FixItem = Finding & {
* have a structured `pitfall_id`; pitfalls themselves use this field as a
* self-id marker so the same builder works for both. */
__kind?: 'finding' | 'pitfall'
+ /** For derived_invariant items: the ids of the domain_invariants this was
+ * derived from. When present, the builder inlines the premise texts so the
+ * receiving agent sees the full grounding chain. */
+ derived_from?: string[]
}
interface ResolvedPitfall {
@@ -109,6 +113,7 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
const pitfall = resolvePitfall(item, bp)
const decision = pitfall?.stems_from ? resolveDecision(pitfall.stems_from, bp) : null
+ const premises = resolveDerivedFromPremises(item, bp)
const guideline = resolveGuideline(item, bp)
const rules = resolveRules(item, opts.adoptedRules, bp)
const components = resolveComponents(item, bp)
@@ -243,6 +248,19 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
}
}
+ // ───── Derived-invariant premises ─────────────────────────────────────
+ if (premises.length > 0) {
+ const blocks = premises.map((p) => {
+ const lines: string[] = [`- \`${p.id}\``]
+ if (p.invariant) lines.push(` **Law:** ${p.invariant}`)
+ if (p.failure_mode) lines.push(` **Failure mode:** ${p.failure_mode}`)
+ return lines.join('\n')
+ })
+ sections.push(
+ `## Grounding premises (this law is derived from)\nThe derived invariant above holds only if all premises below hold.\n\n${blocks.join('\n\n')}`,
+ )
+ }
+
// ───── Decision chain (root → matching constraint) ─────────────────────
if (chainPath && chainPath.length > 1) {
const body = chainPath.map((step, i) => `${i + 1}. ${step}`).join('\n')
@@ -431,6 +449,39 @@ function formatAlternatives(alts: any): string[] {
return out.slice(0, 5)
}
+// ── Derived-invariant premise resolution ────────────────────────────────
+// When a FixItem carries `derived_from` ids, resolve each id to its
+// matching domain_invariant so the prompt includes the grounding premises.
+
+interface ResolvedPremise {
+ id: string
+ invariant?: string
+ failure_mode?: string
+}
+
+function resolveDerivedFromPremises(item: FixItem, bp: any): ResolvedPremise[] {
+ const ids = Array.isArray(item.derived_from) ? item.derived_from : []
+ if (ids.length === 0) return []
+ const domainInvariants: any[] = Array.isArray(bp?.domain_invariants) ? bp.domain_invariants : []
+ if (domainInvariants.length === 0) return []
+ const out: ResolvedPremise[] = []
+ for (const id of ids) {
+ const found = domainInvariants.find((d: any) => d?.id === id)
+ if (found) {
+ out.push({
+ id,
+ invariant: found.invariant,
+ failure_mode: found.failure_mode,
+ })
+ } else {
+ // id not found — include it as an unresolved reference so the agent
+ // knows it exists but can't be expanded.
+ out.push({ id })
+ }
+ }
+ return out
+}
+
// ── Guideline resolution ─────────────────────────────────────────────────
function collectGuidelines(bp: any): ResolvedGuideline[] {
diff --git a/archie/assets/viewer/src/pages/ReportPage.tsx b/archie/assets/viewer/src/pages/ReportPage.tsx
index 0b47ae75..22a3b764 100644
--- a/archie/assets/viewer/src/pages/ReportPage.tsx
+++ b/archie/assets/viewer/src/pages/ReportPage.tsx
@@ -5,7 +5,7 @@ import { LocalEditContext } from '@/components/local/context/LocalEditContext'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
-import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle } from 'lucide-react'
+import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle, BookOpen } from 'lucide-react'
import { fetchReport, type Bundle } from '@/lib/api'
import { autoBacktick } from '@/lib/autocode'
import { formatBlueprintTitle } from '@/lib/blueprintTitle'
@@ -192,6 +192,9 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
'integrations',
'technology',
'deployment',
+ 'product-model',
+ 'product-laws',
+ 'unverified-laws',
'problems',
'pitfalls',
'try-archie',
@@ -301,6 +304,11 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
const keyDecisions = bp.decisions?.key_decisions || []
const tradeOffs = bp.decisions?.trade_offs || []
const pitfalls = Array.isArray(bp.pitfalls) ? bp.pitfalls : []
+ const domainInvariants = Array.isArray(bp.domain_invariants) ? bp.domain_invariants : []
+ const derivedInvariants = Array.isArray(bp.derived_invariants) ? bp.derived_invariants : []
+ const unenforcedInvariants = Array.isArray(bp.unenforced_invariants) ? bp.unenforced_invariants : []
+ const productModel = bp.product_model && typeof bp.product_model === 'object' ? bp.product_model : null
+ const hasProductLaws = domainInvariants.length > 0 || derivedInvariants.length > 0
const archRules = bp.architecture_rules || {}
const filePlacement = archRules.file_placement_rules || []
const naming = archRules.naming_conventions || []
@@ -547,6 +555,37 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* Product */}
+ {(productModel || hasProductLaws || unenforcedInvariants.length > 0) && (
+
+
Product
+ {productModel && (
+
scrollToSection('product-model')}
+ icon={BookOpen}
+ label="Product Overview"
+ />
+ )}
+ {hasProductLaws && (
+ scrollToSection('product-laws')}
+ icon={Shield}
+ label="Product Laws"
+ />
+ )}
+ {unenforcedInvariants.length > 0 && (
+ scrollToSection('unverified-laws')}
+ icon={AlertTriangle}
+ label="Unverified Gaps"
+ />
+ )}
+
+ )}
+
{/* Practice */}
{(implementationGuidelines.length > 0 || communications.length > 0 || patternSelection.length > 0 || errorMapping.length > 0) && (
@@ -939,6 +978,27 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* 7b. Product Model — domain map */}
+ {productModel && (
+
+ )}
+
+ {/* 7c. Product Laws — grounded tier (domain_invariants + derived_invariants) */}
+ {hasProductLaws && (
+
+ )}
+
+ {/* 7d. Unverified Gaps — speculative unenforced_invariants, visually distinct */}
+ {unenforcedInvariants.length > 0 && (
+
+ )}
+
{/* 8. Implementation Guidelines */}
{implementationGuidelines.length > 0 && (
diff --git a/archie/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md b/archie/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
index 09711f0a..ec1cd892 100644
--- a/archie/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
+++ b/archie/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
@@ -1,4 +1,4 @@
-### Data agent (only if `has_persistence_signal == true`)
+### Data agent (always runs)
> **CRITICAL INSTRUCTIONS:**
> You are analyzing a codebase to inventory its data models, persistence stores, and the lifecycle of each model (how to add a new one, how to modify, how to read, what the backup posture is, what tests exist, which business objects consume it).
diff --git a/archie/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md b/archie/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md
new file mode 100644
index 00000000..a5394fb6
--- /dev/null
+++ b/archie/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md
@@ -0,0 +1,89 @@
+### Domain agent (always runs)
+
+> **CRITICAL INSTRUCTIONS:**
+> You are extracting this product's **behavioral invariants** — laws that must always hold for the product to be *correct*, and that are enforced by **code**, not by the database schema. You reason about the *domain*, the way the Structure agent reasons about code layout.
+>
+> **Schema-enforced contracts are NOT your job.** PK / FK / UNIQUE / NOT-NULL / soft-delete are the Data agent's `guarantees` — do NOT duplicate them. Your target is the law the database **cannot** enforce: a balance that must never go negative, an issued record that must never mutate, an event that must be counted at most once, a query that must always be tenant-scoped.
+>
+> **OBSERVE, do not invent. Cite or omit.** Every law MUST cite the `file:line` that enforces it. An invariant you cannot tie to enforcing code is a hypothesis, not a law — drop it. Hallucinated business laws are the failure mode; citations make them falsifiable. This is the same discipline the Data agent uses for field descriptions.
+>
+> **Self-discover the entities.** You run in parallel with the other Wave 1 agents and cannot read their output. Identify this product's core entities yourself from the scan's file_tree and skeletons — look in domain/model/entity directories, service packages, and schema declarations. Some overlap with the Data agent's inventory is expected and fine.
+>
+> **First, decide what this repository IS — it is not always an end-user app.** Archie runs on apps, libraries/SDKs, services/APIs, and CLIs/tools. Frame the "product" accordingly:
+> - **End-user app** → the value is a user journey; the core workflow is what a user opens it to do.
+> - **Library / SDK** → the value is the capability it exposes to *consumers*; the core workflow is the consumer's primary call path (construct → configure → call → handle result), and the core laws are the **behavioral contracts a caller can rely on** (ordering, idempotency, what a return/err guarantees, thread-safety promises).
+> - **Service / API** → the value is what it does for *clients*; the core workflow is the request lifecycle; the core laws are the guarantees the service makes per request.
+> - **CLI / tool** → the value is the command outcome; the core workflow is the invocation→effect path.
+>
+> **Anchor to the core FIRST — this is the most important instruction.** Decide the **primary value workflow** for whichever kind above this repo is — the path that delivers what the repo exists to provide. That is the **core**. Everything that *gates, monetizes, authenticates, or configures* access to the core — subscriptions/paywalls, auth/login, settings, telemetry — is **supporting**. Build/network/storage/serialization plumbing is **platform**. Cues: the repo's name + README, its public entrypoints/exported API, the most-depended-on domain types, the package that isn't `auth`/`billing`/`subscription`/`settings`/`infra`.
+>
+> Why this matters: **laws cluster where guard code is dense, and monetization/auth code is the most densely guarded — so a naive sweep over-produces paywall laws and starves the core.** You must counteract that bias deliberately: extract the **core first and hardest**, and ration the supporting/platform laws (see Depth). Tag every law with `domain_role` so the skew is visible and the output can lead with the core.
+>
+> **Write each law as a PRODUCT RULE — what the product always guarantees for its user or consumer, in the domain's own words.** A product owner should recognise the statement. Name product concepts (a location's kind, an account balance, an invoice, the recommendation shown) and describe the behaviour someone can rely on. **The code that backs the law has its own home: the `mechanism` field (the prose how) and `enforced_at` (the `file:line`).** Split every law into the two: the `invariant` is the product guarantee; the `mechanism` is how the code delivers it.
+> - ✅ `invariant`: *"A location is always treated as exactly one kind — the user's live GPS position, a saved city, or a timed-out fallback — and the screen the app opens to follows from that kind; it never mistakes one kind for another."*
+> `mechanism`: *"The kind is re-derived from the location id on every selection and never stored, mapping the GPS-position / timed-out / modified ids to their kinds."*
+>
+> Across every category, say *what the product guarantees* — about balances, totals, at-most-once effects, who can see what — and let the `mechanism` field carry the code. If you're about to name a private function or constant in `invariant`, that token belongs in `mechanism`; replace it with the **product concept** it stands for.
+>
+> **Where laws live — read these, in order:**
+> - `Validate()` methods, guard clauses, precondition checks (`if x < 0 { return err }`, `require(...)`, `assert ...`)
+> - finite-state-machine transition tables (`Permit(...)`, allowed-status maps, `canTransition`) — these define the legal lifecycle edges; an illegal edge is a law
+> - comments that state a law in prose (e.g. *"its value never turns negative"*, *"must be idempotent"*)
+> - **test assertions** — the invariants someone cared enough to pin (`assertEqual(total, sum)`, table-driven "should reject…" cases). These are the highest-signal source.
+>
+> **Bulk-content read exception (stated, not implicit):** the orchestration step bans reading bulk categories. You MAY surgically Read **test files** to harvest invariant assertions — bounded to the few highest-signal test files per core entity in default depth. In comprehensive depth (`DEPTH=comprehensive`) this bound is lifted along with the global bulk-read ban.
+>
+> **For the CORE, mine the data flow, not just the guards.** The core's correctness often lives in *computation and data contracts*, not explicit `if`-guards — so a guard-only sweep misses it. For the core value workflow, ask: *what must hold about the inputs and outputs for the result to be correct?* Examples of the shape: a derived value computed from the right source (an age derived from a birth date, not stored stale); a unit normalized before a lookup (a temperature converted to a canonical unit before the recommendation table); an output that always reflects the currently-selected inputs. These are real laws even when enforcement is a transformation in the data path rather than a thrown error — cite the transformation site. This is how the core earns its fair share of laws instead of losing to the densely-guarded paywall.
+>
+> **Run this taxonomy as a checklist against each core entity.** For each (entity × category), emit a law when one is enforced; move on when none is. Do NOT force a law into a category it doesn't fit.
+> - **conservation / accounting** — totals reconcile (a record total equals the sum of its line items; ledger debits equal credits)
+> - **value-bound** — a quantity stays within bounds (balance ≥ 0, amount ≥ 0, quantity > 0)
+> - **lifecycle / state** — terminal or immutable states; only FSM-permitted transitions are legal
+> - **idempotency** — the same key produces an at-most-once effect (ingestion, charge creation, webhooks)
+> - **tenant-isolation** — every query is scoped by the tenant key
+> - **append-only / monotonicity** — rows are never mutated or retroactively deleted
+> - **referential (app-enforced)** — a reference with no DB foreign key is validated in application code
+>
+> **Depth & distribution (default depth).** Emit the highest-signal laws — precision over coverage, soft cap ~12 total. Spend that budget core-first:
+> - **Core laws: no per-subsystem cap.** Extract every core law that clears the cite-or-omit bar — the core can never be over-represented.
+> - **Supporting subsystems: at most ~2–3 laws *each*** (subscription, auth, settings…). If a densely-guarded feature like a paywall yields more, keep only the most load-bearing 2–3 and drop the rest — do NOT let one monetization/auth subsystem consume the budget the core needs.
+> - **Platform: at most ~1–2 total** (networking/build/storage plumbing rarely carries product laws).
+> - If, after this, the core produced fewer laws than a single supporting subsystem, go back and mine the core's data flow harder (see above) before emitting — a core-starved result means you swept guards instead of reasoning about value.
+>
+> **COMPREHENSIVE DEPTH (`DEPTH=comprehensive`) — NO CAPS OF ANY KIND.** The total ~12 cap, the per-supporting-subsystem ~2–3 cap, and the platform ~1–2 cap are ALL lifted. Emit every law in every role that clears the cite-or-omit bar, with no upper bound and no padding. Still extract core-first and still tag `domain_role`, but suppress nothing.
+>
+> ## Output — a top-level `domain_invariants` array
+>
+> Each entry:
+> ```json
+> {
+> "id": "inv-balance-001",
+> "entity": "AccountBalance",
+> "category": "value-bound",
+> "domain_role": "core",
+> "invariant": "An account's spendable balance never goes negative — a customer can never spend value they haven't funded.",
+> "mechanism": "Every debit is checked against the remaining balance and rejected past zero before the write commits.",
+> "enforced_at": ["app/wallet/balance_repo.ext:214", "app/wallet/engine/"],
+> "evidence": ["code-comment:app/wallet/balance.ext:43", "guard:app/wallet/balance_repo.ext:214"],
+> "failure_mode": "A customer spends value they never funded; the ledger and the charged total diverge and can't be reconciled.",
+> "confidence": "stated",
+> "keywords": ["balance", "debit", "wallet"]
+> }
+> ```
+>
+> Field rules:
+> - **id** — `inv--NNN`. Stable across runs for the same law.
+> - **entity** — the core entity the law governs, in the codebase's own vocabulary.
+> - **category** — exactly one taxonomy slug from the checklist above.
+> - **domain_role** — REQUIRED. Exactly one of `"core"` (delivers the product's primary value), `"supporting"` (gates/monetizes/authenticates/configures the core — subscription, auth, settings), or `"platform"` (build/network/storage plumbing). This is what lets the output lead with core laws and ration supporting ones.
+> - **invariant** — one sentence stating what the product guarantees for its user or consumer, in the domain's own words (a product owner should recognise it). This sentence names **product concepts only** — no function/method names, no constants, no `items[selectedIndex]`-style code expressions. The code that enforces it goes in `mechanism`; the `file:line` goes in `enforced_at`. A reviewer must understand it without reading the source.
+> - **mechanism** — REQUIRED. One phrase describing *how* the code enforces the law: name the function, the constants it maps, the derivation (`getLocationType()` maps the id to a kind; `canAddChildren()` checks `< MAX_BABY_COUNT`). **This is the home for the code detail — it is what keeps `invariant` product-clean.** If you catch yourself putting a function or constant name in `invariant`, move it here.
+> - **enforced_at** — array of `file:line` (or directory) sites that enforce it. REQUIRED and non-empty — this is the citation that makes the law falsifiable.
+> - **evidence** — array of typed citations: `guard:`, `code-comment:`, `test:`, `fsm-edge:`. At least one.
+> - **failure_mode** — what breaks in production if the law is violated (the cost), in one sentence.
+> - **confidence** — `"stated"` when ≥2 corroborating sources or an explicit guard/comment; `"inferred"` when read from a single weak signal (one test, an implicit clamp).
+> - **keywords** — 2-5 terms an agent would use when describing a task that touches this law (drives the prompt-time hook match downstream).
+>
+> If the codebase has no stateful business laws (a pure stateless utility), emit `domain_invariants: []`. An empty array is a correct answer. Do NOT pad with generic programming advice — every entry must be specific to THIS product and cite real code.
+>
+> GROUNDING RULES apply (see below).
diff --git a/archie/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md b/archie/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
index 45ce5451..848e0426 100644
--- a/archie/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
+++ b/archie/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
@@ -36,6 +36,7 @@ Agent prompt:
> - Changed communication patterns or integrations
> - New technology or dependencies
> - Modified file placement patterns
+> - New or changed product invariants (`domain_invariants[*]`) — behavioral laws the changed files add, alter, or remove (balance bounds, lifecycle immutability, idempotency, tenant scoping). Cite `enforced_at` for each, same cite-or-omit discipline as the full Domain agent. Omit when no changed file touches an enforced law.
>
> Return the same JSON structure as the full analysis but ONLY for sections affected by the changes. Omit unchanged sections — they'll be preserved from the existing blueprint.
>
@@ -47,20 +48,23 @@ Then skip to Step 4.
### If SCAN_MODE = "full" (default):
-Spawn 3–5 {{ANALYSIS_MODEL}} subagents in parallel, each focused on a different analytical concern. ALL agents read ALL source files under `$PROJECT_ROOT` — they are not split by directory. Each agent gets: the scan.json file_tree, dependencies, config files, and the GROUNDING RULES at the end of this step.
+Spawn 3–6 {{ANALYSIS_MODEL}} subagents in parallel, each focused on a different analytical concern. ALL agents read ALL source files under `$PROJECT_ROOT` — they are not split by directory. Each agent gets: the scan.json file_tree, dependencies, config files, and the GROUNDING RULES at the end of this step.
+
+**The five core agents — Structure, Patterns, Technology, Data, Domain — ALWAYS spawn.** They are NOT gated on any heuristic. Data inventories the data layer; Domain extracts the product's behavioral invariants; both feed the later steps (merge → blueprint → rules) on every run. A repo with no data surface simply yields an empty `data_models` / `domain_invariants` array — a valid result, never a reason to skip the agent. (`has_persistence_signal` is no longer a spawn gate; it survives only as an informational hint inside the Data/Domain prompts.)
**Conditional agents:**
-- **UI Layer** — spawn when `frontend_ratio >= 0.20`; otherwise skip.
-- **Data** — spawn when `has_persistence_signal == true` (set by scanner.py based on detected ORM deps, schema files, migrations dirs, mobile local-persistence APIs, or declared databases in the tech stack); otherwise skip.
+- **UI Layer** — the ONLY optional Wave 1 agent. Spawn when `frontend_ratio >= 0.20`; otherwise skip.
-The first 3 agents (Structure, Patterns, Technology) always spawn. The Data and UI Layer agents spawn independently — a pure-frontend SPA with no persistence gets UI Layer but not Data; a headless backend service with a DB gets Data but not UI Layer; a full-stack app gets all 5.
+A backend service gets the five core agents; a full-stack app gets all six.
-**When `DEPTH=comprehensive`, spawn ALL agents (Structure, Patterns, Technology, Data, UI Layer) regardless of `frontend_ratio` or persistence signal.**
+**When `DEPTH=comprehensive`, also spawn the UI Layer agent regardless of `frontend_ratio` — the five core agents already always run.**
**Bulk content — off-limits for reading.** `scan.json.bulk_content_manifest` lists files classified by `.archiebulk` as "visible inventory, not contents": categories like `ui_resource` (Android `res/`, iOS storyboards), `generated`, `localization`, `migration`, `fixture`, `asset`, `lockfile`, `dependency`, `data`. Every agent below inherits this rule: **you may reference these paths by name and inventory counts, but you MUST NOT call Read on them.** The scanner has already summarized their shape. If a specific file is genuinely required to resolve a finding, read it surgically and note why — it is an exception, not the default.
**Data agent exception (stated, not implicit):** the Data agent MAY surgically Read 1-2 recent migration files per persistence store to extract the observed `how_to_add` / `how_to_modify` procedure. This is the only blanket exception to the bulk-content rule and is bounded — enumeration of every migration in `migration` category is still forbidden.
+**Domain agent exception (stated, not implicit):** the Domain agent MAY surgically Read **test files** to harvest invariant assertions (the highest-signal source of product laws), bounded to the few most relevant test files per core entity in default depth. Enumeration of every test in the `fixture` category is still forbidden.
+
**When `DEPTH=comprehensive`, the bulk-content read ban is lifted: agents MAY Read any path that is not excluded by `.gitignore`/`.archieignore` (the ignore system remains the boundary). The 1-2-migration-files limit does not apply in comprehensive depth.**
**Dispatching the sub-agents:**
@@ -75,7 +79,8 @@ All paths are relative to the project root (your cwd).
| Patterns | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/patterns-agent.md` | `.archie/tmp/archie_sub2_$PROJECT_NAME.json` | Always |
| Technology | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/technology-agent.md` | `.archie/tmp/archie_sub3_$PROJECT_NAME.json` | Always |
| UI Layer | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/ui-layer-agent.md` | `.archie/tmp/archie_sub4_$PROJECT_NAME.json` | Only when `frontend_ratio >= 0.20` |
-| Data | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/data-agent.md` | `.archie/tmp/archie_sub5_$PROJECT_NAME.json` | Only when `has_persistence_signal == true` |
+| Data | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/data-agent.md` | `.archie/tmp/archie_sub5_$PROJECT_NAME.json` | Always |
+| Domain | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/domain-agent.md` | `.archie/tmp/archie_sub6_$PROJECT_NAME.json` | Always |
**Before dispatch — append the output contract to each sub-agent's prompt,
substituting its output path from the table above as the "file path named
@@ -102,20 +107,20 @@ not just the aggregate wave1 step). The sub-agent's FIRST action is
`python3 .archie/telemetry.py agent-start wave1 ` and its LAST action
(after writing its output file) is `python3 .archie/telemetry.py agent-finish wave1 `,
where `` is: Structure → `structure`, Patterns → `patterns`,
-Technology → `technology`, UI Layer → `ui`, Data → `data`.
+Technology → `technology`, UI Layer → `ui`, Data → `data`, Domain → `domain`.
The merge step (Step 4) reads each agent's output file directly — do NOT
copy or transcribe a subagent's output yourself.
-All spawned sub-agents (3 always + UI Layer and/or Data as applicable) run at the {{ANALYSIS_MODEL}} model. {{>dispatch_parallel}}
+All spawned sub-agents (5 core always + UI Layer when `frontend_ratio >= 0.20`) run at the {{ANALYSIS_MODEL}} model. {{>dispatch_parallel}}
-After the parallel dispatch returns, fold the per-agent timings into the `wave1` step so it reports each fact agent's duration (Structure / Patterns / Technology / UI Layer / Data) the same way `wave2_synthesis` does, instead of one aggregate number:
+After the parallel dispatch returns, fold the per-agent timings into the `wave1` step so it reports each fact agent's duration (Structure / Patterns / Technology / Data / Domain / UI Layer) the same way `wave2_synthesis` does, instead of one aggregate number:
```bash
python3 .archie/telemetry.py collect-agents "$PROJECT_ROOT" wave1
```
-Then record per-agent counts for trend tracking. Each call no-ops gracefully when its source file is missing (skipped agent — sub5 absent when `has_persistence_signal == false`). Uses the standard `python3 -c …` form that both Claude and Codex auto-approve via the installer's command catalogue — no new permission rules needed:
+Then record per-agent counts for trend tracking. Each call no-ops gracefully when its source file is missing (skipped agent — e.g. sub4 absent when `frontend_ratio < 0.20`; the five core agents always produce their file). Uses the standard `python3 -c …` form that both Claude and Codex auto-approve via the installer's command catalogue — no new permission rules needed:
```bash
DATA_COUNT=$(python3 -c "import json,os,sys; p=sys.argv[1]; print(len((json.load(open(p)).get('data_models') or [])) if os.path.exists(p) else 0)" .archie/tmp/archie_sub5_$PROJECT_NAME.json)
diff --git a/archie/assets/workflow/deep-scan/steps/step-4-merge.md b/archie/assets/workflow/deep-scan/steps/step-4-merge.md
index 2e64983c..2581633d 100644
--- a/archie/assets/workflow/deep-scan/steps/step-4-merge.md
+++ b/archie/assets/workflow/deep-scan/steps/step-4-merge.md
@@ -27,13 +27,14 @@ Step 4 is a **consumer step**: Step 3 already assigned each Wave 1 sub-agent
its output path and appended the output contract to its prompt before
dispatch. Each sub-agent has now written its file under `.archie/tmp/`.
-**Expected files** (skip UI Layer when `frontend_ratio < 0.20`; skip Data when `has_persistence_signal == false`):
+**Expected files** (UI Layer is the only optional one — skip when `frontend_ratio < 0.20`; Structure, Patterns, Technology, Data, and Domain always run):
- `.archie/tmp/archie_sub1_$PROJECT_NAME.json` (Structure)
- `.archie/tmp/archie_sub2_$PROJECT_NAME.json` (Patterns)
- `.archie/tmp/archie_sub3_$PROJECT_NAME.json` (Technology)
- `.archie/tmp/archie_sub4_$PROJECT_NAME.json` (UI Layer, optional)
-- `.archie/tmp/archie_sub5_$PROJECT_NAME.json` (Data, optional)
+- `.archie/tmp/archie_sub5_$PROJECT_NAME.json` (Data)
+- `.archie/tmp/archie_sub6_$PROJECT_NAME.json` (Domain)
**If resuming via `--from` or `--continue`:** `.archie/tmp/` is workspace-relative
so the files normally survive reboots, but an interrupted or `--from 4` run
@@ -44,10 +45,10 @@ missing — do NOT attempt to re-extract output from a subagent's transcript.
Merge the files that exist:
```bash
-python3 .archie/merge.py "$PROJECT_ROOT" .archie/tmp/archie_sub1_$PROJECT_NAME.json .archie/tmp/archie_sub2_$PROJECT_NAME.json .archie/tmp/archie_sub3_$PROJECT_NAME.json .archie/tmp/archie_sub4_$PROJECT_NAME.json .archie/tmp/archie_sub5_$PROJECT_NAME.json
+python3 .archie/merge.py "$PROJECT_ROOT" .archie/tmp/archie_sub1_$PROJECT_NAME.json .archie/tmp/archie_sub2_$PROJECT_NAME.json .archie/tmp/archie_sub3_$PROJECT_NAME.json .archie/tmp/archie_sub4_$PROJECT_NAME.json .archie/tmp/archie_sub5_$PROJECT_NAME.json .archie/tmp/archie_sub6_$PROJECT_NAME.json
```
-`merge.py` warns and skips files that weren't produced (skipped agents) — listing all five paths unconditionally keeps the command stable across full / frontend-only / backend-only repos.
+`merge.py` warns and skips files that weren't produced (skipped agents) — listing all six paths unconditionally keeps the command stable across full / frontend-only / backend-only repos.
This saves `$PROJECT_ROOT/.archie/blueprint_raw.json` (raw merged data). Verify the output shows non-zero component/section counts. If it says "0 sections, 0 components", the merge failed — check the agent output files.
diff --git a/archie/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md b/archie/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
index 570e41f0..280a76c5 100644
--- a/archie/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
+++ b/archie/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
@@ -9,13 +9,14 @@ TELEMETRY_STEP5_START=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
**If START_STEP > 5, skip this step.**
-Wave 2 reasoning is split into **three sub-agents** that run **in parallel**, all at `{{REASONING_MODEL}}`:
+Wave 2 reasoning is split into **up to four sub-agents** that run **in parallel**, all at `{{REASONING_MODEL}}`:
- **Design** — decision chain, architectural style, key decisions, trade-offs, out-of-scope, implementation guidelines, communication-pattern enrichment.
- **Risk** — findings + pitfalls.
- **Overview** — architecture diagram + executive summary.
+- **Product** — `product_model` (domain map) + `derived_invariants` (reasoned product laws) + `unenforced_invariants` (the ungrounded gap list). It reasons over the Wave 1 Domain agent's `domain_invariants`. **Spawn on a FULL scan when `domain_invariants` is non-empty.** It is skipped in incremental mode: `derived_invariants`/`unenforced_invariants` are *global* reasoning over the whole law set, not per-change deltas, so they don't compose with a patch merge — the prior full scan's product sections are preserved unchanged by the patch and refresh on the next full scan (Wave 1 still updates the observed `domain_invariants` incrementally).
-Key ownership is disjoint, so the three outputs merge cleanly. The same three-agent dispatch runs in BOTH full and incremental modes — only the injected context preamble and the finalize flag differ (no special-case workflow).
+Key ownership is disjoint, so the outputs merge cleanly. The same dispatch runs in BOTH full and incremental modes — only the injected context preamble and the finalize flag differ (no special-case workflow).
### Findings store (accumulates across all runs)
@@ -42,13 +43,22 @@ For each sub-agent below, Read its prompt file, then ALSO Read `{{WORKFLOW_ROOT}
All paths are relative to the project root (your cwd).
-| Sub-agent | Prompt file | Output path |
-|---|---|---|
-| Design | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5a-design.md` | `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` |
-| Risk | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5b-risk.md` | `.archie/tmp/archie_sub_risk_$PROJECT_NAME.json` |
-| Overview | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5c-overview.md` | `.archie/tmp/archie_sub_overview_$PROJECT_NAME.json` |
+| Sub-agent | Prompt file | Output path | Spawn when |
+|---|---|---|---|
+| Design | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5a-design.md` | `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` | Always |
+| Risk | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5b-risk.md` | `.archie/tmp/archie_sub_risk_$PROJECT_NAME.json` | Always |
+| Overview | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5c-overview.md` | `.archie/tmp/archie_sub_overview_$PROJECT_NAME.json` | Always |
+| Product | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5d-product.md` | `.archie/tmp/archie_sub_product_$PROJECT_NAME.json` | Full scan + `domain_invariants` non-empty |
-**Mode preamble — prepend the SAME block to all three prompts:**
+**Evaluate the Product spawn gate first (full mode only).** In incremental mode, do NOT spawn Product — skip straight to Design / Risk / Overview. In full mode, count the laws the Wave 1 Domain agent produced (the merged `blueprint_raw.json` already carries `domain_invariants`) using the auto-approved `python3 -c …` form:
+
+```bash
+DOMAIN_LAW_COUNT=$(python3 -c "import json,os,sys; p=sys.argv[1]; print(len((json.load(open(p)).get('domain_invariants') or [])) if os.path.exists(p) else 0)" .archie/blueprint_raw.json)
+```
+
+Spawn the **Product** sub-agent only when `DOMAIN_LAW_COUNT` is greater than 0. Design, Risk, and Overview always spawn regardless of this count, in both modes.
+
+**Mode preamble — prepend the SAME block to all sub-agent prompts:**
- **Always prepend the depth contract (both scan modes).** When this run's `DEPTH=comprehensive`, prepend this line verbatim (it makes ALL counts in the body floors, so the Risk agent's "soft floor of 3" findings/pitfalls and every other count go unbounded):
> *COMPREHENSIVE MODE — be exhaustive. Every item-count in these instructions ("N-M", "up to N", "soft floor of N", "top N", "the most important") is a FLOOR, not a ceiling: emit every item that meets the quality bar, with no upper bound and no padding. Exception: keep the architecture diagram to 8-12 nodes.*
@@ -56,7 +66,7 @@ All paths are relative to the project root (your cwd).
When `DEPTH=default`, prepend nothing and apply the stated caps.
- **If SCAN_MODE = "full" (default):**
- > Produce your sections fresh from the full Wave-1 analysis in `$PROJECT_ROOT/.archie/blueprint_raw.json` (components, communication patterns, technology, deployment, frontend, data models and persistence stores when the Data agent spawned).
+ > Produce your sections fresh from the full Wave-1 analysis in `$PROJECT_ROOT/.archie/blueprint_raw.json` (components, communication patterns, technology, deployment, frontend, data models and persistence stores, and `domain_invariants` — the product's observed correctness laws). The Design and Risk agents should read `domain_invariants` per their own instructions (Design links each key decision to the law it preserves; Risk turns each law into a violation pitfall). The Product agent reasons over `domain_invariants` exclusively.
- **If SCAN_MODE = "incremental":**
> INCREMENTAL UPDATE. The architecture was previously analyzed — `$PROJECT_ROOT/.archie/blueprint.json` is the current full architecture and `$PROJECT_ROOT/.archie/blueprint_raw.json` carries the structural changes from Step 4. These files changed: [list `changed_files`]. Update ONLY the sections you own that are affected by these changes, and return ONLY what changed — unchanged sections are preserved by the patch merge. Use the 4-field contract (`problem_statement`, `evidence`, `root_cause`, `fix_direction`) when writing finding or pitfall entries.
@@ -69,18 +79,20 @@ OUTPUT CONTRACT (mandatory):
{{>output_contract}}
```
-All three sub-agents run at the `{{REASONING_MODEL}}` model. {{>dispatch_parallel}}
+All sub-agents run at the `{{REASONING_MODEL}}` model. {{>dispatch_parallel}}
The merge step below reads each agent's output file directly — do NOT copy or transcribe a subagent's output yourself.
-**Verify all three output files exist before merging.** Check that
+**Verify the expected output files exist before merging.** Check that
`archie_sub_design_$PROJECT_NAME.json`, `archie_sub_risk_$PROJECT_NAME.json`, and
-`archie_sub_overview_$PROJECT_NAME.json` are all on disk under `.archie/tmp/`. If any
-is missing, that sub-agent failed — **STOP, report which file is missing, and do NOT
+`archie_sub_overview_$PROJECT_NAME.json` are all on disk under `.archie/tmp/` — and
+`archie_sub_product_$PROJECT_NAME.json` too **when the Product agent was spawned** (full
+scan with non-empty `domain_invariants`; never in incremental mode). If any expected file is
+missing, that sub-agent failed — **STOP, report which file is missing, and do NOT
run the merge or `complete-step 5`.** Re-run Step 5 (`{{COMMAND_PREFIX}}archie-deep-scan
--from 5`) to respawn the agents. Proceeding with a partial merge would mark Step 5
complete with whole sections (e.g. the diagram or executive summary) silently missing,
-and resume would not re-run it. All three must be present, or none.
+and resume would not re-run it. All expected files must be present, or none.
**Fold in the per-agent timings** (each sub-agent self-timed its run). This records
how long each of Design/Risk/Overview ran, alongside the step's end-to-end duration:
@@ -89,20 +101,20 @@ how long each of Design/Risk/Overview ran, alongside the step's end-to-end durat
python3 .archie/telemetry.py collect-agents "$PROJECT_ROOT" wave2_synthesis
```
-### Merge (one finalize call, all three files)
+### Merge (one finalize call, all files)
-After all three output files are on disk, merge them in a SINGLE finalize call — this keeps Step 5 atomic (the blueprint is written once), so an interrupted run is recovered by simply re-running Step 5 from the top.
+After the output files are on disk, merge them in a SINGLE finalize call — this keeps Step 5 atomic (the blueprint is written once), so an interrupted run is recovered by simply re-running Step 5 from the top. The Product file is listed unconditionally; `finalize.py` warns and skips it when the Product agent wasn't spawned (`domain_invariants` empty), keeping the command stable.
- **If SCAN_MODE = "full":**
```bash
- python3 .archie/finalize.py "$PROJECT_ROOT" .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json
+ python3 .archie/finalize.py "$PROJECT_ROOT" .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json .archie/tmp/archie_sub_product_$PROJECT_NAME.json
```
- **If SCAN_MODE = "incremental":**
```bash
- python3 .archie/finalize.py "$PROJECT_ROOT" --patch .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json
+ python3 .archie/finalize.py "$PROJECT_ROOT" --patch .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json .archie/tmp/archie_sub_product_$PROJECT_NAME.json
```
-This single command merges all three agents' output (routing `findings` to `findings.json` and deep-merging the rest), normalizes the schema, renders CLAUDE.md + AGENTS.md + rule files, installs hooks, and validates. Review the validation output — warnings are informational, not blocking. The validate step now includes a WARN-only **cross-link integrity** check (pitfall→decision, finding→pitfall, trade_off→decision, decision_chain→key_decisions); since Design and Risk run in parallel, a small number of these warnings is expected and not a failure.
+This single command merges all agents' output (routing `findings` to `findings.json` and deep-merging the rest), normalizes the schema, renders CLAUDE.md + AGENTS.md + rule files, installs hooks, and validates. Review the validation output — warnings are informational, not blocking. The validate step now includes a WARN-only **cross-link integrity** check (pitfall→decision, finding→pitfall, trade_off→decision, decision_chain→key_decisions); since Design and Risk run in parallel, a small number of these warnings is expected and not a failure.
**Backward-check the findings against actual code.** After finalize writes `.archie/findings.json`, run the {{VERIFY_MODEL}} verifier and apply hysteresis. The verifier reads each finding's required `triggering_call_site` field, walks one level out from the cited caller, and decides per finding: `keep` (failure fires there — real finding), `demote` (call site exists but failure doesn't fire — risk class, not current problem), or `drop` (premise unsound for this codebase). The hysteresis layer then applies the verdict with cross-run stability — single-scan flips on unchanged code don't propagate (kills LLM-noise flicker), but a git-diff anchor (a file in the finding's `triggering_call_site` was touched in the last 5 commits) lets a real transition land immediately.
diff --git a/archie/assets/workflow/deep-scan/steps/step-5a-design.md b/archie/assets/workflow/deep-scan/steps/step-5a-design.md
index 6eeda427..836f9d99 100644
--- a/archie/assets/workflow/deep-scan/steps/step-5a-design.md
+++ b/archie/assets/workflow/deep-scan/steps/step-5a-design.md
@@ -2,6 +2,8 @@ You are the **Design reasoning agent** (Wave 2). You read the full Wave-1 analys
**Timing (required):** Your FIRST action, before reading anything, run `python3 .archie/telemetry.py agent-start wave2_synthesis design`. Your LAST action, after writing your output file per the OUTPUT CONTRACT, run `python3 .archie/telemetry.py agent-finish wave2_synthesis design`.
+**Link decisions to the product laws they preserve.** Wave 1's Domain agent wrote `domain_invariants` (the product's observed correctness laws — balance bounds, lifecycle immutability, idempotency, tenant scoping) into `blueprint_raw.json`. For each `key_decisions[*]` that exists to uphold such a law, set its `forced_by` to name that law and its `enables` to the capability the law preserves — this is what turns a flat decision into a deep, well-motivated one. When a single law cuts across the whole system (e.g. tenant isolation on every query), promote it to its own `key_decisions[*]` entry. Do NOT restate the laws verbatim as decisions or emit `domain_invariants`/`derived_invariants` yourself — the Product agent owns the law-derivation track; you only reference the laws.
+
With the COMPLETE picture of what was built and how, produce deep architectural reasoning. Every claim must be grounded in code you can see in the blueprint or source files. Do NOT invent theoretical constraints.
### 1. Decision Chain
diff --git a/archie/assets/workflow/deep-scan/steps/step-5b-risk.md b/archie/assets/workflow/deep-scan/steps/step-5b-risk.md
index 4c6a46cf..a933ec21 100644
--- a/archie/assets/workflow/deep-scan/steps/step-5b-risk.md
+++ b/archie/assets/workflow/deep-scan/steps/step-5b-risk.md
@@ -4,6 +4,8 @@ You are the **Risk reasoning agent** (Wave 2). You produce the *risk* layer: `fi
You will upgrade any draft findings in the accumulated store, emit new findings you discover, AND emit pitfalls. Both findings and pitfalls share the same 4-field core (`problem_statement`, `evidence`, `root_cause`, `fix_direction`); pitfalls differ in altitude (class-of-problem, not instance) and ownership (blueprint-durable, not per-run).
+**Turn product laws into pitfalls.** `blueprint_raw.json` carries `domain_invariants` — the product's observed correctness laws (the Domain agent's output). For each law whose violation fails *silently or with delay* (a balance that can be driven negative, an issued record that can be mutated, ingestion that can double-count), emit a pitfall describing the trap: how a plausible edit slips past the law and what corrupts downstream as a result, with `root_cause` naming the law and its enforcing mechanism. These are the highest-value pitfalls — they guard product correctness, not just code structure. Do NOT emit `domain_invariants` or `derived_invariants` yourself (Domain and Product own those).
+
**Grounding your root causes in the decision layer.** The Design agent runs alongside you and writes `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` (architectural_style, key_decisions, decision_chain). **If that file is present, read it** and name your `root_cause` strings in its vocabulary — a pitfall/finding whose root cause is architectural should reference an actual decision title or a `communication.patterns[].name` from `blueprint_raw.json`. If the file is not yet available, ground root causes in the patterns/components/constraints visible in `blueprint_raw.json` (and, in incremental mode, the decisions already in `blueprint.json`). Either way, every root cause must trace to something real in the blueprint — never invent a decision name.
**root_cause shape — three required parts (this is what keeps findings dense, not thin).** Every `root_cause` must combine:
diff --git a/archie/assets/workflow/deep-scan/steps/step-5d-product.md b/archie/assets/workflow/deep-scan/steps/step-5d-product.md
new file mode 100644
index 00000000..dd15c586
--- /dev/null
+++ b/archie/assets/workflow/deep-scan/steps/step-5d-product.md
@@ -0,0 +1,88 @@
+You reason about this product's **domain** the way the Design agent reasons about its **code architecture**. Your input is the Domain agent's observed product laws — read `$PROJECT_ROOT/.archie/blueprint_raw.json` (full mode) or `$PROJECT_ROOT/.archie/blueprint.json` (incremental) and study its `domain_invariants` array (each is a cited, enforced law) plus `data_models` and `components` for entity context. Read it ONCE.
+
+You produce three things the citation-bound Wave 1 Domain agent structurally cannot. You own these keys and no others — emit them at the top level of your output JSON.
+
+## 1. `product_model` — what the product IS (informational)
+
+A clear picture of the product in the domain's own vocabulary: a rich prose description plus the end-to-end workflow that delivers its value. Do NOT enumerate entities here — the Data Models section already inventories every model in detail; duplicating them is noise.
+
+```json
+"product_model": {
+ "summary": "A DETAILED description — 3 to 6 sentences — of what this REPOSITORY is from a product/capability perspective. It is not always an end-user app: for a library/SDK describe the capability it gives consumers and the problem it solves for them; for a service/API what it does for clients; for an app what the user gets. Cover the core value loop, the key inputs it turns into that value, and the main supporting concerns (monetization, auth) in one clause. Domain/capability language. This is the headline a newcomer reads to understand what the repo stands for.",
+ "core_workflow": [
+ {
+ "title": "Short step name (3-6 words)",
+ "description": "One sentence on what happens in this step and why it matters to the product's value."
+ }
+ ]
+}
+```
+
+- **`summary`** — written as **2–4 SHORT paragraphs separated by blank lines (`\n\n`), not one dense block.** Suggested shape: (1) a one-sentence lead — what the repo is and the value it delivers; (2) the core value loop — the inputs it turns into that value; (3) how it's built/served + the main supporting concerns (monetization, auth) in a sentence or two. Bold the product name and key domain nouns with markdown (`**like this**`). Keep sentences tight — a reader should be able to skim it.
+- **`core_workflow`** — an ORDERED list tracing the value path end to end, framed for what the repo IS: an app's **user journey**, a library's **consumer call path** (construct → configure → call → handle result), or a service's **request lifecycle**. Each stage is product/usage behaviour, not an internal code step. Keep to the ~5-8 stages that actually move the value forward; default depth soft-caps at ~8 (comprehensive lifts it).
+
+> **⚠️ STRICT — every `core_workflow` item is an OBJECT `{title, description}`, NEVER a bare string.** A plain string renders as an untitled "Step N" and defeats the whole point. Derive `title` as a 3–6 word stage name; put the full sentence in `description`.
+> - ❌ WRONG: `"core_workflow": ["On app start, initialize subscription, then settings before fetching data."]`
+> - ✅ RIGHT: `"core_workflow": [{"title": "Cold start", "description": "On app start, initialize subscription and localisation, then settings, before fetching location data."}]`
+
+- **Do NOT emit an `entities` field.** Entity inventory + lifecycle lives in `data_models` (the Data agent owns it). The product_model is the narrative + workflow only. (Any `entities` you emit is stripped downstream — don't waste tokens on it.)
+
+## 2. `derived_invariants` — the laws no single file states (grounded by reasoning)
+
+Combine observed laws to surface emergent invariants that no one file declares but that MUST hold given the combination. These are the highest-value laws: an AI agent cannot infer them from reading code, because the code never states them.
+
+```json
+"derived_invariants": [
+ {
+ "id": "der-001",
+ "invariant": "A credit cannot be retroactively reduced below the amount already spent against it.",
+ "mechanism": "Balance is recomputed from the append-only ledger, so a reduction below spent value would produce a negative historical balance the ledger cannot represent.",
+ "derived_from": ["inv-balance-001", "inv-ingest-001"],
+ "domain_role": "core",
+ "failure_mode": "Forces a negative historical balance; recompute silently corrupts the ledger vs the balance snapshot.",
+ "confidence": "inferred"
+ }
+]
+```
+
+**Derivation discipline (your anti-hallucination guardrail):** every `derived_invariants[*]` MUST carry `derived_from` — the list of observed-law `id`s (from the input `domain_invariants`) it combines. **Cite the premises, not the conclusion.** A derived law that can't point back to ≥2 observed-law anchors is speculation — drop it. This is the reasoning-mode analogue of the Domain agent's cite-or-omit rule. `confidence` is `"inferred"` by default; only `"stated"` when the derivation is mechanically airtight.
+
+**State the derived `invariant` as a product rule** — what the product (or its consumer/client) guarantees, in the domain's own words — the same guidance the Domain agent follows. Put the code-level *how* in a `mechanism` field (same split as the Domain agent: `invariant` = product guarantee, `mechanism` = how the code delivers it); keep function and constant names out of `invariant`. A reviewer should grasp the law from the `invariant` sentence alone.
+
+**Set `domain_role`** on each derived law to `"core"`, `"supporting"`, or `"platform"` — inherit it from the anchors' roles (each observed law in `domain_invariants` carries `domain_role`). If the anchors mix roles, use the role of the law's primary subject; prefer `"core"` whenever a core anchor is load-bearing in the derivation. This keeps the rendered output leading with core laws. Lead with core derived laws; in default depth ration supporting/platform derived laws the same way the Domain agent does (comprehensive depth lifts all caps).
+
+## 3. `unenforced_invariants` — the gap list (UNGROUNDED — worth knowing, may be wrong)
+
+The inverse question: *what laws should this product enforce, but nothing in the code does?* An enforced invariant is already safe (the code stops you). The dangerous laws are the ones everyone assumes hold but nothing checks — those are the latent bugs. Run the same taxonomy (conservation, value-bound, lifecycle, idempotency, tenant-isolation, append-only, referential) against each core entity and, where the domain clearly implies a law but you found NO enforcing guard / FSM edge / test / DB constraint, emit a gap.
+
+```json
+"unenforced_invariants": [
+ {
+ "id": "gap-001",
+ "expected_law": "An issued charge record's total equals the sum of its line amounts.",
+ "entity": "ChargeRecord",
+ "category": "conservation",
+ "why_expected": "Standard double-entry conservation; the product issues charge records customers are billed against.",
+ "searched": ["app/billing/**/validate.ext", "app/billing/**/*_test.ext", "charge-record state machine"],
+ "found_enforcement": "none",
+ "risk": "A line-mutation path that skips total recompute drifts the charged amount from the line sum, undetected.",
+ "confidence": "inferred"
+ }
+]
+```
+
+**Proof-of-absence discipline:** this section makes TWO fallible claims — *"this law should exist"* (a domain judgment) and *"nothing enforces it"* (proof of absence). To stay honest:
+- `searched` is **REQUIRED and non-empty** — list the specific places you looked (validation code, FSM tables, test directories) so a human can falsify "nothing enforces it" in seconds. Cite the premises of your search, the same way derived laws cite their premises.
+- Only emit a gap when the expected law is clearly implied by the product's domain AND your search genuinely found no enforcement. When unsure whether something enforces it, do NOT emit the gap — a false "you don't validate X!" when the product does is the failure mode.
+- The whole section is `confidence: "inferred"` by construction. It is advisory: it will be rendered in a clearly-labeled "unverified" section and will NEVER become an enforcement rule.
+
+## Depth
+
+Default depth — derive the load-bearing laws and the highest-risk gaps only (soft caps: `derived_invariants` ~8, `unenforced_invariants` ~8). The comprehensive preamble lifts the caps. Never lower derivation/search rigor for coverage.
+
+## Critical
+
+- Everything you emit must be specific to THIS product and trace back to its observed laws or data models. Never generic programming advice.
+- If the input has an empty `domain_invariants` array, you have nothing to reason from — emit empty `derived_invariants` and `unenforced_invariants`, and a minimal `product_model` only if `data_models`/`components` clearly support one. Empty is a correct answer; do NOT pad.
+
+GROUNDING RULES apply (see below).
diff --git a/archie/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md b/archie/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
index 65ae51d7..bf74911c 100644
--- a/archie/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
+++ b/archie/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
@@ -29,6 +29,8 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
>
> Produce 30-60 architectural rules. Each rule captures an enforcement intent a coding agent must respect when planning or making changes. Coverage MUST span every blueprint section that carries enforcement signal — not just decisions and pitfalls. See "Coverage" below.
>
+> **Quality over quantity — lead with the product laws.** The `domain_invariant` rules (from `domain_invariants[*]` / `derived_invariants[*]`) are the highest-signal rules: they guard what the product *does*. Ensure every core entity that has an observed law gets a `domain_invariant` rule before you spend the budget padding cosmetic sections. Naming/file-placement rules are real but low-signal — a handful of representative ones beats one per file; do not inflate the count with near-duplicate housekeeping rules.
+>
> **In comprehensive depth (`DEPTH=comprehensive`):** no upper bound — produce every rule that meets the quality bar and carries genuine enforcement signal. Keep ALL required fields (`severity_class`/`why`/`example`); comprehensiveness must never lower per-rule quality.
>
> **Primary enforcement is AI-powered:** the AI reviewer reads each rule's `why` and `example` on every plan approval and pre-commit, and evaluates whether changes violate the rule's *intent*. The hook also surfaces these inline at edit time when the rule applies, so the agent sees the canonical reasoning + example without any extra lookup.
@@ -90,6 +92,7 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
> - `infrastructure` — derived from `blueprint.infrastructure_rules` or directly from CI/build/deploy/secrets files (`azure-pipelines.yml`, `.github/`, `Dockerfile`, `package.json`, `pyproject.toml`, entitlements, lock files). Onboarding/pipeline knowledge an engineer needs once, not when writing features. Pair with `severity_class: "pattern_divergence"`.
> - `coding_practice` — derived from `blueprint.development_rules`: general project-specific guidance the agent should remember at edit time. Catch-all of last resort — prefer one of the narrower kinds above when possible. Pair with `severity_class: "pattern_divergence"`.
> - `data_contract` — derived from `blueprint.data_models[*].invariants` or `blueprint.data_models[*].lifecycle`: a structural rule about a data model (FK / unique / NOT-NULL invariant, repository-only-read, idempotency requirement, migration procedure). Pair with `severity_class: "decision_violation"` when the invariant is FK/unique/NOT-NULL — those are schema-enforced contracts; pair with `pattern_divergence` for repository discipline and lifecycle reminders. The hook fires when editing files inside the model's `location` or its `lifecycle.related_business_logic`.
+> - `domain_invariant` — derived from `blueprint.domain_invariants[*]` (observed, cited product laws) and `blueprint.derived_invariants[*]` (reasoned laws). A **product correctness law** the database cannot enforce: a balance that must never go negative, an issued record that must never mutate, ingestion that must be idempotent, every query scoped to a tenant. This is the **highest-value kind** — it guards what the product *does*, not how its code is shaped, and an agent breaks it precisely because no single file states it. Use the id prefix `inv-` for observed laws and `der-` for derived laws. Follow the dedicated severity + dedup policy in "Product-law rules" below.
>
> `kind` and `severity_class` are not redundant: `kind` is *what the rule is about* (UI grouping), `severity_class` is *how the hook responds* (enforcement behavior).
>
@@ -311,9 +314,22 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
> | `data_models[*].invariants` (FK / unique / NOT-NULL / soft-delete / audit) | `data_contract` | `decision_violation` (schema-enforced contract) |
> | `data_models[*].lifecycle.how_to_*` (modify/read discipline) | `data_contract` | `pattern_divergence` |
> | `persistence_stores[*]` (e.g. "all reads via cache before primary", "queue-only writes") | `data_contract` | `pattern_divergence` |
+> | `domain_invariants[*]` (observed product law, cited) | `domain_invariant` | `decision_violation` (see Product-law policy) |
+> | `derived_invariants[*]` (reasoned product law) | `domain_invariant` | `tradeoff_undermined` (see Product-law policy) |
>
> Do NOT skip a section because "those aren't 'real' rules" — if it's in the blueprint, the agent should know about it, and the only way the agent learns about it is for you to emit it into proposed_rules.json so the user adopts it. Aim for ≥1 emitted rule per non-empty section.
>
+> **Do NOT emit rules from `product_model` (it is the domain map — orientation, not a constraint) or `unenforced_invariants` (the ungrounded gap list — advisory, too speculative to enforce). Those two sections are informational only; the renderer surfaces them and the hook never fires on them.**
+>
+> ### Product-law rules (`domain_invariant`) — severity + dedup policy
+>
+> Product laws are the highest-signal rules, but a wrong block costs more trust than ten good warns earn. Apply this policy exactly:
+>
+> - **Observed law** (`domain_invariants[*]`, `confidence: "stated"`, cites enforcing code) → `severity_class: "decision_violation"` (the hook **blocks**). These are grounded; blocking is justified.
+> - **Derived / inferred law** (`derived_invariants[*]`, or any `confidence: "inferred"`) → `severity_class: "tradeoff_undermined"` (the hook **warns**). The agent may have a reason; never hard-block on a reasoned-but-unenforced law. Promote a derived law to `decision_violation` ONLY when it carries ≥3 `derived_from` anchors AND its conclusion is mechanically tight.
+> - **Dedup — `domain_invariant` is the canonical carrier.** A single law (e.g. "balance ≥ 0") can surface as a `domain_invariants` entry, a promoted `key_decision`, and a `pitfall`. Emit it as exactly ONE `domain_invariant` rule, keyed on `entity + category`. Fold the motivating decision into `forced_by`/`enables` and the violation path into `why` — do NOT also emit a separate `decision`/`pitfall` rule for the same law.
+> - **Carry the grounding through.** For observed laws, put the `failure_mode` in `why`, and derive `triggers.path_glob` from `enforced_at` so the hook fires on the right files — but **`enforced_at` entries are `file:line` (or directory) citations, NOT globs**: strip the `:line` suffix and broaden to the enclosing directory so the glob matches edits anywhere the law is enforced. Example: `enforced_at: ["app/wallet/balance_repo.ext:214", "app/wallet/engine/"]` → `triggers.path_glob: ["app/wallet/**"]`. Never copy a `file:line` string into `path_glob` verbatim — it will match nothing. For derived laws, list the `derived_from` ids in `why` so the agent sees the premises.
+>
> **Deep architectural rules** — invariants an AI coding agent might accidentally violate. These are the most valuable. Derive them from decision chains, trade-offs, pitfalls, and pattern descriptions. Examples: "ViewModel must never reference View/Context", "Repository must use IO dispatcher", "Fragments must use DI delegation not direct construction".
>
> **Structural rules** — dependency direction between layers/components, forbidden technologies (from decisions/trade-offs).
diff --git a/archie/standalone/_common.py b/archie/standalone/_common.py
index d3dfc8c6..5c673c1b 100644
--- a/archie/standalone/_common.py
+++ b/archie/standalone/_common.py
@@ -450,7 +450,7 @@ def normalize_blueprint(bp: dict) -> dict:
# Sections that must be dicts
for key in ("meta", "architecture_rules", "decisions",
"communication", "quick_reference", "technology", "frontend",
- "deployment"):
+ "deployment", "product_model"):
val = bp.get(key)
if not isinstance(val, dict):
bp[key] = {} if val is None else {}
@@ -465,11 +465,20 @@ def normalize_blueprint(bp: dict) -> dict:
bp["components"]["components"] = []
# Sections that must be lists
- for key in ("pitfalls", "implementation_guidelines", "development_rules"):
+ for key in ("pitfalls", "implementation_guidelines", "development_rules",
+ "domain_invariants", "derived_invariants", "unenforced_invariants"):
val = bp.get(key)
if not isinstance(val, list):
bp[key] = []
+ # product_model.entities is deliberately dropped — the Data Models section
+ # is the canonical, detailed entity inventory; duplicating it under the
+ # product overview is noise. The Product agent is told not to emit it, but
+ # strip it deterministically so a non-compliant emission never renders.
+ pm = bp.get("product_model")
+ if isinstance(pm, dict):
+ pm.pop("entities", None)
+
bp.setdefault("architecture_diagram", "")
return bp
diff --git a/archie/standalone/finalize.py b/archie/standalone/finalize.py
index be4407bf..8d8e18a5 100644
--- a/archie/standalone/finalize.py
+++ b/archie/standalone/finalize.py
@@ -264,7 +264,13 @@ def _reset_reasoning_sections(bp: dict, payloads: list[dict]) -> None:
present = set()
for p in payloads:
present.update(p.keys())
- for key in ("decisions", "implementation_guidelines", "pitfalls", "architecture_diagram"):
+ # Product agent (Wave 2) owns product_model / derived_invariants /
+ # unenforced_invariants — same redo-safety as the Design/Risk sections so
+ # `--from 5` REPLACES them instead of concatenating (lists) or stale-merging
+ # (product_model.entities). domain_invariants is Wave 1 (lives in
+ # blueprint_raw) and is intentionally NOT reset here.
+ for key in ("decisions", "implementation_guidelines", "pitfalls", "architecture_diagram",
+ "product_model", "derived_invariants", "unenforced_invariants"):
if key in present:
bp.pop(key, None)
# Nested: Overview owns meta.executive_summary; keep the rest of meta (Wave-1
diff --git a/archie/standalone/merge.py b/archie/standalone/merge.py
index 5e8abb38..7cf426e1 100644
--- a/archie/standalone/merge.py
+++ b/archie/standalone/merge.py
@@ -238,6 +238,25 @@ def extract_json_from_text(text: str) -> dict | None:
seen[name] = c
deduped.append(c)
comps["components"] = deduped
+ # Deduplicate id-keyed list sections by `id` (keep the latest/patch
+ # version). deep_merge dedups dicts by `name`; sections whose entries
+ # carry `id` but no `name` (domain_invariants, derived_invariants,
+ # unenforced_invariants) would otherwise append a stale duplicate when an
+ # incremental scan re-emits a changed law. The patch entry wins.
+ for section in ("domain_invariants", "derived_invariants", "unenforced_invariants"):
+ items = merged.get(section)
+ if isinstance(items, list):
+ by_id: dict[str, dict] = {}
+ order: list = []
+ for it in items:
+ if isinstance(it, dict) and it.get("id"):
+ if it["id"] not in by_id:
+ order.append(it["id"])
+ by_id[it["id"]] = it # later (patch) wins
+ else:
+ order.append(id(it))
+ by_id[id(it)] = it
+ merged[section] = [by_id[k] for k in order]
bp_raw.write_text(json.dumps(merged, indent=2, ensure_ascii=False))
# Also update blueprint.json
bp = root / ".archie" / "blueprint.json"
diff --git a/archie/standalone/renderer.py b/archie/standalone/renderer.py
index 36cdd791..246441ef 100644
--- a/archie/standalone/renderer.py
+++ b/archie/standalone/renderer.py
@@ -1517,6 +1517,44 @@ def _generate_agent_body(bp: dict, *, h1: str) -> str:
lines.append(f"**CI/CD:** {', '.join(cleaned)}")
lines.append("")
+ # Product Laws — concise summary; the full set (grounded + unverified) lives
+ # in .claude/rules/product-laws.md, the domain map in product-model.md.
+ domain_inv = [x for x in (bp.get("domain_invariants") or []) if isinstance(x, dict)]
+ derived_inv = [x for x in (bp.get("derived_invariants") or []) if isinstance(x, dict)]
+ unenforced_inv = [x for x in (bp.get("unenforced_invariants") or []) if isinstance(x, dict)]
+ if domain_inv or derived_inv or unenforced_inv:
+ lines.append("## Product Laws")
+ lines.append("")
+ grounded = [inv for inv in (domain_inv + derived_inv)
+ if (inv.get("invariant") or "").strip()]
+ # Lead the (capped) summary with core laws so a densely-guarded support
+ # subsystem can't crowd the core out of the first 6 shown.
+ _role_rank = {"core": 0, "supporting": 1, "platform": 2}
+ grounded.sort(key=lambda i: _role_rank.get(
+ (i.get("domain_role") or "supporting").strip().lower(), 1))
+ if grounded:
+ lines.append(
+ "**Enforced (grounded)** — correctness laws; full list in "
+ "[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md):"
+ )
+ shown = _cap(grounded, 6)
+ for inv in shown:
+ lines.append(f"- {inv.get('invariant', '')}")
+ extra = len(grounded) - len(shown)
+ if extra > 0:
+ lines.append(
+ f"- _… {extra} more in "
+ f"[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md)_"
+ )
+ lines.append("")
+ if unenforced_inv:
+ lines.append(
+ f"**⚠️ Unverified** — {len(unenforced_inv)} implied-but-unenforced "
+ "law(s) worth knowing (may not be true); see "
+ "[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md)."
+ )
+ lines.append("")
+
# Data Models — concise summary; full lifecycle lives in
# .claude/rules/data-models.md (rendered by _build_data_models_rule).
# Section is elided entirely on blueprints with no data surface.
@@ -2090,6 +2128,190 @@ def build_enforcement_directory(rules: list[dict]) -> dict[str, str]:
# Main orchestrator
# ---------------------------------------------------------------------------
+def _render_grounded_invariant_lines(inv: dict) -> list[str]:
+ """One observed (Wave 1) product law as markdown bullet + sub-bullets."""
+ if not isinstance(inv, dict) or not (inv.get("invariant") or "").strip():
+ return []
+ lines = [f"- **{inv.get('invariant', '')}**"]
+ meta = " · ".join(p for p in (inv.get("entity"), inv.get("category")) if p)
+ if meta:
+ lines.append(f" - _{meta}_")
+ if (inv.get("mechanism") or "").strip():
+ lines.append(f" - *How it's enforced:* {inv['mechanism'].strip()}")
+ if inv.get("failure_mode"):
+ lines.append(f" - *If violated:* {inv['failure_mode']}")
+ enforced = inv.get("enforced_at") or []
+ if enforced:
+ lines.append(" - *Enforced at:* " + ", ".join(f"`{e}`" for e in enforced))
+ return lines
+
+
+def _render_derived_invariant_lines(d: dict) -> list[str]:
+ """One derived (Wave 2) product law — anchored to its premises."""
+ if not isinstance(d, dict) or not (d.get("invariant") or "").strip():
+ return []
+ lines = [f"- **{d.get('invariant', '')}** _(derived)_"]
+ df = d.get("derived_from") or []
+ if df:
+ lines.append(" - *Derived from:* " + ", ".join(f"`{x}`" for x in df))
+ if (d.get("mechanism") or "").strip():
+ lines.append(f" - *How it's enforced:* {d['mechanism'].strip()}")
+ if d.get("failure_mode"):
+ lines.append(f" - *If violated:* {d['failure_mode']}")
+ return lines
+
+
+def _render_unenforced_lines(g: dict) -> list[str]:
+ """One ungrounded gap entry — must surface its `searched` proof-of-work."""
+ if not isinstance(g, dict) or not (g.get("expected_law") or "").strip():
+ return []
+ lines = [f"- **{g.get('expected_law', '')}**"]
+ meta = " · ".join(p for p in (g.get("entity"), g.get("category")) if p)
+ if meta:
+ lines.append(f" - _{meta}_")
+ if g.get("why_expected"):
+ lines.append(f" - *Why expected:* {g['why_expected']}")
+ if g.get("risk"):
+ lines.append(f" - *Risk if real:* {g['risk']}")
+ searched = g.get("searched") or []
+ if searched:
+ lines.append(" - *Searched (no enforcement found):* "
+ + ", ".join(f"`{s}`" for s in searched))
+ return lines
+
+
+_DOMAIN_ROLE_ORDER = (
+ ("core", "Core product laws"),
+ ("supporting", "Supporting features (auth, subscription, settings)"),
+ ("platform", "Platform"),
+)
+
+
+def _render_grounded_by_role(domain: list, derived: list) -> list[str]:
+ """Render observed + derived laws grouped by domain_role (core first).
+
+ Falls back to a flat list when no law carries domain_role (blueprints that
+ predate the field, or fixtures), so old output renders unchanged."""
+ items = [("d", x) for x in domain] + [("r", x) for x in derived]
+
+ def _render_one(kind, item):
+ return (_render_grounded_invariant_lines(item) if kind == "d"
+ else _render_derived_invariant_lines(item))
+
+ has_role = any((it.get("domain_role") or "").strip() for _, it in items)
+ if not has_role:
+ out: list[str] = []
+ for kind, item in items:
+ out += _render_one(kind, item)
+ return out
+
+ by_role: dict[str, list] = {}
+ for kind, item in items:
+ role = (item.get("domain_role") or "supporting").strip().lower()
+ if role not in ("core", "supporting", "platform"):
+ role = "supporting"
+ by_role.setdefault(role, []).append((kind, item))
+
+ out = []
+ for role, heading in _DOMAIN_ROLE_ORDER:
+ bucket = by_role.get(role) or []
+ rendered: list[str] = []
+ for kind, item in bucket:
+ rendered += _render_one(kind, item)
+ if rendered:
+ out.append(f"### {heading}")
+ out.append("")
+ out += rendered
+ out.append("")
+ return out
+
+
+def _build_product_model_rule(bp: dict):
+ """`.claude/rules/product-model.md` — the product narrative + value workflow.
+
+ Entities are intentionally NOT rendered here — `data-models.md` inventories
+ them in full; duplicating is noise. Workflow steps accept both the new
+ `{title, description}` shape and the legacy plain-string shape."""
+ pm = bp.get("product_model")
+ if not isinstance(pm, dict):
+ return None
+ summary = (pm.get("summary") or "").strip()
+ workflow = [s for s in (pm.get("core_workflow") or []) if s]
+ if not (summary or workflow):
+ return None
+
+ lines = ["## Product Overview", ""]
+ if summary:
+ lines += [summary, ""]
+ if workflow:
+ lines += ["### Core workflow", ""]
+ for i, step in enumerate(workflow, 1):
+ if isinstance(step, dict):
+ title = (step.get("title") or "").strip()
+ desc = (step.get("description") or "").strip()
+ if title and desc:
+ lines.append(f"{i}. **{title}** — {desc}")
+ else:
+ lines.append(f"{i}. {title or desc}")
+ else:
+ lines.append(f"{i}. {str(step).strip()}")
+ lines.append("")
+
+ return {
+ "topic": "product-model",
+ "body": "\n".join(lines).rstrip(),
+ "description": "The product overview — what it does and the workflow that delivers its value",
+ "always_apply": True,
+ "globs": [],
+ }
+
+
+def _build_product_laws_rule(bp: dict):
+ """`.claude/rules/product-laws.md` — BOTH epistemic tiers in one file with an
+ unmissable boundary: enforced (grounded) laws vs implied-but-unenforced
+ (unverified). The grounded/ungrounded distinction is drawn identically here,
+ in the CLI viewer, and in the web report."""
+ domain = [x for x in (bp.get("domain_invariants") or []) if isinstance(x, dict)]
+ derived = [x for x in (bp.get("derived_invariants") or []) if isinstance(x, dict)]
+ unenforced = [x for x in (bp.get("unenforced_invariants") or []) if isinstance(x, dict)]
+ if not (domain or derived or unenforced):
+ return None
+
+ lines: list[str] = []
+ if domain or derived:
+ lines += [
+ "## Product Laws — enforced (grounded)",
+ "",
+ "_Correctness laws this product enforces in code. Violating one breaks the "
+ "product's behavior, not just its style. Grouped by role — the core laws are "
+ "what the product fundamentally does; supporting laws gate/monetize/secure it._",
+ "",
+ ]
+ lines += _render_grounded_by_role(domain, derived)
+ lines.append("")
+
+ if unenforced:
+ lines += [
+ "## ⚠️ Unverified — implied but NOT enforced by any code we found (may not be true)",
+ "",
+ "_These laws are inferred from the domain, not observed in code. They may be "
+ "false — but if real and unenforced, each is a latent bug. Each lists where we "
+ "looked (`searched`) so you can confirm in seconds. Not enforced by any hook._",
+ "",
+ ]
+ for g in unenforced:
+ lines += _render_unenforced_lines(g)
+ lines.append("")
+
+ return {
+ "topic": "product-laws",
+ "body": "\n".join(lines).rstrip(),
+ "description": "Product correctness laws — enforced (grounded) and implied-but-unenforced (unverified)",
+ "always_apply": True,
+ "globs": [],
+ }
+
+
def generate_all(bp: dict, enforcement_rules: list[dict] | None = None) -> dict:
"""Generate all output files from a blueprint dict.
@@ -2113,6 +2335,8 @@ def generate_all(bp: dict, enforcement_rules: list[dict] | None = None) -> dict:
_build_dev_rules_rule,
_build_infrastructure_rule,
_build_data_models_rule,
+ _build_product_model_rule,
+ _build_product_laws_rule,
]
for builder in builders:
diff --git a/archie/standalone/rule_kinds.py b/archie/standalone/rule_kinds.py
index d197ca0b..d717e7ea 100644
--- a/archie/standalone/rule_kinds.py
+++ b/archie/standalone/rule_kinds.py
@@ -20,6 +20,7 @@
"naming_convention",
"infrastructure",
"data_contract",
+ "domain_invariant",
"coding_practice",
)
@@ -33,6 +34,7 @@
"naming_convention": "Specifies a file or identifier naming pattern; typically expressible as a basename regex.",
"infrastructure": "Build, CI, deploy, secrets, dependency-registry, signing conventions; lives in `azure-pipelines.yml`, `.github/`, `Dockerfile`, `package.json`, `pyproject.toml`, etc.",
"data_contract": "Structural rule about a data model — FK/unique/NOT-NULL invariant, repository-only-read discipline, idempotency requirement, or migration procedure; derived from `data_models[*]` / `persistence_stores[*]`.",
+ "domain_invariant": "A product correctness law enforced by code, not the schema (e.g. balance never negative, issued-record immutability, idempotent ingestion). Grounded ones cite the enforcing code; derived ones combine ≥2 grounded laws. Violating one breaks the product's behavior, not just its style; derived from `domain_invariants[*]` / `derived_invariants[*]`.",
"coding_practice": "General project-specific guidance the agent should remember at edit time; catch-all when no narrower kind fits.",
}
@@ -58,6 +60,8 @@ def is_valid_kind(value: object) -> bool:
"impact": "decision",
"arch": "decision",
"dep": "decision",
+ "inv": "domain_invariant", # observed product law (domain-agent)
+ "der": "domain_invariant", # derived product law (product-agent)
}
# severity_class → kind fallback.
diff --git a/npm-package/assets/_common.py b/npm-package/assets/_common.py
index d3dfc8c6..5c673c1b 100644
--- a/npm-package/assets/_common.py
+++ b/npm-package/assets/_common.py
@@ -450,7 +450,7 @@ def normalize_blueprint(bp: dict) -> dict:
# Sections that must be dicts
for key in ("meta", "architecture_rules", "decisions",
"communication", "quick_reference", "technology", "frontend",
- "deployment"):
+ "deployment", "product_model"):
val = bp.get(key)
if not isinstance(val, dict):
bp[key] = {} if val is None else {}
@@ -465,11 +465,20 @@ def normalize_blueprint(bp: dict) -> dict:
bp["components"]["components"] = []
# Sections that must be lists
- for key in ("pitfalls", "implementation_guidelines", "development_rules"):
+ for key in ("pitfalls", "implementation_guidelines", "development_rules",
+ "domain_invariants", "derived_invariants", "unenforced_invariants"):
val = bp.get(key)
if not isinstance(val, list):
bp[key] = []
+ # product_model.entities is deliberately dropped — the Data Models section
+ # is the canonical, detailed entity inventory; duplicating it under the
+ # product overview is noise. The Product agent is told not to emit it, but
+ # strip it deterministically so a non-compliant emission never renders.
+ pm = bp.get("product_model")
+ if isinstance(pm, dict):
+ pm.pop("entities", None)
+
bp.setdefault("architecture_diagram", "")
return bp
diff --git a/npm-package/assets/finalize.py b/npm-package/assets/finalize.py
index be4407bf..8d8e18a5 100644
--- a/npm-package/assets/finalize.py
+++ b/npm-package/assets/finalize.py
@@ -264,7 +264,13 @@ def _reset_reasoning_sections(bp: dict, payloads: list[dict]) -> None:
present = set()
for p in payloads:
present.update(p.keys())
- for key in ("decisions", "implementation_guidelines", "pitfalls", "architecture_diagram"):
+ # Product agent (Wave 2) owns product_model / derived_invariants /
+ # unenforced_invariants — same redo-safety as the Design/Risk sections so
+ # `--from 5` REPLACES them instead of concatenating (lists) or stale-merging
+ # (product_model.entities). domain_invariants is Wave 1 (lives in
+ # blueprint_raw) and is intentionally NOT reset here.
+ for key in ("decisions", "implementation_guidelines", "pitfalls", "architecture_diagram",
+ "product_model", "derived_invariants", "unenforced_invariants"):
if key in present:
bp.pop(key, None)
# Nested: Overview owns meta.executive_summary; keep the rest of meta (Wave-1
diff --git a/npm-package/assets/merge.py b/npm-package/assets/merge.py
index 5e8abb38..7cf426e1 100644
--- a/npm-package/assets/merge.py
+++ b/npm-package/assets/merge.py
@@ -238,6 +238,25 @@ def extract_json_from_text(text: str) -> dict | None:
seen[name] = c
deduped.append(c)
comps["components"] = deduped
+ # Deduplicate id-keyed list sections by `id` (keep the latest/patch
+ # version). deep_merge dedups dicts by `name`; sections whose entries
+ # carry `id` but no `name` (domain_invariants, derived_invariants,
+ # unenforced_invariants) would otherwise append a stale duplicate when an
+ # incremental scan re-emits a changed law. The patch entry wins.
+ for section in ("domain_invariants", "derived_invariants", "unenforced_invariants"):
+ items = merged.get(section)
+ if isinstance(items, list):
+ by_id: dict[str, dict] = {}
+ order: list = []
+ for it in items:
+ if isinstance(it, dict) and it.get("id"):
+ if it["id"] not in by_id:
+ order.append(it["id"])
+ by_id[it["id"]] = it # later (patch) wins
+ else:
+ order.append(id(it))
+ by_id[id(it)] = it
+ merged[section] = [by_id[k] for k in order]
bp_raw.write_text(json.dumps(merged, indent=2, ensure_ascii=False))
# Also update blueprint.json
bp = root / ".archie" / "blueprint.json"
diff --git a/npm-package/assets/renderer.py b/npm-package/assets/renderer.py
index 36cdd791..246441ef 100644
--- a/npm-package/assets/renderer.py
+++ b/npm-package/assets/renderer.py
@@ -1517,6 +1517,44 @@ def _generate_agent_body(bp: dict, *, h1: str) -> str:
lines.append(f"**CI/CD:** {', '.join(cleaned)}")
lines.append("")
+ # Product Laws — concise summary; the full set (grounded + unverified) lives
+ # in .claude/rules/product-laws.md, the domain map in product-model.md.
+ domain_inv = [x for x in (bp.get("domain_invariants") or []) if isinstance(x, dict)]
+ derived_inv = [x for x in (bp.get("derived_invariants") or []) if isinstance(x, dict)]
+ unenforced_inv = [x for x in (bp.get("unenforced_invariants") or []) if isinstance(x, dict)]
+ if domain_inv or derived_inv or unenforced_inv:
+ lines.append("## Product Laws")
+ lines.append("")
+ grounded = [inv for inv in (domain_inv + derived_inv)
+ if (inv.get("invariant") or "").strip()]
+ # Lead the (capped) summary with core laws so a densely-guarded support
+ # subsystem can't crowd the core out of the first 6 shown.
+ _role_rank = {"core": 0, "supporting": 1, "platform": 2}
+ grounded.sort(key=lambda i: _role_rank.get(
+ (i.get("domain_role") or "supporting").strip().lower(), 1))
+ if grounded:
+ lines.append(
+ "**Enforced (grounded)** — correctness laws; full list in "
+ "[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md):"
+ )
+ shown = _cap(grounded, 6)
+ for inv in shown:
+ lines.append(f"- {inv.get('invariant', '')}")
+ extra = len(grounded) - len(shown)
+ if extra > 0:
+ lines.append(
+ f"- _… {extra} more in "
+ f"[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md)_"
+ )
+ lines.append("")
+ if unenforced_inv:
+ lines.append(
+ f"**⚠️ Unverified** — {len(unenforced_inv)} implied-but-unenforced "
+ "law(s) worth knowing (may not be true); see "
+ "[`.claude/rules/product-laws.md`](.claude/rules/product-laws.md)."
+ )
+ lines.append("")
+
# Data Models — concise summary; full lifecycle lives in
# .claude/rules/data-models.md (rendered by _build_data_models_rule).
# Section is elided entirely on blueprints with no data surface.
@@ -2090,6 +2128,190 @@ def build_enforcement_directory(rules: list[dict]) -> dict[str, str]:
# Main orchestrator
# ---------------------------------------------------------------------------
+def _render_grounded_invariant_lines(inv: dict) -> list[str]:
+ """One observed (Wave 1) product law as markdown bullet + sub-bullets."""
+ if not isinstance(inv, dict) or not (inv.get("invariant") or "").strip():
+ return []
+ lines = [f"- **{inv.get('invariant', '')}**"]
+ meta = " · ".join(p for p in (inv.get("entity"), inv.get("category")) if p)
+ if meta:
+ lines.append(f" - _{meta}_")
+ if (inv.get("mechanism") or "").strip():
+ lines.append(f" - *How it's enforced:* {inv['mechanism'].strip()}")
+ if inv.get("failure_mode"):
+ lines.append(f" - *If violated:* {inv['failure_mode']}")
+ enforced = inv.get("enforced_at") or []
+ if enforced:
+ lines.append(" - *Enforced at:* " + ", ".join(f"`{e}`" for e in enforced))
+ return lines
+
+
+def _render_derived_invariant_lines(d: dict) -> list[str]:
+ """One derived (Wave 2) product law — anchored to its premises."""
+ if not isinstance(d, dict) or not (d.get("invariant") or "").strip():
+ return []
+ lines = [f"- **{d.get('invariant', '')}** _(derived)_"]
+ df = d.get("derived_from") or []
+ if df:
+ lines.append(" - *Derived from:* " + ", ".join(f"`{x}`" for x in df))
+ if (d.get("mechanism") or "").strip():
+ lines.append(f" - *How it's enforced:* {d['mechanism'].strip()}")
+ if d.get("failure_mode"):
+ lines.append(f" - *If violated:* {d['failure_mode']}")
+ return lines
+
+
+def _render_unenforced_lines(g: dict) -> list[str]:
+ """One ungrounded gap entry — must surface its `searched` proof-of-work."""
+ if not isinstance(g, dict) or not (g.get("expected_law") or "").strip():
+ return []
+ lines = [f"- **{g.get('expected_law', '')}**"]
+ meta = " · ".join(p for p in (g.get("entity"), g.get("category")) if p)
+ if meta:
+ lines.append(f" - _{meta}_")
+ if g.get("why_expected"):
+ lines.append(f" - *Why expected:* {g['why_expected']}")
+ if g.get("risk"):
+ lines.append(f" - *Risk if real:* {g['risk']}")
+ searched = g.get("searched") or []
+ if searched:
+ lines.append(" - *Searched (no enforcement found):* "
+ + ", ".join(f"`{s}`" for s in searched))
+ return lines
+
+
+_DOMAIN_ROLE_ORDER = (
+ ("core", "Core product laws"),
+ ("supporting", "Supporting features (auth, subscription, settings)"),
+ ("platform", "Platform"),
+)
+
+
+def _render_grounded_by_role(domain: list, derived: list) -> list[str]:
+ """Render observed + derived laws grouped by domain_role (core first).
+
+ Falls back to a flat list when no law carries domain_role (blueprints that
+ predate the field, or fixtures), so old output renders unchanged."""
+ items = [("d", x) for x in domain] + [("r", x) for x in derived]
+
+ def _render_one(kind, item):
+ return (_render_grounded_invariant_lines(item) if kind == "d"
+ else _render_derived_invariant_lines(item))
+
+ has_role = any((it.get("domain_role") or "").strip() for _, it in items)
+ if not has_role:
+ out: list[str] = []
+ for kind, item in items:
+ out += _render_one(kind, item)
+ return out
+
+ by_role: dict[str, list] = {}
+ for kind, item in items:
+ role = (item.get("domain_role") or "supporting").strip().lower()
+ if role not in ("core", "supporting", "platform"):
+ role = "supporting"
+ by_role.setdefault(role, []).append((kind, item))
+
+ out = []
+ for role, heading in _DOMAIN_ROLE_ORDER:
+ bucket = by_role.get(role) or []
+ rendered: list[str] = []
+ for kind, item in bucket:
+ rendered += _render_one(kind, item)
+ if rendered:
+ out.append(f"### {heading}")
+ out.append("")
+ out += rendered
+ out.append("")
+ return out
+
+
+def _build_product_model_rule(bp: dict):
+ """`.claude/rules/product-model.md` — the product narrative + value workflow.
+
+ Entities are intentionally NOT rendered here — `data-models.md` inventories
+ them in full; duplicating is noise. Workflow steps accept both the new
+ `{title, description}` shape and the legacy plain-string shape."""
+ pm = bp.get("product_model")
+ if not isinstance(pm, dict):
+ return None
+ summary = (pm.get("summary") or "").strip()
+ workflow = [s for s in (pm.get("core_workflow") or []) if s]
+ if not (summary or workflow):
+ return None
+
+ lines = ["## Product Overview", ""]
+ if summary:
+ lines += [summary, ""]
+ if workflow:
+ lines += ["### Core workflow", ""]
+ for i, step in enumerate(workflow, 1):
+ if isinstance(step, dict):
+ title = (step.get("title") or "").strip()
+ desc = (step.get("description") or "").strip()
+ if title and desc:
+ lines.append(f"{i}. **{title}** — {desc}")
+ else:
+ lines.append(f"{i}. {title or desc}")
+ else:
+ lines.append(f"{i}. {str(step).strip()}")
+ lines.append("")
+
+ return {
+ "topic": "product-model",
+ "body": "\n".join(lines).rstrip(),
+ "description": "The product overview — what it does and the workflow that delivers its value",
+ "always_apply": True,
+ "globs": [],
+ }
+
+
+def _build_product_laws_rule(bp: dict):
+ """`.claude/rules/product-laws.md` — BOTH epistemic tiers in one file with an
+ unmissable boundary: enforced (grounded) laws vs implied-but-unenforced
+ (unverified). The grounded/ungrounded distinction is drawn identically here,
+ in the CLI viewer, and in the web report."""
+ domain = [x for x in (bp.get("domain_invariants") or []) if isinstance(x, dict)]
+ derived = [x for x in (bp.get("derived_invariants") or []) if isinstance(x, dict)]
+ unenforced = [x for x in (bp.get("unenforced_invariants") or []) if isinstance(x, dict)]
+ if not (domain or derived or unenforced):
+ return None
+
+ lines: list[str] = []
+ if domain or derived:
+ lines += [
+ "## Product Laws — enforced (grounded)",
+ "",
+ "_Correctness laws this product enforces in code. Violating one breaks the "
+ "product's behavior, not just its style. Grouped by role — the core laws are "
+ "what the product fundamentally does; supporting laws gate/monetize/secure it._",
+ "",
+ ]
+ lines += _render_grounded_by_role(domain, derived)
+ lines.append("")
+
+ if unenforced:
+ lines += [
+ "## ⚠️ Unverified — implied but NOT enforced by any code we found (may not be true)",
+ "",
+ "_These laws are inferred from the domain, not observed in code. They may be "
+ "false — but if real and unenforced, each is a latent bug. Each lists where we "
+ "looked (`searched`) so you can confirm in seconds. Not enforced by any hook._",
+ "",
+ ]
+ for g in unenforced:
+ lines += _render_unenforced_lines(g)
+ lines.append("")
+
+ return {
+ "topic": "product-laws",
+ "body": "\n".join(lines).rstrip(),
+ "description": "Product correctness laws — enforced (grounded) and implied-but-unenforced (unverified)",
+ "always_apply": True,
+ "globs": [],
+ }
+
+
def generate_all(bp: dict, enforcement_rules: list[dict] | None = None) -> dict:
"""Generate all output files from a blueprint dict.
@@ -2113,6 +2335,8 @@ def generate_all(bp: dict, enforcement_rules: list[dict] | None = None) -> dict:
_build_dev_rules_rule,
_build_infrastructure_rule,
_build_data_models_rule,
+ _build_product_model_rule,
+ _build_product_laws_rule,
]
for builder in builders:
diff --git a/npm-package/assets/rule_kinds.py b/npm-package/assets/rule_kinds.py
index d197ca0b..d717e7ea 100644
--- a/npm-package/assets/rule_kinds.py
+++ b/npm-package/assets/rule_kinds.py
@@ -20,6 +20,7 @@
"naming_convention",
"infrastructure",
"data_contract",
+ "domain_invariant",
"coding_practice",
)
@@ -33,6 +34,7 @@
"naming_convention": "Specifies a file or identifier naming pattern; typically expressible as a basename regex.",
"infrastructure": "Build, CI, deploy, secrets, dependency-registry, signing conventions; lives in `azure-pipelines.yml`, `.github/`, `Dockerfile`, `package.json`, `pyproject.toml`, etc.",
"data_contract": "Structural rule about a data model — FK/unique/NOT-NULL invariant, repository-only-read discipline, idempotency requirement, or migration procedure; derived from `data_models[*]` / `persistence_stores[*]`.",
+ "domain_invariant": "A product correctness law enforced by code, not the schema (e.g. balance never negative, issued-record immutability, idempotent ingestion). Grounded ones cite the enforcing code; derived ones combine ≥2 grounded laws. Violating one breaks the product's behavior, not just its style; derived from `domain_invariants[*]` / `derived_invariants[*]`.",
"coding_practice": "General project-specific guidance the agent should remember at edit time; catch-all when no narrower kind fits.",
}
@@ -58,6 +60,8 @@ def is_valid_kind(value: object) -> bool:
"impact": "decision",
"arch": "decision",
"dep": "decision",
+ "inv": "domain_invariant", # observed product law (domain-agent)
+ "der": "domain_invariant", # derived product law (product-agent)
}
# severity_class → kind fallback.
diff --git a/npm-package/assets/viewer/src/components/ReportSections.tsx b/npm-package/assets/viewer/src/components/ReportSections.tsx
index aded99ff..36b928f1 100644
--- a/npm-package/assets/viewer/src/components/ReportSections.tsx
+++ b/npm-package/assets/viewer/src/components/ReportSections.tsx
@@ -6,9 +6,9 @@ import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
import { Badge } from './ui/badge'
// @ts-ignore
import { Progress } from './ui/progress'
-import { lazy, Suspense, useContext, useEffect, useRef, useState } from 'react'
+import { lazy, Suspense, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
-import { ChevronRight, FileText, Database, Activity, Shield, Zap, Server, HelpCircle, AlertTriangle, Rocket, Info, Terminal, Layers, Search, BarChart3, ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
+import { ChevronRight, FileText, Database, Activity, Shield, Zap, Server, HelpCircle, AlertTriangle, Rocket, Info, Terminal, Layers, Search, BarChart3, ChevronDown, CheckCircle2, AlertCircle, BookOpen, GitMerge } from 'lucide-react'
// @ts-ignore
import ReactMarkdown from 'react-markdown'
// @ts-ignore
@@ -16,7 +16,7 @@ import remarkGfm from 'remark-gfm'
import type { Finding } from '@/lib/findings'
import { isSemanticDupFinding, normalizePitfall, severityColor } from '@/lib/findings'
-import { AutoCode, PathChip, Prose, codeInlineClassName } from '@/lib/autocode'
+import { AutoCode, PathChip, Prose, codeInlineClassName, codeInlineSubtleClassName } from '@/lib/autocode'
import { LocalEditContext } from '@/components/local/context/LocalEditContext'
import FixThisButton from '@/components/FixThisButton'
@@ -2588,3 +2588,527 @@ export function IntegrationsSection({
)
}
+
+// ---------------------------------------------------------------------------
+// Product Model — the domain map: summary, entities, core workflow.
+// Informational — no epistemic tier labeling needed.
+// ---------------------------------------------------------------------------
+
+/**
+ * Render inline markdown: **bold** and `code` spans only.
+ * No external dependency — splits on those two patterns, returns an array
+ * of ReactNodes suitable for embedding in a
or similar.
+ */
+function renderInlineMd(text: string): ReactNode[] {
+ // Split on **...** or `...` (non-greedy inside each delimiter pair).
+ const INLINE_MD_RE = /(\*\*(.+?)\*\*|`([^`]+)`)/g
+ const parts: React.ReactNode[] = []
+ let last = 0
+ let key = 0
+ for (const m of text.matchAll(INLINE_MD_RE)) {
+ if (m.index! > last) {
+ parts.push(text.slice(last, m.index!))
+ }
+ if (m[0].startsWith('**')) {
+ parts.push({m[2]})
+ } else {
+ // backtick code
+ parts.push({m[3]})
+ }
+ last = m.index! + m[0].length
+ }
+ if (last < text.length) parts.push(text.slice(last))
+ return parts
+}
+
+export function ProductModelSection({ productModel }: { productModel: any }) {
+ const summary: string = productModel?.summary || ''
+ const rawWorkflow: any[] = Array.isArray(productModel?.core_workflow) ? productModel.core_workflow : []
+
+ // Normalise each step: objects keep their title+description; plain strings
+ // become { title: '', description: string } so the render path is uniform.
+ const workflow = rawWorkflow.map((step) => {
+ if (step && typeof step === 'object') {
+ return { title: String(step.title || ''), description: String(step.description || '') }
+ }
+ return { title: '', description: String(step ?? '') }
+ })
+
+ return (
+
+
+
+ {summary && (() => {
+ const paragraphs = summary.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
+ return (
+
+ {paragraphs.map((para, idx) =>
+ idx === 0 ? (
+
+ {renderInlineMd(para)}
+
+ ) : (
+
+ {renderInlineMd(para)}
+
+ )
+ )}
+
+ )
+ })()}
+
+ {workflow.length > 0 && (
+
+
Core Workflow
+
+ {workflow.map((step, i) => (
+ -
+
+ {i + 1}
+
+
+ {step.title && (
+
+ )}
+ {step.description && (
+
+ {renderInlineMd(step.description)}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Confidence badge used in ProductLawsSection
+// ---------------------------------------------------------------------------
+
+function ConfidenceBadge({ confidence }: { confidence?: string }) {
+ if (!confidence) return null
+ const isStated = confidence === 'stated'
+ return (
+
+ {confidence}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Product Laws — GROUNDED tier: domain_invariants + derived_invariants.
+// These are authoritative. Derived ones show their premises (derived_from).
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Helpers for ProductLawsSection — card renderers extracted so the grouped
+// and flat code paths share identical markup.
+// ---------------------------------------------------------------------------
+
+const VALID_DOMAIN_ROLES = ['core', 'supporting', 'platform'] as const
+type DomainRole = typeof VALID_DOMAIN_ROLES[number]
+
+function normalizeDomainRole(raw: unknown): DomainRole {
+ if (raw === 'core' || raw === 'platform') return raw
+ // missing / unknown → supporting (fallback bucket)
+ return 'supporting'
+}
+
+function ObservedLawCard({ inv }: { inv: any }) {
+ const enforced: string[] = Array.isArray(inv.enforced_at) ? inv.enforced_at : []
+ const evidence: string[] = Array.isArray(inv.evidence) ? inv.evidence : []
+ const mechanism: string = typeof inv.mechanism === 'string' ? inv.mechanism.trim() : ''
+ return (
+
+
+
+
+ {inv.category || 'invariant'}
+
+ {inv.entity && (
+
+ {inv.entity}
+
+ )}
+
+
+
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+
+
+
+
+ {mechanism && (
+
+ )}
+
+ {inv.failure_mode && (
+
+ )}
+
+ {enforced.length > 0 && (
+
+
Enforced at
+
+ {enforced.map((loc, j) => (
+
+ ))}
+
+
+ )}
+
+ {evidence.length > 0 && (
+
+
Evidence
+
+ {evidence.map((e, j) => (
+ -
+
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function DerivedLawCard({ inv, domainById }: { inv: any; domainById: Record
}) {
+ const premises: string[] = Array.isArray(inv.derived_from) ? inv.derived_from : []
+ const mechanism: string = typeof inv.mechanism === 'string' ? inv.mechanism.trim() : ''
+ return (
+
+
+
+
+ derived
+
+
+
+
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+
+
+
+
+ {mechanism && (
+
+ )}
+
+ {inv.failure_mode && (
+
+ )}
+
+ {premises.length > 0 && (
+
+
Derived from
+
+ {premises.map((pid, j) => {
+ const text = domainById[pid]
+ return (
+
+
+ {pid}
+
+ {text && (
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
+
+// Role-group config — order matters (core → supporting → platform).
+const ROLE_GROUPS: Array<{ role: DomainRole; label: string }> = [
+ { role: 'core', label: 'Core product laws' },
+ { role: 'supporting', label: 'Supporting features (auth, subscription, settings)' },
+ { role: 'platform', label: 'Platform' },
+]
+
+export function ProductLawsSection({
+ domainInvariants,
+ derivedInvariants,
+}: {
+ domainInvariants: any[]
+ derivedInvariants: any[]
+}) {
+ // Build a lookup map id → invariant text for derived_from resolution
+ const domainById: Record = {}
+ for (const inv of domainInvariants) {
+ if (inv?.id) domainById[inv.id] = inv.invariant || inv.id
+ }
+
+ // Flat-fallback detection: if NO entry in either array has a recognised
+ // domain_role, render the original flat layout unchanged.
+ const allLaws = [...domainInvariants, ...derivedInvariants]
+ const useGrouped = allLaws.some(
+ (inv) => inv && VALID_DOMAIN_ROLES.includes(inv.domain_role as DomainRole),
+ )
+
+ const hasObserved = domainInvariants.length > 0
+ const hasDerived = derivedInvariants.length > 0
+
+ return (
+
+
+
+ {useGrouped ? (
+ // ── GROUPED LAYOUT ─────────────────────────────────────────────────
+ // Split observed + derived laws into role buckets, then render each
+ // non-empty bucket under its own subheading in canonical order.
+ ROLE_GROUPS.map(({ role, label }) => {
+ const observed = domainInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ const derived = derivedInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ if (observed.length === 0 && derived.length === 0) return null
+ return (
+
+ {/* Role-group subheading */}
+
+
{label}
+
+ Grounded
+
+
+
+ {observed.length > 0 && (
+
+
Observed
+
+ {observed.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {derived.length > 0 && (
+
+
Derived
+
+ {derived.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ )
+ })
+ ) : (
+ // ── FLAT LAYOUT (original) ──────────────────────────────────────────
+ // Rendered when no law carries a domain_role — preserves existing
+ // behaviour for blueprints scanned before the field was introduced.
+ <>
+ {hasObserved && (
+
+
+
Observed Laws
+
+ Grounded
+
+
+
+ {domainInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {hasDerived && (
+
+
+
Derived Laws
+
+ Inferred from premises
+
+
+
+ {derivedInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Unverified Invariants — UNGROUNDED gap list. Visually distinct from the
+// grounded tier: amber/warning surface + a prominent "Unverified" badge.
+// Shows searched[] so a reader can falsify "nothing enforces it."
+// ---------------------------------------------------------------------------
+
+export function UnverifiedInvariantsSection({ unenforcedInvariants }: { unenforcedInvariants: any[] }) {
+ return (
+
+ {/* Warning header — makes the epistemic tier unmissable */}
+
+
+ {/* One-line banner reinforcing the epistemic status */}
+
+
+
+ These are inferred and may not be true. Each item is a law the analysis expected to find enforced but could not locate. Verify with the listed search targets before acting.
+
+
+
+
+ {unenforcedInvariants.map((inv: any, i: number) => {
+ const searched: string[] = Array.isArray(inv.searched) ? inv.searched : []
+ return (
+
+ {/* Unverified badge + category/entity metadata */}
+
+
+ {inv.category && (
+
+ {inv.category}
+
+ )}
+ {inv.entity && (
+
+ {inv.entity}
+
+ )}
+ {inv.confidence && (
+
+ {inv.confidence}
+
+ )}
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+ {/* The expected law */}
+
+
+
+
+ {/* Why it was expected */}
+ {inv.why_expected && (
+
+ )}
+
+ {/* Risk of the gap */}
+ {inv.risk && (
+
+ )}
+
+ {/* Searched targets — key for falsifiability */}
+ {searched.length > 0 && (
+
+
+ Searched (nothing found)
+
+
+ {searched.map((loc, j) => (
+
+ ))}
+
+
+ )}
+
+ {inv.found_enforcement && inv.found_enforcement !== 'none' && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/npm-package/assets/viewer/src/lib/fixPrompt.ts b/npm-package/assets/viewer/src/lib/fixPrompt.ts
index e98a6e0a..f863d630 100644
--- a/npm-package/assets/viewer/src/lib/fixPrompt.ts
+++ b/npm-package/assets/viewer/src/lib/fixPrompt.ts
@@ -28,6 +28,10 @@ export type FixItem = Finding & {
* have a structured `pitfall_id`; pitfalls themselves use this field as a
* self-id marker so the same builder works for both. */
__kind?: 'finding' | 'pitfall'
+ /** For derived_invariant items: the ids of the domain_invariants this was
+ * derived from. When present, the builder inlines the premise texts so the
+ * receiving agent sees the full grounding chain. */
+ derived_from?: string[]
}
interface ResolvedPitfall {
@@ -109,6 +113,7 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
const pitfall = resolvePitfall(item, bp)
const decision = pitfall?.stems_from ? resolveDecision(pitfall.stems_from, bp) : null
+ const premises = resolveDerivedFromPremises(item, bp)
const guideline = resolveGuideline(item, bp)
const rules = resolveRules(item, opts.adoptedRules, bp)
const components = resolveComponents(item, bp)
@@ -243,6 +248,19 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
}
}
+ // ───── Derived-invariant premises ─────────────────────────────────────
+ if (premises.length > 0) {
+ const blocks = premises.map((p) => {
+ const lines: string[] = [`- \`${p.id}\``]
+ if (p.invariant) lines.push(` **Law:** ${p.invariant}`)
+ if (p.failure_mode) lines.push(` **Failure mode:** ${p.failure_mode}`)
+ return lines.join('\n')
+ })
+ sections.push(
+ `## Grounding premises (this law is derived from)\nThe derived invariant above holds only if all premises below hold.\n\n${blocks.join('\n\n')}`,
+ )
+ }
+
// ───── Decision chain (root → matching constraint) ─────────────────────
if (chainPath && chainPath.length > 1) {
const body = chainPath.map((step, i) => `${i + 1}. ${step}`).join('\n')
@@ -431,6 +449,39 @@ function formatAlternatives(alts: any): string[] {
return out.slice(0, 5)
}
+// ── Derived-invariant premise resolution ────────────────────────────────
+// When a FixItem carries `derived_from` ids, resolve each id to its
+// matching domain_invariant so the prompt includes the grounding premises.
+
+interface ResolvedPremise {
+ id: string
+ invariant?: string
+ failure_mode?: string
+}
+
+function resolveDerivedFromPremises(item: FixItem, bp: any): ResolvedPremise[] {
+ const ids = Array.isArray(item.derived_from) ? item.derived_from : []
+ if (ids.length === 0) return []
+ const domainInvariants: any[] = Array.isArray(bp?.domain_invariants) ? bp.domain_invariants : []
+ if (domainInvariants.length === 0) return []
+ const out: ResolvedPremise[] = []
+ for (const id of ids) {
+ const found = domainInvariants.find((d: any) => d?.id === id)
+ if (found) {
+ out.push({
+ id,
+ invariant: found.invariant,
+ failure_mode: found.failure_mode,
+ })
+ } else {
+ // id not found — include it as an unresolved reference so the agent
+ // knows it exists but can't be expanded.
+ out.push({ id })
+ }
+ }
+ return out
+}
+
// ── Guideline resolution ─────────────────────────────────────────────────
function collectGuidelines(bp: any): ResolvedGuideline[] {
diff --git a/npm-package/assets/viewer/src/pages/ReportPage.tsx b/npm-package/assets/viewer/src/pages/ReportPage.tsx
index 0b47ae75..22a3b764 100644
--- a/npm-package/assets/viewer/src/pages/ReportPage.tsx
+++ b/npm-package/assets/viewer/src/pages/ReportPage.tsx
@@ -5,7 +5,7 @@ import { LocalEditContext } from '@/components/local/context/LocalEditContext'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
-import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle } from 'lucide-react'
+import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle, BookOpen } from 'lucide-react'
import { fetchReport, type Bundle } from '@/lib/api'
import { autoBacktick } from '@/lib/autocode'
import { formatBlueprintTitle } from '@/lib/blueprintTitle'
@@ -192,6 +192,9 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
'integrations',
'technology',
'deployment',
+ 'product-model',
+ 'product-laws',
+ 'unverified-laws',
'problems',
'pitfalls',
'try-archie',
@@ -301,6 +304,11 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
const keyDecisions = bp.decisions?.key_decisions || []
const tradeOffs = bp.decisions?.trade_offs || []
const pitfalls = Array.isArray(bp.pitfalls) ? bp.pitfalls : []
+ const domainInvariants = Array.isArray(bp.domain_invariants) ? bp.domain_invariants : []
+ const derivedInvariants = Array.isArray(bp.derived_invariants) ? bp.derived_invariants : []
+ const unenforcedInvariants = Array.isArray(bp.unenforced_invariants) ? bp.unenforced_invariants : []
+ const productModel = bp.product_model && typeof bp.product_model === 'object' ? bp.product_model : null
+ const hasProductLaws = domainInvariants.length > 0 || derivedInvariants.length > 0
const archRules = bp.architecture_rules || {}
const filePlacement = archRules.file_placement_rules || []
const naming = archRules.naming_conventions || []
@@ -547,6 +555,37 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* Product */}
+ {(productModel || hasProductLaws || unenforcedInvariants.length > 0) && (
+
+
Product
+ {productModel && (
+
scrollToSection('product-model')}
+ icon={BookOpen}
+ label="Product Overview"
+ />
+ )}
+ {hasProductLaws && (
+ scrollToSection('product-laws')}
+ icon={Shield}
+ label="Product Laws"
+ />
+ )}
+ {unenforcedInvariants.length > 0 && (
+ scrollToSection('unverified-laws')}
+ icon={AlertTriangle}
+ label="Unverified Gaps"
+ />
+ )}
+
+ )}
+
{/* Practice */}
{(implementationGuidelines.length > 0 || communications.length > 0 || patternSelection.length > 0 || errorMapping.length > 0) && (
@@ -939,6 +978,27 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* 7b. Product Model — domain map */}
+ {productModel && (
+
+ )}
+
+ {/* 7c. Product Laws — grounded tier (domain_invariants + derived_invariants) */}
+ {hasProductLaws && (
+
+ )}
+
+ {/* 7d. Unverified Gaps — speculative unenforced_invariants, visually distinct */}
+ {unenforcedInvariants.length > 0 && (
+
+ )}
+
{/* 8. Implementation Guidelines */}
{implementationGuidelines.length > 0 && (
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
index 09711f0a..ec1cd892 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/data-agent.md
@@ -1,4 +1,4 @@
-### Data agent (only if `has_persistence_signal == true`)
+### Data agent (always runs)
> **CRITICAL INSTRUCTIONS:**
> You are analyzing a codebase to inventory its data models, persistence stores, and the lifecycle of each model (how to add a new one, how to modify, how to read, what the backup posture is, what tests exist, which business objects consume it).
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md
new file mode 100644
index 00000000..a5394fb6
--- /dev/null
+++ b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/domain-agent.md
@@ -0,0 +1,89 @@
+### Domain agent (always runs)
+
+> **CRITICAL INSTRUCTIONS:**
+> You are extracting this product's **behavioral invariants** — laws that must always hold for the product to be *correct*, and that are enforced by **code**, not by the database schema. You reason about the *domain*, the way the Structure agent reasons about code layout.
+>
+> **Schema-enforced contracts are NOT your job.** PK / FK / UNIQUE / NOT-NULL / soft-delete are the Data agent's `guarantees` — do NOT duplicate them. Your target is the law the database **cannot** enforce: a balance that must never go negative, an issued record that must never mutate, an event that must be counted at most once, a query that must always be tenant-scoped.
+>
+> **OBSERVE, do not invent. Cite or omit.** Every law MUST cite the `file:line` that enforces it. An invariant you cannot tie to enforcing code is a hypothesis, not a law — drop it. Hallucinated business laws are the failure mode; citations make them falsifiable. This is the same discipline the Data agent uses for field descriptions.
+>
+> **Self-discover the entities.** You run in parallel with the other Wave 1 agents and cannot read their output. Identify this product's core entities yourself from the scan's file_tree and skeletons — look in domain/model/entity directories, service packages, and schema declarations. Some overlap with the Data agent's inventory is expected and fine.
+>
+> **First, decide what this repository IS — it is not always an end-user app.** Archie runs on apps, libraries/SDKs, services/APIs, and CLIs/tools. Frame the "product" accordingly:
+> - **End-user app** → the value is a user journey; the core workflow is what a user opens it to do.
+> - **Library / SDK** → the value is the capability it exposes to *consumers*; the core workflow is the consumer's primary call path (construct → configure → call → handle result), and the core laws are the **behavioral contracts a caller can rely on** (ordering, idempotency, what a return/err guarantees, thread-safety promises).
+> - **Service / API** → the value is what it does for *clients*; the core workflow is the request lifecycle; the core laws are the guarantees the service makes per request.
+> - **CLI / tool** → the value is the command outcome; the core workflow is the invocation→effect path.
+>
+> **Anchor to the core FIRST — this is the most important instruction.** Decide the **primary value workflow** for whichever kind above this repo is — the path that delivers what the repo exists to provide. That is the **core**. Everything that *gates, monetizes, authenticates, or configures* access to the core — subscriptions/paywalls, auth/login, settings, telemetry — is **supporting**. Build/network/storage/serialization plumbing is **platform**. Cues: the repo's name + README, its public entrypoints/exported API, the most-depended-on domain types, the package that isn't `auth`/`billing`/`subscription`/`settings`/`infra`.
+>
+> Why this matters: **laws cluster where guard code is dense, and monetization/auth code is the most densely guarded — so a naive sweep over-produces paywall laws and starves the core.** You must counteract that bias deliberately: extract the **core first and hardest**, and ration the supporting/platform laws (see Depth). Tag every law with `domain_role` so the skew is visible and the output can lead with the core.
+>
+> **Write each law as a PRODUCT RULE — what the product always guarantees for its user or consumer, in the domain's own words.** A product owner should recognise the statement. Name product concepts (a location's kind, an account balance, an invoice, the recommendation shown) and describe the behaviour someone can rely on. **The code that backs the law has its own home: the `mechanism` field (the prose how) and `enforced_at` (the `file:line`).** Split every law into the two: the `invariant` is the product guarantee; the `mechanism` is how the code delivers it.
+> - ✅ `invariant`: *"A location is always treated as exactly one kind — the user's live GPS position, a saved city, or a timed-out fallback — and the screen the app opens to follows from that kind; it never mistakes one kind for another."*
+> `mechanism`: *"The kind is re-derived from the location id on every selection and never stored, mapping the GPS-position / timed-out / modified ids to their kinds."*
+>
+> Across every category, say *what the product guarantees* — about balances, totals, at-most-once effects, who can see what — and let the `mechanism` field carry the code. If you're about to name a private function or constant in `invariant`, that token belongs in `mechanism`; replace it with the **product concept** it stands for.
+>
+> **Where laws live — read these, in order:**
+> - `Validate()` methods, guard clauses, precondition checks (`if x < 0 { return err }`, `require(...)`, `assert ...`)
+> - finite-state-machine transition tables (`Permit(...)`, allowed-status maps, `canTransition`) — these define the legal lifecycle edges; an illegal edge is a law
+> - comments that state a law in prose (e.g. *"its value never turns negative"*, *"must be idempotent"*)
+> - **test assertions** — the invariants someone cared enough to pin (`assertEqual(total, sum)`, table-driven "should reject…" cases). These are the highest-signal source.
+>
+> **Bulk-content read exception (stated, not implicit):** the orchestration step bans reading bulk categories. You MAY surgically Read **test files** to harvest invariant assertions — bounded to the few highest-signal test files per core entity in default depth. In comprehensive depth (`DEPTH=comprehensive`) this bound is lifted along with the global bulk-read ban.
+>
+> **For the CORE, mine the data flow, not just the guards.** The core's correctness often lives in *computation and data contracts*, not explicit `if`-guards — so a guard-only sweep misses it. For the core value workflow, ask: *what must hold about the inputs and outputs for the result to be correct?* Examples of the shape: a derived value computed from the right source (an age derived from a birth date, not stored stale); a unit normalized before a lookup (a temperature converted to a canonical unit before the recommendation table); an output that always reflects the currently-selected inputs. These are real laws even when enforcement is a transformation in the data path rather than a thrown error — cite the transformation site. This is how the core earns its fair share of laws instead of losing to the densely-guarded paywall.
+>
+> **Run this taxonomy as a checklist against each core entity.** For each (entity × category), emit a law when one is enforced; move on when none is. Do NOT force a law into a category it doesn't fit.
+> - **conservation / accounting** — totals reconcile (a record total equals the sum of its line items; ledger debits equal credits)
+> - **value-bound** — a quantity stays within bounds (balance ≥ 0, amount ≥ 0, quantity > 0)
+> - **lifecycle / state** — terminal or immutable states; only FSM-permitted transitions are legal
+> - **idempotency** — the same key produces an at-most-once effect (ingestion, charge creation, webhooks)
+> - **tenant-isolation** — every query is scoped by the tenant key
+> - **append-only / monotonicity** — rows are never mutated or retroactively deleted
+> - **referential (app-enforced)** — a reference with no DB foreign key is validated in application code
+>
+> **Depth & distribution (default depth).** Emit the highest-signal laws — precision over coverage, soft cap ~12 total. Spend that budget core-first:
+> - **Core laws: no per-subsystem cap.** Extract every core law that clears the cite-or-omit bar — the core can never be over-represented.
+> - **Supporting subsystems: at most ~2–3 laws *each*** (subscription, auth, settings…). If a densely-guarded feature like a paywall yields more, keep only the most load-bearing 2–3 and drop the rest — do NOT let one monetization/auth subsystem consume the budget the core needs.
+> - **Platform: at most ~1–2 total** (networking/build/storage plumbing rarely carries product laws).
+> - If, after this, the core produced fewer laws than a single supporting subsystem, go back and mine the core's data flow harder (see above) before emitting — a core-starved result means you swept guards instead of reasoning about value.
+>
+> **COMPREHENSIVE DEPTH (`DEPTH=comprehensive`) — NO CAPS OF ANY KIND.** The total ~12 cap, the per-supporting-subsystem ~2–3 cap, and the platform ~1–2 cap are ALL lifted. Emit every law in every role that clears the cite-or-omit bar, with no upper bound and no padding. Still extract core-first and still tag `domain_role`, but suppress nothing.
+>
+> ## Output — a top-level `domain_invariants` array
+>
+> Each entry:
+> ```json
+> {
+> "id": "inv-balance-001",
+> "entity": "AccountBalance",
+> "category": "value-bound",
+> "domain_role": "core",
+> "invariant": "An account's spendable balance never goes negative — a customer can never spend value they haven't funded.",
+> "mechanism": "Every debit is checked against the remaining balance and rejected past zero before the write commits.",
+> "enforced_at": ["app/wallet/balance_repo.ext:214", "app/wallet/engine/"],
+> "evidence": ["code-comment:app/wallet/balance.ext:43", "guard:app/wallet/balance_repo.ext:214"],
+> "failure_mode": "A customer spends value they never funded; the ledger and the charged total diverge and can't be reconciled.",
+> "confidence": "stated",
+> "keywords": ["balance", "debit", "wallet"]
+> }
+> ```
+>
+> Field rules:
+> - **id** — `inv--NNN`. Stable across runs for the same law.
+> - **entity** — the core entity the law governs, in the codebase's own vocabulary.
+> - **category** — exactly one taxonomy slug from the checklist above.
+> - **domain_role** — REQUIRED. Exactly one of `"core"` (delivers the product's primary value), `"supporting"` (gates/monetizes/authenticates/configures the core — subscription, auth, settings), or `"platform"` (build/network/storage plumbing). This is what lets the output lead with core laws and ration supporting ones.
+> - **invariant** — one sentence stating what the product guarantees for its user or consumer, in the domain's own words (a product owner should recognise it). This sentence names **product concepts only** — no function/method names, no constants, no `items[selectedIndex]`-style code expressions. The code that enforces it goes in `mechanism`; the `file:line` goes in `enforced_at`. A reviewer must understand it without reading the source.
+> - **mechanism** — REQUIRED. One phrase describing *how* the code enforces the law: name the function, the constants it maps, the derivation (`getLocationType()` maps the id to a kind; `canAddChildren()` checks `< MAX_BABY_COUNT`). **This is the home for the code detail — it is what keeps `invariant` product-clean.** If you catch yourself putting a function or constant name in `invariant`, move it here.
+> - **enforced_at** — array of `file:line` (or directory) sites that enforce it. REQUIRED and non-empty — this is the citation that makes the law falsifiable.
+> - **evidence** — array of typed citations: `guard:`, `code-comment:`, `test:`, `fsm-edge:`. At least one.
+> - **failure_mode** — what breaks in production if the law is violated (the cost), in one sentence.
+> - **confidence** — `"stated"` when ≥2 corroborating sources or an explicit guard/comment; `"inferred"` when read from a single weak signal (one test, an implicit clamp).
+> - **keywords** — 2-5 terms an agent would use when describing a task that touches this law (drives the prompt-time hook match downstream).
+>
+> If the codebase has no stateful business laws (a pure stateless utility), emit `domain_invariants: []`. An empty array is a correct answer. Do NOT pad with generic programming advice — every entry must be specific to THIS product and cite real code.
+>
+> GROUNDING RULES apply (see below).
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
index 45ce5451..848e0426 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-3-wave1/orchestration.md
@@ -36,6 +36,7 @@ Agent prompt:
> - Changed communication patterns or integrations
> - New technology or dependencies
> - Modified file placement patterns
+> - New or changed product invariants (`domain_invariants[*]`) — behavioral laws the changed files add, alter, or remove (balance bounds, lifecycle immutability, idempotency, tenant scoping). Cite `enforced_at` for each, same cite-or-omit discipline as the full Domain agent. Omit when no changed file touches an enforced law.
>
> Return the same JSON structure as the full analysis but ONLY for sections affected by the changes. Omit unchanged sections — they'll be preserved from the existing blueprint.
>
@@ -47,20 +48,23 @@ Then skip to Step 4.
### If SCAN_MODE = "full" (default):
-Spawn 3–5 {{ANALYSIS_MODEL}} subagents in parallel, each focused on a different analytical concern. ALL agents read ALL source files under `$PROJECT_ROOT` — they are not split by directory. Each agent gets: the scan.json file_tree, dependencies, config files, and the GROUNDING RULES at the end of this step.
+Spawn 3–6 {{ANALYSIS_MODEL}} subagents in parallel, each focused on a different analytical concern. ALL agents read ALL source files under `$PROJECT_ROOT` — they are not split by directory. Each agent gets: the scan.json file_tree, dependencies, config files, and the GROUNDING RULES at the end of this step.
+
+**The five core agents — Structure, Patterns, Technology, Data, Domain — ALWAYS spawn.** They are NOT gated on any heuristic. Data inventories the data layer; Domain extracts the product's behavioral invariants; both feed the later steps (merge → blueprint → rules) on every run. A repo with no data surface simply yields an empty `data_models` / `domain_invariants` array — a valid result, never a reason to skip the agent. (`has_persistence_signal` is no longer a spawn gate; it survives only as an informational hint inside the Data/Domain prompts.)
**Conditional agents:**
-- **UI Layer** — spawn when `frontend_ratio >= 0.20`; otherwise skip.
-- **Data** — spawn when `has_persistence_signal == true` (set by scanner.py based on detected ORM deps, schema files, migrations dirs, mobile local-persistence APIs, or declared databases in the tech stack); otherwise skip.
+- **UI Layer** — the ONLY optional Wave 1 agent. Spawn when `frontend_ratio >= 0.20`; otherwise skip.
-The first 3 agents (Structure, Patterns, Technology) always spawn. The Data and UI Layer agents spawn independently — a pure-frontend SPA with no persistence gets UI Layer but not Data; a headless backend service with a DB gets Data but not UI Layer; a full-stack app gets all 5.
+A backend service gets the five core agents; a full-stack app gets all six.
-**When `DEPTH=comprehensive`, spawn ALL agents (Structure, Patterns, Technology, Data, UI Layer) regardless of `frontend_ratio` or persistence signal.**
+**When `DEPTH=comprehensive`, also spawn the UI Layer agent regardless of `frontend_ratio` — the five core agents already always run.**
**Bulk content — off-limits for reading.** `scan.json.bulk_content_manifest` lists files classified by `.archiebulk` as "visible inventory, not contents": categories like `ui_resource` (Android `res/`, iOS storyboards), `generated`, `localization`, `migration`, `fixture`, `asset`, `lockfile`, `dependency`, `data`. Every agent below inherits this rule: **you may reference these paths by name and inventory counts, but you MUST NOT call Read on them.** The scanner has already summarized their shape. If a specific file is genuinely required to resolve a finding, read it surgically and note why — it is an exception, not the default.
**Data agent exception (stated, not implicit):** the Data agent MAY surgically Read 1-2 recent migration files per persistence store to extract the observed `how_to_add` / `how_to_modify` procedure. This is the only blanket exception to the bulk-content rule and is bounded — enumeration of every migration in `migration` category is still forbidden.
+**Domain agent exception (stated, not implicit):** the Domain agent MAY surgically Read **test files** to harvest invariant assertions (the highest-signal source of product laws), bounded to the few most relevant test files per core entity in default depth. Enumeration of every test in the `fixture` category is still forbidden.
+
**When `DEPTH=comprehensive`, the bulk-content read ban is lifted: agents MAY Read any path that is not excluded by `.gitignore`/`.archieignore` (the ignore system remains the boundary). The 1-2-migration-files limit does not apply in comprehensive depth.**
**Dispatching the sub-agents:**
@@ -75,7 +79,8 @@ All paths are relative to the project root (your cwd).
| Patterns | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/patterns-agent.md` | `.archie/tmp/archie_sub2_$PROJECT_NAME.json` | Always |
| Technology | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/technology-agent.md` | `.archie/tmp/archie_sub3_$PROJECT_NAME.json` | Always |
| UI Layer | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/ui-layer-agent.md` | `.archie/tmp/archie_sub4_$PROJECT_NAME.json` | Only when `frontend_ratio >= 0.20` |
-| Data | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/data-agent.md` | `.archie/tmp/archie_sub5_$PROJECT_NAME.json` | Only when `has_persistence_signal == true` |
+| Data | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/data-agent.md` | `.archie/tmp/archie_sub5_$PROJECT_NAME.json` | Always |
+| Domain | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-3-wave1/domain-agent.md` | `.archie/tmp/archie_sub6_$PROJECT_NAME.json` | Always |
**Before dispatch — append the output contract to each sub-agent's prompt,
substituting its output path from the table above as the "file path named
@@ -102,20 +107,20 @@ not just the aggregate wave1 step). The sub-agent's FIRST action is
`python3 .archie/telemetry.py agent-start wave1 ` and its LAST action
(after writing its output file) is `python3 .archie/telemetry.py agent-finish wave1 `,
where `` is: Structure → `structure`, Patterns → `patterns`,
-Technology → `technology`, UI Layer → `ui`, Data → `data`.
+Technology → `technology`, UI Layer → `ui`, Data → `data`, Domain → `domain`.
The merge step (Step 4) reads each agent's output file directly — do NOT
copy or transcribe a subagent's output yourself.
-All spawned sub-agents (3 always + UI Layer and/or Data as applicable) run at the {{ANALYSIS_MODEL}} model. {{>dispatch_parallel}}
+All spawned sub-agents (5 core always + UI Layer when `frontend_ratio >= 0.20`) run at the {{ANALYSIS_MODEL}} model. {{>dispatch_parallel}}
-After the parallel dispatch returns, fold the per-agent timings into the `wave1` step so it reports each fact agent's duration (Structure / Patterns / Technology / UI Layer / Data) the same way `wave2_synthesis` does, instead of one aggregate number:
+After the parallel dispatch returns, fold the per-agent timings into the `wave1` step so it reports each fact agent's duration (Structure / Patterns / Technology / Data / Domain / UI Layer) the same way `wave2_synthesis` does, instead of one aggregate number:
```bash
python3 .archie/telemetry.py collect-agents "$PROJECT_ROOT" wave1
```
-Then record per-agent counts for trend tracking. Each call no-ops gracefully when its source file is missing (skipped agent — sub5 absent when `has_persistence_signal == false`). Uses the standard `python3 -c …` form that both Claude and Codex auto-approve via the installer's command catalogue — no new permission rules needed:
+Then record per-agent counts for trend tracking. Each call no-ops gracefully when its source file is missing (skipped agent — e.g. sub4 absent when `frontend_ratio < 0.20`; the five core agents always produce their file). Uses the standard `python3 -c …` form that both Claude and Codex auto-approve via the installer's command catalogue — no new permission rules needed:
```bash
DATA_COUNT=$(python3 -c "import json,os,sys; p=sys.argv[1]; print(len((json.load(open(p)).get('data_models') or [])) if os.path.exists(p) else 0)" .archie/tmp/archie_sub5_$PROJECT_NAME.json)
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-4-merge.md b/npm-package/assets/workflow/deep-scan/steps/step-4-merge.md
index 2e64983c..2581633d 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-4-merge.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-4-merge.md
@@ -27,13 +27,14 @@ Step 4 is a **consumer step**: Step 3 already assigned each Wave 1 sub-agent
its output path and appended the output contract to its prompt before
dispatch. Each sub-agent has now written its file under `.archie/tmp/`.
-**Expected files** (skip UI Layer when `frontend_ratio < 0.20`; skip Data when `has_persistence_signal == false`):
+**Expected files** (UI Layer is the only optional one — skip when `frontend_ratio < 0.20`; Structure, Patterns, Technology, Data, and Domain always run):
- `.archie/tmp/archie_sub1_$PROJECT_NAME.json` (Structure)
- `.archie/tmp/archie_sub2_$PROJECT_NAME.json` (Patterns)
- `.archie/tmp/archie_sub3_$PROJECT_NAME.json` (Technology)
- `.archie/tmp/archie_sub4_$PROJECT_NAME.json` (UI Layer, optional)
-- `.archie/tmp/archie_sub5_$PROJECT_NAME.json` (Data, optional)
+- `.archie/tmp/archie_sub5_$PROJECT_NAME.json` (Data)
+- `.archie/tmp/archie_sub6_$PROJECT_NAME.json` (Domain)
**If resuming via `--from` or `--continue`:** `.archie/tmp/` is workspace-relative
so the files normally survive reboots, but an interrupted or `--from 4` run
@@ -44,10 +45,10 @@ missing — do NOT attempt to re-extract output from a subagent's transcript.
Merge the files that exist:
```bash
-python3 .archie/merge.py "$PROJECT_ROOT" .archie/tmp/archie_sub1_$PROJECT_NAME.json .archie/tmp/archie_sub2_$PROJECT_NAME.json .archie/tmp/archie_sub3_$PROJECT_NAME.json .archie/tmp/archie_sub4_$PROJECT_NAME.json .archie/tmp/archie_sub5_$PROJECT_NAME.json
+python3 .archie/merge.py "$PROJECT_ROOT" .archie/tmp/archie_sub1_$PROJECT_NAME.json .archie/tmp/archie_sub2_$PROJECT_NAME.json .archie/tmp/archie_sub3_$PROJECT_NAME.json .archie/tmp/archie_sub4_$PROJECT_NAME.json .archie/tmp/archie_sub5_$PROJECT_NAME.json .archie/tmp/archie_sub6_$PROJECT_NAME.json
```
-`merge.py` warns and skips files that weren't produced (skipped agents) — listing all five paths unconditionally keeps the command stable across full / frontend-only / backend-only repos.
+`merge.py` warns and skips files that weren't produced (skipped agents) — listing all six paths unconditionally keeps the command stable across full / frontend-only / backend-only repos.
This saves `$PROJECT_ROOT/.archie/blueprint_raw.json` (raw merged data). Verify the output shows non-zero component/section counts. If it says "0 sections, 0 components", the merge failed — check the agent output files.
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md b/npm-package/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
index 570e41f0..280a76c5 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-5-wave2-reasoning.md
@@ -9,13 +9,14 @@ TELEMETRY_STEP5_START=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
**If START_STEP > 5, skip this step.**
-Wave 2 reasoning is split into **three sub-agents** that run **in parallel**, all at `{{REASONING_MODEL}}`:
+Wave 2 reasoning is split into **up to four sub-agents** that run **in parallel**, all at `{{REASONING_MODEL}}`:
- **Design** — decision chain, architectural style, key decisions, trade-offs, out-of-scope, implementation guidelines, communication-pattern enrichment.
- **Risk** — findings + pitfalls.
- **Overview** — architecture diagram + executive summary.
+- **Product** — `product_model` (domain map) + `derived_invariants` (reasoned product laws) + `unenforced_invariants` (the ungrounded gap list). It reasons over the Wave 1 Domain agent's `domain_invariants`. **Spawn on a FULL scan when `domain_invariants` is non-empty.** It is skipped in incremental mode: `derived_invariants`/`unenforced_invariants` are *global* reasoning over the whole law set, not per-change deltas, so they don't compose with a patch merge — the prior full scan's product sections are preserved unchanged by the patch and refresh on the next full scan (Wave 1 still updates the observed `domain_invariants` incrementally).
-Key ownership is disjoint, so the three outputs merge cleanly. The same three-agent dispatch runs in BOTH full and incremental modes — only the injected context preamble and the finalize flag differ (no special-case workflow).
+Key ownership is disjoint, so the outputs merge cleanly. The same dispatch runs in BOTH full and incremental modes — only the injected context preamble and the finalize flag differ (no special-case workflow).
### Findings store (accumulates across all runs)
@@ -42,13 +43,22 @@ For each sub-agent below, Read its prompt file, then ALSO Read `{{WORKFLOW_ROOT}
All paths are relative to the project root (your cwd).
-| Sub-agent | Prompt file | Output path |
-|---|---|---|
-| Design | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5a-design.md` | `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` |
-| Risk | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5b-risk.md` | `.archie/tmp/archie_sub_risk_$PROJECT_NAME.json` |
-| Overview | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5c-overview.md` | `.archie/tmp/archie_sub_overview_$PROJECT_NAME.json` |
+| Sub-agent | Prompt file | Output path | Spawn when |
+|---|---|---|---|
+| Design | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5a-design.md` | `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` | Always |
+| Risk | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5b-risk.md` | `.archie/tmp/archie_sub_risk_$PROJECT_NAME.json` | Always |
+| Overview | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5c-overview.md` | `.archie/tmp/archie_sub_overview_$PROJECT_NAME.json` | Always |
+| Product | `{{WORKFLOW_ROOT}}/deep-scan/steps/step-5d-product.md` | `.archie/tmp/archie_sub_product_$PROJECT_NAME.json` | Full scan + `domain_invariants` non-empty |
-**Mode preamble — prepend the SAME block to all three prompts:**
+**Evaluate the Product spawn gate first (full mode only).** In incremental mode, do NOT spawn Product — skip straight to Design / Risk / Overview. In full mode, count the laws the Wave 1 Domain agent produced (the merged `blueprint_raw.json` already carries `domain_invariants`) using the auto-approved `python3 -c …` form:
+
+```bash
+DOMAIN_LAW_COUNT=$(python3 -c "import json,os,sys; p=sys.argv[1]; print(len((json.load(open(p)).get('domain_invariants') or [])) if os.path.exists(p) else 0)" .archie/blueprint_raw.json)
+```
+
+Spawn the **Product** sub-agent only when `DOMAIN_LAW_COUNT` is greater than 0. Design, Risk, and Overview always spawn regardless of this count, in both modes.
+
+**Mode preamble — prepend the SAME block to all sub-agent prompts:**
- **Always prepend the depth contract (both scan modes).** When this run's `DEPTH=comprehensive`, prepend this line verbatim (it makes ALL counts in the body floors, so the Risk agent's "soft floor of 3" findings/pitfalls and every other count go unbounded):
> *COMPREHENSIVE MODE — be exhaustive. Every item-count in these instructions ("N-M", "up to N", "soft floor of N", "top N", "the most important") is a FLOOR, not a ceiling: emit every item that meets the quality bar, with no upper bound and no padding. Exception: keep the architecture diagram to 8-12 nodes.*
@@ -56,7 +66,7 @@ All paths are relative to the project root (your cwd).
When `DEPTH=default`, prepend nothing and apply the stated caps.
- **If SCAN_MODE = "full" (default):**
- > Produce your sections fresh from the full Wave-1 analysis in `$PROJECT_ROOT/.archie/blueprint_raw.json` (components, communication patterns, technology, deployment, frontend, data models and persistence stores when the Data agent spawned).
+ > Produce your sections fresh from the full Wave-1 analysis in `$PROJECT_ROOT/.archie/blueprint_raw.json` (components, communication patterns, technology, deployment, frontend, data models and persistence stores, and `domain_invariants` — the product's observed correctness laws). The Design and Risk agents should read `domain_invariants` per their own instructions (Design links each key decision to the law it preserves; Risk turns each law into a violation pitfall). The Product agent reasons over `domain_invariants` exclusively.
- **If SCAN_MODE = "incremental":**
> INCREMENTAL UPDATE. The architecture was previously analyzed — `$PROJECT_ROOT/.archie/blueprint.json` is the current full architecture and `$PROJECT_ROOT/.archie/blueprint_raw.json` carries the structural changes from Step 4. These files changed: [list `changed_files`]. Update ONLY the sections you own that are affected by these changes, and return ONLY what changed — unchanged sections are preserved by the patch merge. Use the 4-field contract (`problem_statement`, `evidence`, `root_cause`, `fix_direction`) when writing finding or pitfall entries.
@@ -69,18 +79,20 @@ OUTPUT CONTRACT (mandatory):
{{>output_contract}}
```
-All three sub-agents run at the `{{REASONING_MODEL}}` model. {{>dispatch_parallel}}
+All sub-agents run at the `{{REASONING_MODEL}}` model. {{>dispatch_parallel}}
The merge step below reads each agent's output file directly — do NOT copy or transcribe a subagent's output yourself.
-**Verify all three output files exist before merging.** Check that
+**Verify the expected output files exist before merging.** Check that
`archie_sub_design_$PROJECT_NAME.json`, `archie_sub_risk_$PROJECT_NAME.json`, and
-`archie_sub_overview_$PROJECT_NAME.json` are all on disk under `.archie/tmp/`. If any
-is missing, that sub-agent failed — **STOP, report which file is missing, and do NOT
+`archie_sub_overview_$PROJECT_NAME.json` are all on disk under `.archie/tmp/` — and
+`archie_sub_product_$PROJECT_NAME.json` too **when the Product agent was spawned** (full
+scan with non-empty `domain_invariants`; never in incremental mode). If any expected file is
+missing, that sub-agent failed — **STOP, report which file is missing, and do NOT
run the merge or `complete-step 5`.** Re-run Step 5 (`{{COMMAND_PREFIX}}archie-deep-scan
--from 5`) to respawn the agents. Proceeding with a partial merge would mark Step 5
complete with whole sections (e.g. the diagram or executive summary) silently missing,
-and resume would not re-run it. All three must be present, or none.
+and resume would not re-run it. All expected files must be present, or none.
**Fold in the per-agent timings** (each sub-agent self-timed its run). This records
how long each of Design/Risk/Overview ran, alongside the step's end-to-end duration:
@@ -89,20 +101,20 @@ how long each of Design/Risk/Overview ran, alongside the step's end-to-end durat
python3 .archie/telemetry.py collect-agents "$PROJECT_ROOT" wave2_synthesis
```
-### Merge (one finalize call, all three files)
+### Merge (one finalize call, all files)
-After all three output files are on disk, merge them in a SINGLE finalize call — this keeps Step 5 atomic (the blueprint is written once), so an interrupted run is recovered by simply re-running Step 5 from the top.
+After the output files are on disk, merge them in a SINGLE finalize call — this keeps Step 5 atomic (the blueprint is written once), so an interrupted run is recovered by simply re-running Step 5 from the top. The Product file is listed unconditionally; `finalize.py` warns and skips it when the Product agent wasn't spawned (`domain_invariants` empty), keeping the command stable.
- **If SCAN_MODE = "full":**
```bash
- python3 .archie/finalize.py "$PROJECT_ROOT" .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json
+ python3 .archie/finalize.py "$PROJECT_ROOT" .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json .archie/tmp/archie_sub_product_$PROJECT_NAME.json
```
- **If SCAN_MODE = "incremental":**
```bash
- python3 .archie/finalize.py "$PROJECT_ROOT" --patch .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json
+ python3 .archie/finalize.py "$PROJECT_ROOT" --patch .archie/tmp/archie_sub_design_$PROJECT_NAME.json .archie/tmp/archie_sub_risk_$PROJECT_NAME.json .archie/tmp/archie_sub_overview_$PROJECT_NAME.json .archie/tmp/archie_sub_product_$PROJECT_NAME.json
```
-This single command merges all three agents' output (routing `findings` to `findings.json` and deep-merging the rest), normalizes the schema, renders CLAUDE.md + AGENTS.md + rule files, installs hooks, and validates. Review the validation output — warnings are informational, not blocking. The validate step now includes a WARN-only **cross-link integrity** check (pitfall→decision, finding→pitfall, trade_off→decision, decision_chain→key_decisions); since Design and Risk run in parallel, a small number of these warnings is expected and not a failure.
+This single command merges all agents' output (routing `findings` to `findings.json` and deep-merging the rest), normalizes the schema, renders CLAUDE.md + AGENTS.md + rule files, installs hooks, and validates. Review the validation output — warnings are informational, not blocking. The validate step now includes a WARN-only **cross-link integrity** check (pitfall→decision, finding→pitfall, trade_off→decision, decision_chain→key_decisions); since Design and Risk run in parallel, a small number of these warnings is expected and not a failure.
**Backward-check the findings against actual code.** After finalize writes `.archie/findings.json`, run the {{VERIFY_MODEL}} verifier and apply hysteresis. The verifier reads each finding's required `triggering_call_site` field, walks one level out from the cited caller, and decides per finding: `keep` (failure fires there — real finding), `demote` (call site exists but failure doesn't fire — risk class, not current problem), or `drop` (premise unsound for this codebase). The hysteresis layer then applies the verdict with cross-run stability — single-scan flips on unchanged code don't propagate (kills LLM-noise flicker), but a git-diff anchor (a file in the finding's `triggering_call_site` was touched in the last 5 commits) lets a real transition land immediately.
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-5a-design.md b/npm-package/assets/workflow/deep-scan/steps/step-5a-design.md
index 6eeda427..836f9d99 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-5a-design.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-5a-design.md
@@ -2,6 +2,8 @@ You are the **Design reasoning agent** (Wave 2). You read the full Wave-1 analys
**Timing (required):** Your FIRST action, before reading anything, run `python3 .archie/telemetry.py agent-start wave2_synthesis design`. Your LAST action, after writing your output file per the OUTPUT CONTRACT, run `python3 .archie/telemetry.py agent-finish wave2_synthesis design`.
+**Link decisions to the product laws they preserve.** Wave 1's Domain agent wrote `domain_invariants` (the product's observed correctness laws — balance bounds, lifecycle immutability, idempotency, tenant scoping) into `blueprint_raw.json`. For each `key_decisions[*]` that exists to uphold such a law, set its `forced_by` to name that law and its `enables` to the capability the law preserves — this is what turns a flat decision into a deep, well-motivated one. When a single law cuts across the whole system (e.g. tenant isolation on every query), promote it to its own `key_decisions[*]` entry. Do NOT restate the laws verbatim as decisions or emit `domain_invariants`/`derived_invariants` yourself — the Product agent owns the law-derivation track; you only reference the laws.
+
With the COMPLETE picture of what was built and how, produce deep architectural reasoning. Every claim must be grounded in code you can see in the blueprint or source files. Do NOT invent theoretical constraints.
### 1. Decision Chain
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-5b-risk.md b/npm-package/assets/workflow/deep-scan/steps/step-5b-risk.md
index 4c6a46cf..a933ec21 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-5b-risk.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-5b-risk.md
@@ -4,6 +4,8 @@ You are the **Risk reasoning agent** (Wave 2). You produce the *risk* layer: `fi
You will upgrade any draft findings in the accumulated store, emit new findings you discover, AND emit pitfalls. Both findings and pitfalls share the same 4-field core (`problem_statement`, `evidence`, `root_cause`, `fix_direction`); pitfalls differ in altitude (class-of-problem, not instance) and ownership (blueprint-durable, not per-run).
+**Turn product laws into pitfalls.** `blueprint_raw.json` carries `domain_invariants` — the product's observed correctness laws (the Domain agent's output). For each law whose violation fails *silently or with delay* (a balance that can be driven negative, an issued record that can be mutated, ingestion that can double-count), emit a pitfall describing the trap: how a plausible edit slips past the law and what corrupts downstream as a result, with `root_cause` naming the law and its enforcing mechanism. These are the highest-value pitfalls — they guard product correctness, not just code structure. Do NOT emit `domain_invariants` or `derived_invariants` yourself (Domain and Product own those).
+
**Grounding your root causes in the decision layer.** The Design agent runs alongside you and writes `.archie/tmp/archie_sub_design_$PROJECT_NAME.json` (architectural_style, key_decisions, decision_chain). **If that file is present, read it** and name your `root_cause` strings in its vocabulary — a pitfall/finding whose root cause is architectural should reference an actual decision title or a `communication.patterns[].name` from `blueprint_raw.json`. If the file is not yet available, ground root causes in the patterns/components/constraints visible in `blueprint_raw.json` (and, in incremental mode, the decisions already in `blueprint.json`). Either way, every root cause must trace to something real in the blueprint — never invent a decision name.
**root_cause shape — three required parts (this is what keeps findings dense, not thin).** Every `root_cause` must combine:
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-5d-product.md b/npm-package/assets/workflow/deep-scan/steps/step-5d-product.md
new file mode 100644
index 00000000..dd15c586
--- /dev/null
+++ b/npm-package/assets/workflow/deep-scan/steps/step-5d-product.md
@@ -0,0 +1,88 @@
+You reason about this product's **domain** the way the Design agent reasons about its **code architecture**. Your input is the Domain agent's observed product laws — read `$PROJECT_ROOT/.archie/blueprint_raw.json` (full mode) or `$PROJECT_ROOT/.archie/blueprint.json` (incremental) and study its `domain_invariants` array (each is a cited, enforced law) plus `data_models` and `components` for entity context. Read it ONCE.
+
+You produce three things the citation-bound Wave 1 Domain agent structurally cannot. You own these keys and no others — emit them at the top level of your output JSON.
+
+## 1. `product_model` — what the product IS (informational)
+
+A clear picture of the product in the domain's own vocabulary: a rich prose description plus the end-to-end workflow that delivers its value. Do NOT enumerate entities here — the Data Models section already inventories every model in detail; duplicating them is noise.
+
+```json
+"product_model": {
+ "summary": "A DETAILED description — 3 to 6 sentences — of what this REPOSITORY is from a product/capability perspective. It is not always an end-user app: for a library/SDK describe the capability it gives consumers and the problem it solves for them; for a service/API what it does for clients; for an app what the user gets. Cover the core value loop, the key inputs it turns into that value, and the main supporting concerns (monetization, auth) in one clause. Domain/capability language. This is the headline a newcomer reads to understand what the repo stands for.",
+ "core_workflow": [
+ {
+ "title": "Short step name (3-6 words)",
+ "description": "One sentence on what happens in this step and why it matters to the product's value."
+ }
+ ]
+}
+```
+
+- **`summary`** — written as **2–4 SHORT paragraphs separated by blank lines (`\n\n`), not one dense block.** Suggested shape: (1) a one-sentence lead — what the repo is and the value it delivers; (2) the core value loop — the inputs it turns into that value; (3) how it's built/served + the main supporting concerns (monetization, auth) in a sentence or two. Bold the product name and key domain nouns with markdown (`**like this**`). Keep sentences tight — a reader should be able to skim it.
+- **`core_workflow`** — an ORDERED list tracing the value path end to end, framed for what the repo IS: an app's **user journey**, a library's **consumer call path** (construct → configure → call → handle result), or a service's **request lifecycle**. Each stage is product/usage behaviour, not an internal code step. Keep to the ~5-8 stages that actually move the value forward; default depth soft-caps at ~8 (comprehensive lifts it).
+
+> **⚠️ STRICT — every `core_workflow` item is an OBJECT `{title, description}`, NEVER a bare string.** A plain string renders as an untitled "Step N" and defeats the whole point. Derive `title` as a 3–6 word stage name; put the full sentence in `description`.
+> - ❌ WRONG: `"core_workflow": ["On app start, initialize subscription, then settings before fetching data."]`
+> - ✅ RIGHT: `"core_workflow": [{"title": "Cold start", "description": "On app start, initialize subscription and localisation, then settings, before fetching location data."}]`
+
+- **Do NOT emit an `entities` field.** Entity inventory + lifecycle lives in `data_models` (the Data agent owns it). The product_model is the narrative + workflow only. (Any `entities` you emit is stripped downstream — don't waste tokens on it.)
+
+## 2. `derived_invariants` — the laws no single file states (grounded by reasoning)
+
+Combine observed laws to surface emergent invariants that no one file declares but that MUST hold given the combination. These are the highest-value laws: an AI agent cannot infer them from reading code, because the code never states them.
+
+```json
+"derived_invariants": [
+ {
+ "id": "der-001",
+ "invariant": "A credit cannot be retroactively reduced below the amount already spent against it.",
+ "mechanism": "Balance is recomputed from the append-only ledger, so a reduction below spent value would produce a negative historical balance the ledger cannot represent.",
+ "derived_from": ["inv-balance-001", "inv-ingest-001"],
+ "domain_role": "core",
+ "failure_mode": "Forces a negative historical balance; recompute silently corrupts the ledger vs the balance snapshot.",
+ "confidence": "inferred"
+ }
+]
+```
+
+**Derivation discipline (your anti-hallucination guardrail):** every `derived_invariants[*]` MUST carry `derived_from` — the list of observed-law `id`s (from the input `domain_invariants`) it combines. **Cite the premises, not the conclusion.** A derived law that can't point back to ≥2 observed-law anchors is speculation — drop it. This is the reasoning-mode analogue of the Domain agent's cite-or-omit rule. `confidence` is `"inferred"` by default; only `"stated"` when the derivation is mechanically airtight.
+
+**State the derived `invariant` as a product rule** — what the product (or its consumer/client) guarantees, in the domain's own words — the same guidance the Domain agent follows. Put the code-level *how* in a `mechanism` field (same split as the Domain agent: `invariant` = product guarantee, `mechanism` = how the code delivers it); keep function and constant names out of `invariant`. A reviewer should grasp the law from the `invariant` sentence alone.
+
+**Set `domain_role`** on each derived law to `"core"`, `"supporting"`, or `"platform"` — inherit it from the anchors' roles (each observed law in `domain_invariants` carries `domain_role`). If the anchors mix roles, use the role of the law's primary subject; prefer `"core"` whenever a core anchor is load-bearing in the derivation. This keeps the rendered output leading with core laws. Lead with core derived laws; in default depth ration supporting/platform derived laws the same way the Domain agent does (comprehensive depth lifts all caps).
+
+## 3. `unenforced_invariants` — the gap list (UNGROUNDED — worth knowing, may be wrong)
+
+The inverse question: *what laws should this product enforce, but nothing in the code does?* An enforced invariant is already safe (the code stops you). The dangerous laws are the ones everyone assumes hold but nothing checks — those are the latent bugs. Run the same taxonomy (conservation, value-bound, lifecycle, idempotency, tenant-isolation, append-only, referential) against each core entity and, where the domain clearly implies a law but you found NO enforcing guard / FSM edge / test / DB constraint, emit a gap.
+
+```json
+"unenforced_invariants": [
+ {
+ "id": "gap-001",
+ "expected_law": "An issued charge record's total equals the sum of its line amounts.",
+ "entity": "ChargeRecord",
+ "category": "conservation",
+ "why_expected": "Standard double-entry conservation; the product issues charge records customers are billed against.",
+ "searched": ["app/billing/**/validate.ext", "app/billing/**/*_test.ext", "charge-record state machine"],
+ "found_enforcement": "none",
+ "risk": "A line-mutation path that skips total recompute drifts the charged amount from the line sum, undetected.",
+ "confidence": "inferred"
+ }
+]
+```
+
+**Proof-of-absence discipline:** this section makes TWO fallible claims — *"this law should exist"* (a domain judgment) and *"nothing enforces it"* (proof of absence). To stay honest:
+- `searched` is **REQUIRED and non-empty** — list the specific places you looked (validation code, FSM tables, test directories) so a human can falsify "nothing enforces it" in seconds. Cite the premises of your search, the same way derived laws cite their premises.
+- Only emit a gap when the expected law is clearly implied by the product's domain AND your search genuinely found no enforcement. When unsure whether something enforces it, do NOT emit the gap — a false "you don't validate X!" when the product does is the failure mode.
+- The whole section is `confidence: "inferred"` by construction. It is advisory: it will be rendered in a clearly-labeled "unverified" section and will NEVER become an enforcement rule.
+
+## Depth
+
+Default depth — derive the load-bearing laws and the highest-risk gaps only (soft caps: `derived_invariants` ~8, `unenforced_invariants` ~8). The comprehensive preamble lifts the caps. Never lower derivation/search rigor for coverage.
+
+## Critical
+
+- Everything you emit must be specific to THIS product and trace back to its observed laws or data models. Never generic programming advice.
+- If the input has an empty `domain_invariants` array, you have nothing to reason from — emit empty `derived_invariants` and `unenforced_invariants`, and a minimal `product_model` only if `data_models`/`components` clearly support one. Empty is a correct answer; do NOT pad.
+
+GROUNDING RULES apply (see below).
diff --git a/npm-package/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md b/npm-package/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
index 65ae51d7..bf74911c 100644
--- a/npm-package/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
+++ b/npm-package/assets/workflow/deep-scan/steps/step-6-rule-synthesis.md
@@ -29,6 +29,8 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
>
> Produce 30-60 architectural rules. Each rule captures an enforcement intent a coding agent must respect when planning or making changes. Coverage MUST span every blueprint section that carries enforcement signal — not just decisions and pitfalls. See "Coverage" below.
>
+> **Quality over quantity — lead with the product laws.** The `domain_invariant` rules (from `domain_invariants[*]` / `derived_invariants[*]`) are the highest-signal rules: they guard what the product *does*. Ensure every core entity that has an observed law gets a `domain_invariant` rule before you spend the budget padding cosmetic sections. Naming/file-placement rules are real but low-signal — a handful of representative ones beats one per file; do not inflate the count with near-duplicate housekeeping rules.
+>
> **In comprehensive depth (`DEPTH=comprehensive`):** no upper bound — produce every rule that meets the quality bar and carries genuine enforcement signal. Keep ALL required fields (`severity_class`/`why`/`example`); comprehensiveness must never lower per-rule quality.
>
> **Primary enforcement is AI-powered:** the AI reviewer reads each rule's `why` and `example` on every plan approval and pre-commit, and evaluates whether changes violate the rule's *intent*. The hook also surfaces these inline at edit time when the rule applies, so the agent sees the canonical reasoning + example without any extra lookup.
@@ -90,6 +92,7 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
> - `infrastructure` — derived from `blueprint.infrastructure_rules` or directly from CI/build/deploy/secrets files (`azure-pipelines.yml`, `.github/`, `Dockerfile`, `package.json`, `pyproject.toml`, entitlements, lock files). Onboarding/pipeline knowledge an engineer needs once, not when writing features. Pair with `severity_class: "pattern_divergence"`.
> - `coding_practice` — derived from `blueprint.development_rules`: general project-specific guidance the agent should remember at edit time. Catch-all of last resort — prefer one of the narrower kinds above when possible. Pair with `severity_class: "pattern_divergence"`.
> - `data_contract` — derived from `blueprint.data_models[*].invariants` or `blueprint.data_models[*].lifecycle`: a structural rule about a data model (FK / unique / NOT-NULL invariant, repository-only-read, idempotency requirement, migration procedure). Pair with `severity_class: "decision_violation"` when the invariant is FK/unique/NOT-NULL — those are schema-enforced contracts; pair with `pattern_divergence` for repository discipline and lifecycle reminders. The hook fires when editing files inside the model's `location` or its `lifecycle.related_business_logic`.
+> - `domain_invariant` — derived from `blueprint.domain_invariants[*]` (observed, cited product laws) and `blueprint.derived_invariants[*]` (reasoned laws). A **product correctness law** the database cannot enforce: a balance that must never go negative, an issued record that must never mutate, ingestion that must be idempotent, every query scoped to a tenant. This is the **highest-value kind** — it guards what the product *does*, not how its code is shaped, and an agent breaks it precisely because no single file states it. Use the id prefix `inv-` for observed laws and `der-` for derived laws. Follow the dedicated severity + dedup policy in "Product-law rules" below.
>
> `kind` and `severity_class` are not redundant: `kind` is *what the rule is about* (UI grouping), `severity_class` is *how the hook responds* (enforcement behavior).
>
@@ -311,9 +314,22 @@ Spawn a **{{ANALYSIS_MODEL}} subagent** with this prompt. {{>dispatch_single}}
> | `data_models[*].invariants` (FK / unique / NOT-NULL / soft-delete / audit) | `data_contract` | `decision_violation` (schema-enforced contract) |
> | `data_models[*].lifecycle.how_to_*` (modify/read discipline) | `data_contract` | `pattern_divergence` |
> | `persistence_stores[*]` (e.g. "all reads via cache before primary", "queue-only writes") | `data_contract` | `pattern_divergence` |
+> | `domain_invariants[*]` (observed product law, cited) | `domain_invariant` | `decision_violation` (see Product-law policy) |
+> | `derived_invariants[*]` (reasoned product law) | `domain_invariant` | `tradeoff_undermined` (see Product-law policy) |
>
> Do NOT skip a section because "those aren't 'real' rules" — if it's in the blueprint, the agent should know about it, and the only way the agent learns about it is for you to emit it into proposed_rules.json so the user adopts it. Aim for ≥1 emitted rule per non-empty section.
>
+> **Do NOT emit rules from `product_model` (it is the domain map — orientation, not a constraint) or `unenforced_invariants` (the ungrounded gap list — advisory, too speculative to enforce). Those two sections are informational only; the renderer surfaces them and the hook never fires on them.**
+>
+> ### Product-law rules (`domain_invariant`) — severity + dedup policy
+>
+> Product laws are the highest-signal rules, but a wrong block costs more trust than ten good warns earn. Apply this policy exactly:
+>
+> - **Observed law** (`domain_invariants[*]`, `confidence: "stated"`, cites enforcing code) → `severity_class: "decision_violation"` (the hook **blocks**). These are grounded; blocking is justified.
+> - **Derived / inferred law** (`derived_invariants[*]`, or any `confidence: "inferred"`) → `severity_class: "tradeoff_undermined"` (the hook **warns**). The agent may have a reason; never hard-block on a reasoned-but-unenforced law. Promote a derived law to `decision_violation` ONLY when it carries ≥3 `derived_from` anchors AND its conclusion is mechanically tight.
+> - **Dedup — `domain_invariant` is the canonical carrier.** A single law (e.g. "balance ≥ 0") can surface as a `domain_invariants` entry, a promoted `key_decision`, and a `pitfall`. Emit it as exactly ONE `domain_invariant` rule, keyed on `entity + category`. Fold the motivating decision into `forced_by`/`enables` and the violation path into `why` — do NOT also emit a separate `decision`/`pitfall` rule for the same law.
+> - **Carry the grounding through.** For observed laws, put the `failure_mode` in `why`, and derive `triggers.path_glob` from `enforced_at` so the hook fires on the right files — but **`enforced_at` entries are `file:line` (or directory) citations, NOT globs**: strip the `:line` suffix and broaden to the enclosing directory so the glob matches edits anywhere the law is enforced. Example: `enforced_at: ["app/wallet/balance_repo.ext:214", "app/wallet/engine/"]` → `triggers.path_glob: ["app/wallet/**"]`. Never copy a `file:line` string into `path_glob` verbatim — it will match nothing. For derived laws, list the `derived_from` ids in `why` so the agent sees the premises.
+>
> **Deep architectural rules** — invariants an AI coding agent might accidentally violate. These are the most valuable. Derive them from decision chains, trade-offs, pitfalls, and pattern descriptions. Examples: "ViewModel must never reference View/Context", "Repository must use IO dispatcher", "Fragments must use DI delegation not direct construction".
>
> **Structural rules** — dependency direction between layers/components, forbidden technologies (from decisions/trade-offs).
diff --git a/share/viewer/src/components/ReportSections.tsx b/share/viewer/src/components/ReportSections.tsx
index aded99ff..36b928f1 100644
--- a/share/viewer/src/components/ReportSections.tsx
+++ b/share/viewer/src/components/ReportSections.tsx
@@ -6,9 +6,9 @@ import { Card, CardHeader, CardTitle, CardContent } from './ui/card'
import { Badge } from './ui/badge'
// @ts-ignore
import { Progress } from './ui/progress'
-import { lazy, Suspense, useContext, useEffect, useRef, useState } from 'react'
+import { lazy, Suspense, useContext, useEffect, useRef, useState, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
-import { ChevronRight, FileText, Database, Activity, Shield, Zap, Server, HelpCircle, AlertTriangle, Rocket, Info, Terminal, Layers, Search, BarChart3, ChevronDown, CheckCircle2, AlertCircle } from 'lucide-react'
+import { ChevronRight, FileText, Database, Activity, Shield, Zap, Server, HelpCircle, AlertTriangle, Rocket, Info, Terminal, Layers, Search, BarChart3, ChevronDown, CheckCircle2, AlertCircle, BookOpen, GitMerge } from 'lucide-react'
// @ts-ignore
import ReactMarkdown from 'react-markdown'
// @ts-ignore
@@ -16,7 +16,7 @@ import remarkGfm from 'remark-gfm'
import type { Finding } from '@/lib/findings'
import { isSemanticDupFinding, normalizePitfall, severityColor } from '@/lib/findings'
-import { AutoCode, PathChip, Prose, codeInlineClassName } from '@/lib/autocode'
+import { AutoCode, PathChip, Prose, codeInlineClassName, codeInlineSubtleClassName } from '@/lib/autocode'
import { LocalEditContext } from '@/components/local/context/LocalEditContext'
import FixThisButton from '@/components/FixThisButton'
@@ -2588,3 +2588,527 @@ export function IntegrationsSection({
)
}
+
+// ---------------------------------------------------------------------------
+// Product Model — the domain map: summary, entities, core workflow.
+// Informational — no epistemic tier labeling needed.
+// ---------------------------------------------------------------------------
+
+/**
+ * Render inline markdown: **bold** and `code` spans only.
+ * No external dependency — splits on those two patterns, returns an array
+ * of ReactNodes suitable for embedding in a
or similar.
+ */
+function renderInlineMd(text: string): ReactNode[] {
+ // Split on **...** or `...` (non-greedy inside each delimiter pair).
+ const INLINE_MD_RE = /(\*\*(.+?)\*\*|`([^`]+)`)/g
+ const parts: React.ReactNode[] = []
+ let last = 0
+ let key = 0
+ for (const m of text.matchAll(INLINE_MD_RE)) {
+ if (m.index! > last) {
+ parts.push(text.slice(last, m.index!))
+ }
+ if (m[0].startsWith('**')) {
+ parts.push({m[2]})
+ } else {
+ // backtick code
+ parts.push({m[3]})
+ }
+ last = m.index! + m[0].length
+ }
+ if (last < text.length) parts.push(text.slice(last))
+ return parts
+}
+
+export function ProductModelSection({ productModel }: { productModel: any }) {
+ const summary: string = productModel?.summary || ''
+ const rawWorkflow: any[] = Array.isArray(productModel?.core_workflow) ? productModel.core_workflow : []
+
+ // Normalise each step: objects keep their title+description; plain strings
+ // become { title: '', description: string } so the render path is uniform.
+ const workflow = rawWorkflow.map((step) => {
+ if (step && typeof step === 'object') {
+ return { title: String(step.title || ''), description: String(step.description || '') }
+ }
+ return { title: '', description: String(step ?? '') }
+ })
+
+ return (
+
+
+
+ {summary && (() => {
+ const paragraphs = summary.split(/\n{2,}/).map((p) => p.trim()).filter(Boolean)
+ return (
+
+ {paragraphs.map((para, idx) =>
+ idx === 0 ? (
+
+ {renderInlineMd(para)}
+
+ ) : (
+
+ {renderInlineMd(para)}
+
+ )
+ )}
+
+ )
+ })()}
+
+ {workflow.length > 0 && (
+
+
Core Workflow
+
+ {workflow.map((step, i) => (
+ -
+
+ {i + 1}
+
+
+ {step.title && (
+
+ )}
+ {step.description && (
+
+ {renderInlineMd(step.description)}
+
+ )}
+
+
+ ))}
+
+
+ )}
+
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Confidence badge used in ProductLawsSection
+// ---------------------------------------------------------------------------
+
+function ConfidenceBadge({ confidence }: { confidence?: string }) {
+ if (!confidence) return null
+ const isStated = confidence === 'stated'
+ return (
+
+ {confidence}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Product Laws — GROUNDED tier: domain_invariants + derived_invariants.
+// These are authoritative. Derived ones show their premises (derived_from).
+// ---------------------------------------------------------------------------
+
+// ---------------------------------------------------------------------------
+// Helpers for ProductLawsSection — card renderers extracted so the grouped
+// and flat code paths share identical markup.
+// ---------------------------------------------------------------------------
+
+const VALID_DOMAIN_ROLES = ['core', 'supporting', 'platform'] as const
+type DomainRole = typeof VALID_DOMAIN_ROLES[number]
+
+function normalizeDomainRole(raw: unknown): DomainRole {
+ if (raw === 'core' || raw === 'platform') return raw
+ // missing / unknown → supporting (fallback bucket)
+ return 'supporting'
+}
+
+function ObservedLawCard({ inv }: { inv: any }) {
+ const enforced: string[] = Array.isArray(inv.enforced_at) ? inv.enforced_at : []
+ const evidence: string[] = Array.isArray(inv.evidence) ? inv.evidence : []
+ const mechanism: string = typeof inv.mechanism === 'string' ? inv.mechanism.trim() : ''
+ return (
+
+
+
+
+ {inv.category || 'invariant'}
+
+ {inv.entity && (
+
+ {inv.entity}
+
+ )}
+
+
+
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+
+
+
+
+ {mechanism && (
+
+ )}
+
+ {inv.failure_mode && (
+
+ )}
+
+ {enforced.length > 0 && (
+
+
Enforced at
+
+ {enforced.map((loc, j) => (
+
+ ))}
+
+
+ )}
+
+ {evidence.length > 0 && (
+
+
Evidence
+
+ {evidence.map((e, j) => (
+ -
+
+
+
+ ))}
+
+
+ )}
+
+ )
+}
+
+function DerivedLawCard({ inv, domainById }: { inv: any; domainById: Record
}) {
+ const premises: string[] = Array.isArray(inv.derived_from) ? inv.derived_from : []
+ const mechanism: string = typeof inv.mechanism === 'string' ? inv.mechanism.trim() : ''
+ return (
+
+
+
+
+ derived
+
+
+
+
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+
+
+
+
+ {mechanism && (
+
+ )}
+
+ {inv.failure_mode && (
+
+ )}
+
+ {premises.length > 0 && (
+
+
Derived from
+
+ {premises.map((pid, j) => {
+ const text = domainById[pid]
+ return (
+
+
+ {pid}
+
+ {text && (
+
+ )}
+
+ )
+ })}
+
+
+ )}
+
+ )
+}
+
+// Role-group config — order matters (core → supporting → platform).
+const ROLE_GROUPS: Array<{ role: DomainRole; label: string }> = [
+ { role: 'core', label: 'Core product laws' },
+ { role: 'supporting', label: 'Supporting features (auth, subscription, settings)' },
+ { role: 'platform', label: 'Platform' },
+]
+
+export function ProductLawsSection({
+ domainInvariants,
+ derivedInvariants,
+}: {
+ domainInvariants: any[]
+ derivedInvariants: any[]
+}) {
+ // Build a lookup map id → invariant text for derived_from resolution
+ const domainById: Record = {}
+ for (const inv of domainInvariants) {
+ if (inv?.id) domainById[inv.id] = inv.invariant || inv.id
+ }
+
+ // Flat-fallback detection: if NO entry in either array has a recognised
+ // domain_role, render the original flat layout unchanged.
+ const allLaws = [...domainInvariants, ...derivedInvariants]
+ const useGrouped = allLaws.some(
+ (inv) => inv && VALID_DOMAIN_ROLES.includes(inv.domain_role as DomainRole),
+ )
+
+ const hasObserved = domainInvariants.length > 0
+ const hasDerived = derivedInvariants.length > 0
+
+ return (
+
+
+
+ {useGrouped ? (
+ // ── GROUPED LAYOUT ─────────────────────────────────────────────────
+ // Split observed + derived laws into role buckets, then render each
+ // non-empty bucket under its own subheading in canonical order.
+ ROLE_GROUPS.map(({ role, label }) => {
+ const observed = domainInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ const derived = derivedInvariants.filter(
+ (inv) => normalizeDomainRole(inv?.domain_role) === role,
+ )
+ if (observed.length === 0 && derived.length === 0) return null
+ return (
+
+ {/* Role-group subheading */}
+
+
{label}
+
+ Grounded
+
+
+
+ {observed.length > 0 && (
+
+
Observed
+
+ {observed.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {derived.length > 0 && (
+
+
Derived
+
+ {derived.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ )
+ })
+ ) : (
+ // ── FLAT LAYOUT (original) ──────────────────────────────────────────
+ // Rendered when no law carries a domain_role — preserves existing
+ // behaviour for blueprints scanned before the field was introduced.
+ <>
+ {hasObserved && (
+
+
+
Observed Laws
+
+ Grounded
+
+
+
+ {domainInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+
+ {hasDerived && (
+
+
+
Derived Laws
+
+ Inferred from premises
+
+
+
+ {derivedInvariants.map((inv: any, i: number) => (
+
+ ))}
+
+
+ )}
+ >
+ )}
+
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Unverified Invariants — UNGROUNDED gap list. Visually distinct from the
+// grounded tier: amber/warning surface + a prominent "Unverified" badge.
+// Shows searched[] so a reader can falsify "nothing enforces it."
+// ---------------------------------------------------------------------------
+
+export function UnverifiedInvariantsSection({ unenforcedInvariants }: { unenforcedInvariants: any[] }) {
+ return (
+
+ {/* Warning header — makes the epistemic tier unmissable */}
+
+
+ {/* One-line banner reinforcing the epistemic status */}
+
+
+
+ These are inferred and may not be true. Each item is a law the analysis expected to find enforced but could not locate. Verify with the listed search targets before acting.
+
+
+
+
+ {unenforcedInvariants.map((inv: any, i: number) => {
+ const searched: string[] = Array.isArray(inv.searched) ? inv.searched : []
+ return (
+
+ {/* Unverified badge + category/entity metadata */}
+
+
+ {inv.category && (
+
+ {inv.category}
+
+ )}
+ {inv.entity && (
+
+ {inv.entity}
+
+ )}
+ {inv.confidence && (
+
+ {inv.confidence}
+
+ )}
+ {inv.id && (
+
{inv.id}
+ )}
+
+
+ {/* The expected law */}
+
+
+
+
+ {/* Why it was expected */}
+ {inv.why_expected && (
+
+ )}
+
+ {/* Risk of the gap */}
+ {inv.risk && (
+
+ )}
+
+ {/* Searched targets — key for falsifiability */}
+ {searched.length > 0 && (
+
+
+ Searched (nothing found)
+
+
+ {searched.map((loc, j) => (
+
+ ))}
+
+
+ )}
+
+ {inv.found_enforcement && inv.found_enforcement !== 'none' && (
+
+ )}
+
+ )
+ })}
+
+
+ )
+}
diff --git a/share/viewer/src/lib/fixPrompt.ts b/share/viewer/src/lib/fixPrompt.ts
index e98a6e0a..f863d630 100644
--- a/share/viewer/src/lib/fixPrompt.ts
+++ b/share/viewer/src/lib/fixPrompt.ts
@@ -28,6 +28,10 @@ export type FixItem = Finding & {
* have a structured `pitfall_id`; pitfalls themselves use this field as a
* self-id marker so the same builder works for both. */
__kind?: 'finding' | 'pitfall'
+ /** For derived_invariant items: the ids of the domain_invariants this was
+ * derived from. When present, the builder inlines the premise texts so the
+ * receiving agent sees the full grounding chain. */
+ derived_from?: string[]
}
interface ResolvedPitfall {
@@ -109,6 +113,7 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
const pitfall = resolvePitfall(item, bp)
const decision = pitfall?.stems_from ? resolveDecision(pitfall.stems_from, bp) : null
+ const premises = resolveDerivedFromPremises(item, bp)
const guideline = resolveGuideline(item, bp)
const rules = resolveRules(item, opts.adoptedRules, bp)
const components = resolveComponents(item, bp)
@@ -243,6 +248,19 @@ export function buildFixPrompt(item: FixItem, opts: BuildOpts = {}): string {
}
}
+ // ───── Derived-invariant premises ─────────────────────────────────────
+ if (premises.length > 0) {
+ const blocks = premises.map((p) => {
+ const lines: string[] = [`- \`${p.id}\``]
+ if (p.invariant) lines.push(` **Law:** ${p.invariant}`)
+ if (p.failure_mode) lines.push(` **Failure mode:** ${p.failure_mode}`)
+ return lines.join('\n')
+ })
+ sections.push(
+ `## Grounding premises (this law is derived from)\nThe derived invariant above holds only if all premises below hold.\n\n${blocks.join('\n\n')}`,
+ )
+ }
+
// ───── Decision chain (root → matching constraint) ─────────────────────
if (chainPath && chainPath.length > 1) {
const body = chainPath.map((step, i) => `${i + 1}. ${step}`).join('\n')
@@ -431,6 +449,39 @@ function formatAlternatives(alts: any): string[] {
return out.slice(0, 5)
}
+// ── Derived-invariant premise resolution ────────────────────────────────
+// When a FixItem carries `derived_from` ids, resolve each id to its
+// matching domain_invariant so the prompt includes the grounding premises.
+
+interface ResolvedPremise {
+ id: string
+ invariant?: string
+ failure_mode?: string
+}
+
+function resolveDerivedFromPremises(item: FixItem, bp: any): ResolvedPremise[] {
+ const ids = Array.isArray(item.derived_from) ? item.derived_from : []
+ if (ids.length === 0) return []
+ const domainInvariants: any[] = Array.isArray(bp?.domain_invariants) ? bp.domain_invariants : []
+ if (domainInvariants.length === 0) return []
+ const out: ResolvedPremise[] = []
+ for (const id of ids) {
+ const found = domainInvariants.find((d: any) => d?.id === id)
+ if (found) {
+ out.push({
+ id,
+ invariant: found.invariant,
+ failure_mode: found.failure_mode,
+ })
+ } else {
+ // id not found — include it as an unresolved reference so the agent
+ // knows it exists but can't be expanded.
+ out.push({ id })
+ }
+ }
+ return out
+}
+
// ── Guideline resolution ─────────────────────────────────────────────────
function collectGuidelines(bp: any): ResolvedGuideline[] {
diff --git a/share/viewer/src/pages/ReportPage.tsx b/share/viewer/src/pages/ReportPage.tsx
index 0b47ae75..22a3b764 100644
--- a/share/viewer/src/pages/ReportPage.tsx
+++ b/share/viewer/src/pages/ReportPage.tsx
@@ -5,7 +5,7 @@ import { LocalEditContext } from '@/components/local/context/LocalEditContext'
import ReactMarkdown from 'react-markdown'
import remarkGfm from 'remark-gfm'
import rehypeHighlight from 'rehype-highlight'
-import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle } from 'lucide-react'
+import { Copy, Check, ExternalLink, ChevronRight, Layout, Github, Menu, X, Info, Activity, Database, Shield, Zap, Rocket, AlertTriangle, Layers, FileText, AlertCircle, BookOpen } from 'lucide-react'
import { fetchReport, type Bundle } from '@/lib/api'
import { autoBacktick } from '@/lib/autocode'
import { formatBlueprintTitle } from '@/lib/blueprintTitle'
@@ -192,6 +192,9 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
'integrations',
'technology',
'deployment',
+ 'product-model',
+ 'product-laws',
+ 'unverified-laws',
'problems',
'pitfalls',
'try-archie',
@@ -301,6 +304,11 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
const keyDecisions = bp.decisions?.key_decisions || []
const tradeOffs = bp.decisions?.trade_offs || []
const pitfalls = Array.isArray(bp.pitfalls) ? bp.pitfalls : []
+ const domainInvariants = Array.isArray(bp.domain_invariants) ? bp.domain_invariants : []
+ const derivedInvariants = Array.isArray(bp.derived_invariants) ? bp.derived_invariants : []
+ const unenforcedInvariants = Array.isArray(bp.unenforced_invariants) ? bp.unenforced_invariants : []
+ const productModel = bp.product_model && typeof bp.product_model === 'object' ? bp.product_model : null
+ const hasProductLaws = domainInvariants.length > 0 || derivedInvariants.length > 0
const archRules = bp.architecture_rules || {}
const filePlacement = archRules.file_placement_rules || []
const naming = archRules.naming_conventions || []
@@ -547,6 +555,37 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* Product */}
+ {(productModel || hasProductLaws || unenforcedInvariants.length > 0) && (
+
+
Product
+ {productModel && (
+
scrollToSection('product-model')}
+ icon={BookOpen}
+ label="Product Overview"
+ />
+ )}
+ {hasProductLaws && (
+ scrollToSection('product-laws')}
+ icon={Shield}
+ label="Product Laws"
+ />
+ )}
+ {unenforcedInvariants.length > 0 && (
+ scrollToSection('unverified-laws')}
+ icon={AlertTriangle}
+ label="Unverified Gaps"
+ />
+ )}
+
+ )}
+
{/* Practice */}
{(implementationGuidelines.length > 0 || communications.length > 0 || patternSelection.length > 0 || errorMapping.length > 0) && (
@@ -939,6 +978,27 @@ export default function ReportPage({ bundle: bundleProp, createdAt: createdAtPro
)}
+ {/* 7b. Product Model — domain map */}
+ {productModel && (
+
+ )}
+
+ {/* 7c. Product Laws — grounded tier (domain_invariants + derived_invariants) */}
+ {hasProductLaws && (
+
+ )}
+
+ {/* 7d. Unverified Gaps — speculative unenforced_invariants, visually distinct */}
+ {unenforcedInvariants.length > 0 && (
+
+ )}
+
{/* 8. Implementation Guidelines */}
{implementationGuidelines.length > 0 && (
diff --git a/tests/fixtures/blueprint_domain_invariants.json b/tests/fixtures/blueprint_domain_invariants.json
new file mode 100644
index 00000000..763f01d4
--- /dev/null
+++ b/tests/fixtures/blueprint_domain_invariants.json
@@ -0,0 +1,95 @@
+{
+ "meta": {
+ "architecture_style": "Generic metered-billing service (illustrative fixture)",
+ "executive_summary": "Fixture blueprint exercising the domain-invariant + product-model sections."
+ },
+ "components": {
+ "components": [
+ {"name": "WalletService", "location": "app/wallet", "responsibility": "Account balances and debits"},
+ {"name": "BillingService", "location": "app/billing", "responsibility": "Charge records"}
+ ]
+ },
+ "architecture_rules": {"file_placement_rules": [], "naming_conventions": []},
+ "decisions": {
+ "architectural_style": {"title": "Layered service/adapter"},
+ "key_decisions": [
+ {"title": "Balances are recomputed from an append-only ledger",
+ "forced_by": "Balance must never go negative",
+ "enables": "Replayable balance at any timestamp"}
+ ],
+ "trade_offs": [],
+ "out_of_scope": [],
+ "decision_chain": {"root": "Provide metered billing with correct accounting.", "forces": []}
+ },
+ "data_models": [
+ {"name": "AccountBalance", "location": "app/wallet/balance.ext", "kind": "entity",
+ "store": "primary", "guarantees": ["PK id", "NOT NULL account_id"], "fields": [{"name": "id"}]}
+ ],
+ "domain_invariants": [
+ {
+ "id": "inv-balance-001",
+ "entity": "AccountBalance",
+ "category": "value-bound",
+ "domain_role": "core",
+ "invariant": "An account's spendable balance never goes negative — a customer can never spend value they haven't funded.",
+ "mechanism": "Every debit is checked against the remaining balance and rejected past zero before the write commits.",
+ "enforced_at": ["app/wallet/balance_repo.ext:214", "app/wallet/engine/"],
+ "evidence": ["code-comment:app/wallet/balance.ext:43", "guard:app/wallet/balance_repo.ext:214"],
+ "failure_mode": "A customer spends value they never funded; the ledger and the charged total diverge.",
+ "confidence": "stated",
+ "keywords": ["balance", "debit", "wallet"]
+ },
+ {
+ "id": "inv-ingest-001",
+ "entity": "UsageEvent",
+ "category": "idempotency",
+ "domain_role": "core",
+ "invariant": "Event ingestion is at-most-once per idempotency key; re-delivering the same event must not double-count usage.",
+ "enforced_at": ["app/ingest/dedupe.ext:88"],
+ "evidence": ["guard:app/ingest/dedupe.ext:88"],
+ "failure_mode": "Re-delivered events double-count usage and over-bill the customer.",
+ "confidence": "stated",
+ "keywords": ["ingest", "idempotency", "dedupe"]
+ }
+ ],
+ "product_model": {
+ "summary": "Illustrative: a metered-billing service where append-only events draw down account balances, a ledger gates spend, and issued charge records are immutable.",
+ "entities": [
+ {"name": "AccountBalance", "lifecycle": "created on account open; recomputed per debit", "role": "spend gate"},
+ {"name": "ChargeRecord", "lifecycle": "draft -> issued (immutable)", "role": "billing artifact"}
+ ],
+ "core_workflow": [
+ {"title": "Ingest event", "description": "An append-only usage event is recorded for the account."},
+ {"title": "Meter usage", "description": "The event is metered and deducted from the account balance."},
+ {"title": "Issue charge", "description": "An immutable charge record is materialized for billing."}
+ ]
+ },
+ "derived_invariants": [
+ {
+ "id": "der-001",
+ "invariant": "A credit cannot be retroactively reduced below the amount already spent against it.",
+ "derived_from": ["inv-balance-001", "inv-ingest-001"],
+ "domain_role": "core",
+ "failure_mode": "Forces a negative historical balance; recompute silently corrupts ledger vs balance.",
+ "confidence": "inferred",
+ "keywords": ["credit", "ledger", "balance"]
+ }
+ ],
+ "unenforced_invariants": [
+ {
+ "id": "gap-001",
+ "expected_law": "An issued charge record's total equals the sum of its line amounts.",
+ "entity": "ChargeRecord",
+ "category": "conservation",
+ "why_expected": "Standard double-entry conservation; the product issues charge records customers are billed against.",
+ "searched": ["app/billing/**/validate.ext", "app/billing/**/*_test.ext", "charge-record state machine"],
+ "found_enforcement": "none",
+ "risk": "A line-mutation path that skips total recompute drifts the charged amount from the line sum, undetected.",
+ "confidence": "inferred"
+ }
+ ],
+ "pitfalls": [],
+ "implementation_guidelines": [],
+ "development_rules": [],
+ "architecture_diagram": ""
+}
diff --git a/tests/test_domain_sections_pipeline.py b/tests/test_domain_sections_pipeline.py
new file mode 100644
index 00000000..efca121f
--- /dev/null
+++ b/tests/test_domain_sections_pipeline.py
@@ -0,0 +1,144 @@
+"""Phase 1: the merge/normalize/finalize pipeline carries the new
+domain-invariant + product-model sections without dropping or mangling them.
+
+These sections are produced by the Wave 1 Domain agent (`domain_invariants`)
+and the Wave 2 Product agent (`product_model`, `derived_invariants`,
+`unenforced_invariants`). The pipeline must:
+ - coerce them to the right container type (lists / dict) like every other section
+ - preserve populated content verbatim through normalize
+ - reset the Wave-2-owned ones on a full re-run so `--from 5` replaces, not appends
+ - never reset the Wave-1-owned `domain_invariants`
+"""
+from __future__ import annotations
+
+import json
+import subprocess
+import sys
+from pathlib import Path
+
+from archie.standalone._common import normalize_blueprint
+from archie.standalone.finalize import _reset_reasoning_sections
+
+MERGE_SCRIPT = Path(__file__).resolve().parent.parent / "archie" / "standalone" / "merge.py"
+
+FIXTURE = Path(__file__).resolve().parent / "fixtures" / "blueprint_domain_invariants.json"
+
+
+def _load_fixture() -> dict:
+ return json.loads(FIXTURE.read_text())
+
+
+def test_normalize_coerces_missing_sections():
+ """A blueprint with none of the new sections gets stable empty containers."""
+ bp: dict = {}
+ normalize_blueprint(bp)
+ assert bp["product_model"] == {}
+ assert bp["domain_invariants"] == []
+ assert bp["derived_invariants"] == []
+ assert bp["unenforced_invariants"] == []
+
+
+def test_normalize_coerces_wrong_types():
+ bp = {
+ "product_model": None,
+ "domain_invariants": None,
+ "derived_invariants": "oops",
+ "unenforced_invariants": {},
+ }
+ normalize_blueprint(bp)
+ assert bp["product_model"] == {}
+ assert bp["domain_invariants"] == []
+ assert bp["derived_invariants"] == []
+ assert bp["unenforced_invariants"] == []
+
+
+def test_normalize_strips_product_model_entities():
+ """product_model.entities is dropped deterministically — Data Models is the
+ canonical entity inventory; the agent is told not to emit entities, but a
+ non-compliant emission must never survive into the rendered output."""
+ bp = {"product_model": {
+ "summary": "A product.",
+ "entities": [{"name": "Foo"}, {"name": "Bar"}],
+ "core_workflow": [{"title": "Step", "description": "does a thing"}],
+ }}
+ normalize_blueprint(bp)
+ assert "entities" not in bp["product_model"]
+ assert bp["product_model"]["summary"] == "A product." # rest preserved
+ assert bp["product_model"]["core_workflow"] # workflow preserved
+
+
+def test_normalize_preserves_populated_sections():
+ """Populated sections survive normalize untouched (no silent drop)."""
+ bp = _load_fixture()
+ normalize_blueprint(bp)
+ assert len(bp["domain_invariants"]) == 2
+ assert bp["domain_invariants"][0]["id"] == "inv-balance-001"
+ assert bp["domain_invariants"][0]["confidence"] == "stated"
+ assert bp["product_model"]["summary"].startswith("Illustrative")
+ assert "entities" not in bp["product_model"] # stripped by normalize
+ assert len(bp["product_model"]["core_workflow"]) == 3 # workflow preserved
+ assert bp["derived_invariants"][0]["derived_from"] == ["inv-balance-001", "inv-ingest-001"]
+ assert bp["unenforced_invariants"][0]["searched"] # proof-of-absence retained
+ assert bp["unenforced_invariants"][0]["found_enforcement"] == "none"
+
+
+def test_reset_clears_wave2_product_sections():
+ """Full-mode redo-safety: Product-agent sections are cleared when the incoming
+ payload provides them, so the subsequent deep_merge replaces rather than appends."""
+ bp = _load_fixture()
+ product_payload = {
+ "product_model": {"summary": "new"},
+ "derived_invariants": [{"id": "der-002"}],
+ "unenforced_invariants": [{"id": "gap-002"}],
+ }
+ _reset_reasoning_sections(bp, [product_payload])
+ assert "product_model" not in bp
+ assert "derived_invariants" not in bp
+ assert "unenforced_invariants" not in bp
+
+
+def test_reset_never_clears_wave1_domain_invariants():
+ """`domain_invariants` is Wave 1 (lives in blueprint_raw) — a Wave 2 reset
+ must leave it intact even if a payload somehow references it."""
+ bp = _load_fixture()
+ _reset_reasoning_sections(bp, [{"product_model": {"summary": "x"}}])
+ assert len(bp["domain_invariants"]) == 2 # untouched
+
+
+def test_incremental_patch_upserts_domain_law_by_id(tmp_path):
+ """Regression (Reviewer-1 blocker): an incremental --patch that re-emits an
+ existing domain law with changed text must REPLACE it (upsert by id), not
+ append a stale duplicate. deep_merge dedups by `name`; these are id-keyed."""
+ archie = tmp_path / ".archie"
+ archie.mkdir()
+ (archie / "blueprint_raw.json").write_text(json.dumps({
+ "components": {"components": []},
+ "domain_invariants": [
+ {"id": "inv-balance-001", "invariant": "old text"},
+ {"id": "inv-ingest-001", "invariant": "unchanged"},
+ ],
+ }))
+ patch = tmp_path / "patch.json"
+ patch.write_text(json.dumps({
+ "domain_invariants": [{"id": "inv-balance-001", "invariant": "UPDATED text"}],
+ }))
+ res = subprocess.run(
+ [sys.executable, str(MERGE_SCRIPT), str(tmp_path), "--patch", str(patch)],
+ capture_output=True, text=True,
+ )
+ assert res.returncode == 0, res.stderr
+ merged = json.loads((archie / "blueprint_raw.json").read_text())
+ laws = {d["id"]: d["invariant"] for d in merged["domain_invariants"]}
+ assert len(merged["domain_invariants"]) == 2 # no duplicate id
+ assert laws["inv-balance-001"] == "UPDATED text" # patch wins
+ assert laws["inv-ingest-001"] == "unchanged" # untouched law preserved
+
+
+def test_reset_is_noop_when_payload_absent():
+ """A payload that carries none of the product sections clears nothing."""
+ bp = _load_fixture()
+ before = json.dumps(bp, sort_keys=True)
+ _reset_reasoning_sections(bp, [{"meta": {"executive_summary": "s"}}])
+ # product sections still present (meta.executive_summary reset is unrelated)
+ assert "product_model" in bp
+ assert "derived_invariants" in bp
diff --git a/tests/test_finalize_product_sections_integration.py b/tests/test_finalize_product_sections_integration.py
new file mode 100644
index 00000000..d2db28f4
--- /dev/null
+++ b/tests/test_finalize_product_sections_integration.py
@@ -0,0 +1,79 @@
+"""Phase 3/5 integration: a Wave 1 blueprint carrying `domain_invariants` plus a
+Wave 2 Product agent sub-file flow through finalize() into blueprint.json and the
+rendered product-laws.md — the full merge -> normalize -> render round-trip.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from archie.standalone.finalize import finalize
+
+
+def _setup_project(tmp_path: Path) -> Path:
+ archie = tmp_path / ".archie"
+ archie.mkdir()
+ # Wave-1 raw blueprint: Domain agent already wrote domain_invariants here.
+ raw = {
+ "meta": {"architecture_style": "fixture"},
+ "components": {"components": [{"name": "WalletService", "location": "app/wallet"}]},
+ "domain_invariants": [{
+ "id": "inv-balance-001", "entity": "AccountBalance", "category": "value-bound",
+ "invariant": "Balance never goes negative.",
+ "enforced_at": ["app/wallet/balance_repo.ext:214"],
+ "evidence": ["guard:app/wallet/balance_repo.ext:214"],
+ "failure_mode": "Customer spends unfunded value.", "confidence": "stated",
+ }],
+ }
+ (archie / "blueprint_raw.json").write_text(json.dumps(raw))
+ # Wave-2 Product agent output file.
+ product = {
+ "product_model": {
+ "summary": "Illustrative metered-billing service.",
+ "entities": [{"name": "AccountBalance", "role": "spend gate"}],
+ "core_workflow": ["event", "meter", "deduct"],
+ },
+ "derived_invariants": [{
+ "id": "der-001", "invariant": "A credit cannot be reduced below its spent amount.",
+ "derived_from": ["inv-balance-001", "inv-ingest-001"],
+ "failure_mode": "Negative historical balance.", "confidence": "inferred",
+ }],
+ "unenforced_invariants": [{
+ "id": "gap-001", "expected_law": "Record total equals sum of line items.",
+ "entity": "ChargeRecord", "category": "conservation",
+ "why_expected": "Double-entry conservation.",
+ "searched": ["app/billing/**/validate.ext"], "found_enforcement": "none",
+ "risk": "Charged amount drifts from line sum.", "confidence": "inferred",
+ }],
+ }
+ sub = archie / "tmp"
+ sub.mkdir()
+ (sub / "archie_sub_product_x.json").write_text(json.dumps(product))
+ return archie / "tmp" / "archie_sub_product_x.json"
+
+
+def test_finalize_merges_and_renders_product_sections(tmp_path):
+ product_file = _setup_project(tmp_path)
+ finalize(tmp_path, [str(product_file)], patch_mode=False)
+
+ bp = json.loads((tmp_path / ".archie" / "blueprint.json").read_text())
+ # Wave-1 law preserved + Wave-2 sections merged in
+ assert bp["domain_invariants"][0]["id"] == "inv-balance-001"
+ assert bp["product_model"]["summary"].startswith("Illustrative")
+ assert bp["derived_invariants"][0]["id"] == "der-001"
+ assert bp["unenforced_invariants"][0]["id"] == "gap-001"
+
+ # rendered descriptive markdown carries both tiers, distinctly
+ laws = (tmp_path / ".claude" / "rules" / "product-laws.md").read_text()
+ assert "## Product Laws — enforced (grounded)" in laws
+ assert "## ⚠️ Unverified" in laws
+ grounded, _, unverified = laws.partition("## ⚠️ Unverified")
+ assert "Balance never goes negative." in grounded
+ assert "Record total equals sum of line items." in unverified # gap only in unverified
+ assert "Record total equals sum of line items." not in grounded
+
+ model = (tmp_path / ".claude" / "rules" / "product-model.md").read_text()
+ assert "## Product Overview" in model
+ assert "Illustrative metered-billing service" in model # summary
+ assert "1. event" in model # workflow rendered as numbered steps
+ assert "AccountBalance" not in model # entities NOT duplicated here (Data Models owns them)
diff --git a/tests/test_phase3_producer_wiring.py b/tests/test_phase3_producer_wiring.py
new file mode 100644
index 00000000..f79b53f9
--- /dev/null
+++ b/tests/test_phase3_producer_wiring.py
@@ -0,0 +1,156 @@
+"""Phase 3: the Domain (Wave 1) and Product (Wave 2) agents are wired into the
+deep-scan orchestration — not just authored as dead prompt files. These are
+step-lint assertions over the canonical workflow step files (the wire-through
+discipline: a leaf prompt that nothing dispatches is dead wiring).
+"""
+from __future__ import annotations
+
+from pathlib import Path
+
+STEPS = (
+ Path(__file__).resolve().parent.parent
+ / "archie" / "assets" / "workflow" / "deep-scan" / "steps"
+)
+
+
+def _read(rel: str) -> str:
+ return (STEPS / rel).read_text()
+
+
+# ── Wave 1: Domain agent ────────────────────────────────────────────────────
+
+def test_domain_agent_prompt_exists_and_is_grounded():
+ body = _read("step-3-wave1/domain-agent.md")
+ assert "domain_invariants" in body
+ assert "Cite or omit" in body or "cite-or-omit" in body or "OBSERVE, do not invent" in body
+ # the taxonomy checklist and the cite field must be present
+ assert "value-bound" in body and "idempotency" in body and "tenant-isolation" in body
+ assert "enforced_at" in body
+ # explicit boundary vs the Data agent's schema guarantees
+ assert "schema" in body.lower()
+
+
+def test_domain_agent_covers_libraries_and_product_rule_framing():
+ """Product section must work for libraries/services (not just apps) and state
+ laws positively as PRODUCT RULES (what the product guarantees), in product
+ language — not framed by negation against engineering."""
+ body = _read("step-3-wave1/domain-agent.md")
+ assert "library" in body.lower() or "SDK" in body
+ assert "service" in body.lower()
+ assert "consumer" in body.lower() # library consumers / call path
+ # positive product-rule framing
+ assert "PRODUCT RULE" in body or "product rule" in body
+ assert "product owner" in body.lower()
+ assert "guarantees" in body.lower() or "product concept" in body
+ # the framing is POSITIVE — no "not engineering" style wording in the prompt
+ assert "engineering" not in body.lower()
+ # the mechanism field gives code detail its own home (structural fix, not prose)
+ assert "mechanism" in body.lower()
+ assert "product concepts only" in body.lower() or "product concept" in body.lower()
+
+
+def test_product_agent_covers_repo_types_and_product_framing():
+ body = _read("step-5d-product.md")
+ assert "library" in body.lower() and "service" in body.lower()
+ assert "consumer call path" in body or "request lifecycle" in body
+ assert "product rule" in body.lower() or "guarantees" in body.lower()
+ assert "engineering" not in body.lower()
+
+
+def test_domain_agent_steers_distribution_core_first():
+ """The Domain agent must counteract the guard-density skew: anchor to the core,
+ tag domain_role, cap supporting subsystems in default depth, and lift caps in
+ comprehensive."""
+ body = _read("step-3-wave1/domain-agent.md")
+ # core-first anchoring + the named bias it counteracts
+ assert "primary value workflow" in body
+ assert "domain_role" in body
+ assert "guard code is dense" in body or "guards are dense" in body
+ # role values
+ for role in ("core", "supporting", "platform"):
+ assert f'"{role}"' in body or role in body
+ # mine-the-data-flow instruction (so core isn't starved)
+ assert "data flow" in body.lower()
+ # default-depth caps on supporting + explicit comprehensive no-cap
+ assert "per-subsystem cap" in body or "2–3 laws" in body or "2-3 laws" in body
+ assert "NO CAPS" in body or "no caps" in body.lower()
+ assert "comprehensive" in body.lower()
+
+
+def test_domain_and_data_always_spawn_only_ui_optional():
+ orch = _read("step-3-wave1/orchestration.md")
+ # dispatch table rows
+ assert "domain-agent.md" in orch
+ assert "archie_sub6_$PROJECT_NAME.json" in orch
+ # Data + Domain are Always; the persistence heuristic is no longer a spawn gate
+ assert "ALWAYS spawn" in orch
+ assert "only optional Wave 1 agent" in orch.lower() or "ONLY optional Wave 1 agent" in orch
+ # Domain timing key registered
+ assert "Domain → `domain`" in orch
+ # incremental single-agent also emits domain_invariants
+ assert orch.count("domain_invariants") >= 2
+
+
+def test_step4_merges_domain_subfile():
+ merge = _read("step-4-merge.md")
+ assert "archie_sub6_$PROJECT_NAME.json" in merge
+ # listed in the merge.py invocation, not just the expected-files prose
+ assert merge.count("archie_sub6_$PROJECT_NAME.json") >= 2
+
+
+# ── Wave 2: Product agent ───────────────────────────────────────────────────
+
+def test_product_agent_prompt_exists_with_guardrails():
+ body = _read("step-5d-product.md")
+ assert "product_model" in body
+ assert "derived_invariants" in body
+ assert "unenforced_invariants" in body
+ # derivation guardrail (cite the premises)
+ assert "derived_from" in body
+ assert "anchors" in body or "premises" in body
+ # gap-list proof-of-absence discipline
+ assert "searched" in body
+ assert "proof-of-absence" in body.lower() or "proof of absence" in body.lower()
+
+
+def test_product_agent_dispatched_and_gated():
+ w2 = _read("step-5-wave2-reasoning.md")
+ assert "step-5d-product.md" in w2
+ assert "archie_sub_product_$PROJECT_NAME.json" in w2
+ # gated on domain_invariants being non-empty
+ assert "domain_invariants` non-empty" in w2 or "domain_invariants` array is non-empty" in w2
+ # finalize lists the product file in BOTH full and patch invocations
+ assert w2.count("archie_sub_product_$PROJECT_NAME.json") >= 3 # table + 2 finalize calls
+
+
+def test_product_gate_has_concrete_eval_mechanism():
+ """The 'spawn when domain_invariants non-empty' gate must be evaluable by the
+ orchestrator, not just stated — otherwise it's ambiguous wiring."""
+ w2 = _read("step-5-wave2-reasoning.md")
+ assert "DOMAIN_LAW_COUNT" in w2
+ assert "domain_invariants" in w2 and "blueprint_raw.json" in w2
+ # Product is full-mode only (avoids patch-merge duplication of global laws)
+ assert "full mode only" in w2.lower() or "Full scan" in w2
+ assert "incremental" in w2.lower()
+
+
+def test_step6_warns_against_fileline_in_path_glob():
+ """enforced_at entries are file:line citations; the prompt must tell the agent
+ to strip :line and broaden, or hook triggers silently never match."""
+ s6 = _read("step-6-rule-synthesis.md")
+ assert "enforced_at" in s6 and "path_glob" in s6
+ assert ":line" in s6 # the explicit caveat about the suffix
+ assert "match nothing" in s6 or "broaden" in s6
+
+
+def test_design_and_risk_consume_domain_invariants():
+ design = _read("step-5a-design.md")
+ risk = _read("step-5b-risk.md")
+ # Design links decisions to the laws they preserve
+ assert "domain_invariants" in design
+ assert "forced_by" in design and "enables" in design
+ # Risk turns laws into pitfalls
+ assert "domain_invariants" in risk
+ assert "pitfall" in risk.lower()
+ # neither sibling emits the law sections themselves (Domain/Product own them)
+ assert "do NOT" in design.lower() or "Do NOT" in design
diff --git a/tests/test_renderer_product_laws.py b/tests/test_renderer_product_laws.py
new file mode 100644
index 00000000..c971439d
--- /dev/null
+++ b/tests/test_renderer_product_laws.py
@@ -0,0 +1,172 @@
+"""Phase 2: the renderer emits the product-model + two-tier product-laws
+descriptive markdown, and routes domain_invariant enforcement rules into the
+by-topic directory. The grounded/ungrounded boundary must be unmissable and a
+gap entry must never leak into the grounded tier.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from archie.standalone import renderer
+from archie.standalone._common import normalize_blueprint
+
+FIXTURE = Path(__file__).resolve().parent / "fixtures" / "blueprint_domain_invariants.json"
+
+
+def _render():
+ bp = json.loads(FIXTURE.read_text())
+ normalize_blueprint(bp)
+ rules = [{
+ "id": "inv-balance-001", "kind": "domain_invariant", "topic": "data-modeling",
+ "severity_class": "decision_violation", "description": "Balance never negative",
+ "why": "Spending below zero diverges ledger from charges.", "_archie_source": "project",
+ }]
+ return renderer.generate_all(bp, enforcement_rules=rules), bp
+
+
+def test_product_laws_file_has_both_tiers():
+ files, _ = _render()
+ body = files[".claude/rules/product-laws.md"]
+ assert "## Product Laws — enforced (grounded)" in body
+ assert "## ⚠️ Unverified" in body
+ # grounded enforced citation
+ assert "app/wallet/balance_repo.ext:214" in body
+ # mechanism rendered as a secondary "how it's enforced" line, distinct from the law
+ assert "How it's enforced:" in body
+ assert "rejected past zero before the write commits" in body
+ # derived law, anchored
+ assert "_(derived)_" in body
+ assert "inv-balance-001" in body
+ # unverified gap shows its proof-of-work
+ assert "Searched (no enforcement found)" in body
+
+
+def test_gap_law_never_appears_in_grounded_tier():
+ """The unenforced law's text must sit only under the ⚠️ header."""
+ files, _ = _render()
+ body = files[".claude/rules/product-laws.md"]
+ grounded, _, unverified = body.partition("## ⚠️ Unverified")
+ gap_text = "An issued charge record's total equals the sum of its line amounts."
+ assert gap_text not in grounded
+ assert gap_text in unverified
+
+
+def test_product_model_file_rendered():
+ files, _ = _render()
+ body = files[".claude/rules/product-model.md"]
+ assert "## Product Overview" in body
+ assert "### Core workflow" in body
+ # workflow rendered as titled steps (new {title, description} shape)
+ assert "**Ingest event**" in body
+ assert "An append-only usage event is recorded" in body
+ # entities are NOT rendered here — Data Models owns that inventory
+ assert "AccountBalance" not in body
+ assert "spend gate" not in body
+
+
+def test_product_model_workflow_legacy_strings():
+ """Backward compat: a workflow of plain strings still renders as numbered steps."""
+ bp = {
+ "meta": {"architecture_style": "x"}, "components": {"components": []},
+ "product_model": {"summary": "A product.", "core_workflow": ["first thing", "second thing"]},
+ }
+ normalize_blueprint(bp)
+ body = renderer.generate_all(bp)[".claude/rules/product-model.md"]
+ assert "## Product Overview" in body
+ assert "1. first thing" in body
+ assert "2. second thing" in body
+
+
+def test_domain_invariant_rule_grouped_in_enforcement():
+ files, _ = _render()
+ assert ".claude/rules/enforcement/by-topic/data-modeling.md" in files
+ assert "Balance never negative" in files[".claude/rules/enforcement/by-topic/data-modeling.md"]
+
+
+def test_agents_md_summarizes_product_laws():
+ files, _ = _render()
+ agents = files["AGENTS.md"]
+ assert "## Product Laws" in agents
+ assert "Enforced (grounded)" in agents
+ assert "product-laws.md" in agents
+ assert "⚠️ Unverified" in agents
+
+
+def test_product_laws_grouped_by_role_core_first():
+ """domain_role groups the grounded tier with Core before Supporting before Platform."""
+ bp = {
+ "meta": {"architecture_style": "x"}, "components": {"components": []},
+ "domain_invariants": [
+ {"id": "inv-sub-1", "domain_role": "supporting", "category": "lifecycle/state",
+ "invariant": "Pro is granted only on an active entitlement.", "enforced_at": ["a:1"]},
+ {"id": "inv-core-1", "domain_role": "core", "category": "conservation/accounting",
+ "invariant": "Temperature is normalized before the recommendation lookup.", "enforced_at": ["b:2"]},
+ {"id": "inv-plat-1", "domain_role": "platform", "category": "tenant-isolation",
+ "invariant": "All API calls carry a bearer token.", "enforced_at": ["c:3"]},
+ ],
+ }
+ normalize_blueprint(bp)
+ laws = renderer.generate_all(bp)[".claude/rules/product-laws.md"]
+ assert "### Core product laws" in laws
+ assert "### Supporting features" in laws
+ assert "### Platform" in laws
+ # ordering: core subheading precedes supporting precedes platform
+ assert laws.index("### Core product laws") < laws.index("### Supporting features") < laws.index("### Platform")
+ # the core law sits under Core, above the supporting law
+ assert laws.index("Temperature is normalized") < laws.index("Pro is granted only")
+
+
+def test_product_laws_flat_when_no_role():
+ """Backward compat: no domain_role anywhere → flat list, no role subheadings."""
+ bp = {
+ "meta": {"architecture_style": "x"}, "components": {"components": []},
+ "domain_invariants": [
+ {"id": "inv-1", "invariant": "A law without a role.", "enforced_at": ["a:1"]},
+ ],
+ }
+ normalize_blueprint(bp)
+ laws = renderer.generate_all(bp)[".claude/rules/product-laws.md"]
+ assert "A law without a role." in laws
+ assert "### Core product laws" not in laws # flat fallback
+
+
+def test_derived_only_blueprint_no_dangling_header():
+ """Regression (Reviewer-2): a blueprint with derived but no observed laws must
+ still list the derived law in the AGENTS.md summary, not a bare header."""
+ bp = {
+ "meta": {"architecture_style": "x"}, "components": {"components": []},
+ "derived_invariants": [{"id": "der-001", "invariant": "A derived law holds."}],
+ }
+ normalize_blueprint(bp)
+ agents = renderer.generate_all(bp)["AGENTS.md"]
+ assert "## Product Laws" in agents
+ assert "A derived law holds." in agents # listed, not a dangling header
+ # the grounded summary line is immediately followed by content, not another header
+ assert "**Enforced (grounded)**" in agents
+
+
+def test_empty_invariant_entry_skipped():
+ """Regression (Reviewer-2): a malformed entry with empty invariant text must
+ not render as an empty bold bullet (`- ****`)."""
+ bp = {
+ "meta": {"architecture_style": "x"}, "components": {"components": []},
+ "domain_invariants": [
+ {"id": "inv-1", "invariant": "Real law."},
+ {"id": "inv-2", "invariant": " "}, # malformed
+ ],
+ }
+ normalize_blueprint(bp)
+ laws = renderer.generate_all(bp)[".claude/rules/product-laws.md"]
+ assert "Real law." in laws
+ assert "- ****" not in laws
+
+
+def test_no_product_files_when_sections_absent():
+ """A blueprint with none of the new sections emits neither descriptive file."""
+ bp = {"meta": {"architecture_style": "x"}, "components": {"components": []}}
+ normalize_blueprint(bp) # gives empty product_model {} and empty invariant lists
+ files = renderer.generate_all(bp)
+ assert ".claude/rules/product-laws.md" not in files
+ assert ".claude/rules/product-model.md" not in files
+ assert "## Product Laws" not in files["AGENTS.md"]
diff --git a/tests/test_rule_kinds.py b/tests/test_rule_kinds.py
index b9de8b74..f8f7f693 100644
--- a/tests/test_rule_kinds.py
+++ b/tests/test_rule_kinds.py
@@ -11,7 +11,7 @@ def test_expected_kinds_present():
expected = {
"decision", "pitfall", "tradeoff", "layering",
"semantic_pattern", "file_placement", "naming_convention",
- "infrastructure", "data_contract", "coding_practice",
+ "infrastructure", "data_contract", "domain_invariant", "coding_practice",
}
assert set(KINDS) == expected
@@ -68,6 +68,30 @@ def test_classify_id_prefix_extend_is_semantic_pattern():
assert classify_kind({"id": "extend-005"}) == "semantic_pattern"
+def test_classify_id_prefix_inv_is_domain_invariant():
+ """Observed product laws (domain-agent) carry the `inv-` id prefix.
+
+ Like every other prefix in the map (`dec`, `layer`, ...), the fallback fires
+ for simple ids (`inv-001`); multi-segment ids (`inv-balance-001`) rely on the
+ explicit `kind` field, which step-6 always emits (covered separately)."""
+ assert classify_kind({"id": "inv-001"}) == "domain_invariant"
+
+
+def test_classify_id_prefix_der_is_domain_invariant():
+ """Derived product laws (product-agent) carry the `der-` id prefix."""
+ assert classify_kind({"id": "der-001"}) == "domain_invariant"
+
+
+def test_domain_invariant_is_valid_kind():
+ assert is_valid_kind("domain_invariant") is True
+
+
+def test_explicit_domain_invariant_kind_preserved():
+ """A rule already tagged domain_invariant keeps it even with a block severity."""
+ rule = {"id": "inv-x-001", "kind": "domain_invariant", "severity_class": "decision_violation"}
+ assert classify_kind(rule) == "domain_invariant"
+
+
def test_classify_severity_class_decision_violation():
assert classify_kind({"id": "x-001", "severity_class": "decision_violation"}) == "decision"
diff --git a/tests/test_step6_domain_invariant.py b/tests/test_step6_domain_invariant.py
new file mode 100644
index 00000000..1bd1dc79
--- /dev/null
+++ b/tests/test_step6_domain_invariant.py
@@ -0,0 +1,72 @@
+"""Phase 2: step-6 rule synthesis covers the product-law sections, excludes the
+informational ones, and a domain_invariant rule round-trips through
+extract_output -> classify_kind -> rule_index without special-casing.
+"""
+from __future__ import annotations
+
+import json
+from pathlib import Path
+
+from archie.standalone.extract_output import cmd_rules
+from archie.standalone.rule_kinds import classify_kind
+from archie.standalone.rule_index import build_index
+
+STEP6 = (
+ Path(__file__).resolve().parent.parent
+ / "archie" / "assets" / "workflow" / "deep-scan" / "steps" / "step-6-rule-synthesis.md"
+)
+
+
+def test_step6_prompt_covers_product_laws():
+ """Guard against silent drift: the coverage table and policy must name the
+ new sections, and must exclude the informational-only ones."""
+ text = STEP6.read_text()
+ # coverage rows
+ assert "`domain_invariants[*]`" in text
+ assert "`derived_invariants[*]`" in text
+ assert "`domain_invariant`" in text
+ # the informational-only sections are explicitly NOT rule sources
+ assert "Do NOT emit rules from `product_model`" in text
+ assert "unenforced_invariants" in text
+ # the dedicated severity + dedup policy exists
+ assert "Product-law rules" in text
+ assert "canonical carrier" in text
+
+
+def test_domain_invariant_rule_roundtrips(tmp_path):
+ """A stated + a derived product-law rule survive extract_output and index."""
+ agent_out = tmp_path / "agent_rules.json"
+ agent_out.write_text(json.dumps({"rules": [
+ {
+ "id": "inv-balance-001", "kind": "domain_invariant", "topic": "data-access",
+ "severity_class": "decision_violation",
+ "description": "An account balance must never go negative",
+ "why": "Spending below zero diverges the ledger from the charged total.",
+ "triggers": {"path_glob": ["app/wallet/**"]},
+ },
+ {
+ "id": "der-001", "kind": "domain_invariant", "topic": "data-access",
+ "severity_class": "tradeoff_undermined",
+ "description": "A credit cannot be reduced below its spent amount",
+ "why": "Derived from inv-balance-001 + inv-ingest-001.",
+ },
+ ]}))
+ rules_json = tmp_path / "rules.json"
+ cmd_rules(str(agent_out), str(rules_json))
+
+ saved = json.loads(rules_json.read_text())["rules"]
+ by_id = {r["id"]: r for r in saved}
+ assert set(by_id) == {"inv-balance-001", "der-001"}
+ # both classify as domain_invariant (explicit kind preserved)
+ assert classify_kind(by_id["inv-balance-001"]) == "domain_invariant"
+ assert classify_kind(by_id["der-001"]) == "domain_invariant"
+ # severity policy: observed blocks, derived warns
+ assert by_id["inv-balance-001"]["severity_class"] == "decision_violation"
+ assert by_id["der-001"]["severity_class"] == "tradeoff_undermined"
+ # source stamped defensively
+ assert all(r["source"] == "deep_scan" for r in saved)
+
+ # the index picks up the observed law's enforced-at path glob
+ index = build_index(saved)
+ assert "app/wallet/**" in index["by_path_glob"]
+ assert "inv-balance-001" in index["by_path_glob"]["app/wallet/**"]