# Coding Python Enforce Data Architecture Strict

> Use when refactoring or reviewing Python code to enforce a strict data architecture - Pydantic models at every external boundary, typed models (never raw dicts) inside the app, Enums for all fixed categorical values, and minimal input-to-output conversions. Use when asked to eliminate dict parameters, stringly-typed status/mode values, Model->dict->Model conversion chains, or compatibility shims, or to make a Python data flow type-safe end to end.

- Skill: `bitranox/coding-python-enforce-data-architecture-strict` (Agent Skill, multi-file: 10 files)
- Install (CLI): `npx skillmds@latest add bitranox/coding-python-enforce-data-architecture-strict`
- Raw SKILL.md: https://api.skillmd.com/api/skills/bitranox/coding-python-enforce-data-architecture-strict/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: bitranox (https://skillmd.com/u/bitranox)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/bitranox/coding-python-enforce-data-architecture-strict

---


# Data Architecture Enforcement

Refactor Python code to follow strict data architecture rules using Pydantic models and Enums.

> Pydantic-at-the-boundary is also the input-sanitization edge. For the full per-sink output escaping
> (parametrized SQL, HTML autoescape, shell argv) and the boundary-vs-internal-libs scope, see
> `bitranox:coding-input-sanitization`.

---

## Architecture Rules

### Core Principles

1. **Pydantic at Boundaries**: All external data (API requests, file reads, env vars, CLI args) must be parsed into Pydantic models immediately upon entry
2. **Pydantic for Export**: All outputs (API responses, file writes, serialization) must use Pydantic's `.model_dump()` or `.model_dump_json()`
3. **No Internal Dicts**: Inside the application, never use raw dicts for structured data - always typed models
4. **Enums for Constants**: All string literals representing categories, statuses, modes, or fixed values must be Enums. For values that cross an external boundary as strings, use `StrEnum` (Python 3.11+) or `class X(str, Enum)` so Pydantic parses and serializes them without changing the wire format; reserve `IntEnum` for values that are genuinely integers on the wire. **If your floor is 3.10 and you take the `class X(str, Enum)` fallback, its default string form is version-dependent** - read "The `str, Enum` fallback formats differently on 3.10 and 3.11+" below before you interpolate a member anywhere.
5. **Minimize Conversions**: Ideal flow is ONE parse at input, ONE dump at output - nothing in between
6. **String-to-Enum at Edges Only**: Convert strings to Enums only at system boundaries (input parsing). All functions and methods must accept and use the Enum type directly - never convert str->Enum inside business logic. This mirrors the class/Pydantic rule: parse once at entry, use typed objects throughout
7. **No Compatibility Shims**: Remove all compatibility shims - code must use dataclass fields and enums directly. No wrapper methods, aliases, or backward-compatibility layers that accept old formats

### What to Use When

| Scenario                                   | Use                          |
|--------------------------------------------|------------------------------|
| External input/output                      | Pydantic `BaseModel`         |
| Internal business logic (no serialization) | `@dataclass` or Pydantic     |
| Need validation                            | Pydantic `BaseModel`         |
| Fixed string values (string on the wire)   | `StrEnum` / `str, Enum`      |
| Simple value objects                       | `NamedTuple` or `@dataclass` |

### The `str, Enum` fallback formats differently on 3.10 and 3.11+

`enum.StrEnum` is safe: `str(member)`, `format(member)` and an f-string all give the VALUE on
every version that has it. The `class X(str, Enum)` fallback for a 3.10 floor is NOT:

```python
class C(str, Enum):
    A = "capacity"

f"{C.A}"      # 3.10 -> "capacity"      3.11+ -> "C.A"
format(C.A)   # 3.10 -> "capacity"      3.11+ -> "C.A"
str(C.A)      # "C.A" on EVERY version
C.A.value     # "capacity" on every version
```

Python 3.11 made `__format__` follow `__str__` for mixed-in enums. So a member interpolated into
a key, a filename, a log line or any hand-built payload silently changes shape across the version
matrix, and the 3.10 lane is the one that looks correct.

This is the one place the "avoid unnecessary `.value` access" guidance above does NOT apply.
Comparisons, assignments and Pydantic serialization take the member directly; **anything that
builds a string by interpolation takes `.value`.** Measured cost of getting it wrong: an alert-state
key built as `f"{pool}:{category}"` wrote `rpool:IssueCategory.CAPACITY` instead of `rpool:capacity`
on 3.11+, which orphans every previously-written state entry and re-fires every open alert.

Pin the wire form with a test that asserts the exact string, and run the whole declared version
range - a single-interpreter green cannot see this.

### A bare `BaseModel` field serializes as `{}`

Pydantic serializes a field by its DECLARED type, not the runtime value it holds. A field annotated
as the bare `BaseModel` base class has no fields of its own, so `.model_dump()` and
`.model_dump_json()` silently emit `{}` for it no matter which real subclass instance you put there
- a type checker sees a fully-annotated field and passes, and a test that asserts only the OUTER
envelope keys exist stays green too, so nothing catches it short of reading the actual value:

```python
from pydantic import BaseModel, SerializeAsAny

class Payload(BaseModel):
    name: str
    count: int

class Envelope(BaseModel):
    kind: str
    payload: BaseModel  # BAD: declared type has no fields to dump

env = Envelope(kind="a", payload=Payload(name="widget", count=3))
env.model_dump()  # {'kind': 'a', 'payload': {}}  <- silently empty
```

Two fixes, and they are not interchangeable:

- **`SerializeAsAny[BaseModel]`** keeps the field typed for as long as the object lives - use it
  when the envelope is a live object other code still reads before the final dump. It tells
  Pydantic to serialize the RUNTIME type polymorphically: `env.model_dump()` now gives
  `{'kind': 'a', 'payload': {'name': 'widget', 'count': 3}}`.
- **Export at the boundary** - keep the field a plain mapping and call `.model_dump()` on the
  payload yourself in the function that emits the envelope - is the better default when the
  envelope exists only to be dumped (a CLI's `{ok, command, data, skipped}` response, an API
  reply). That is Rule 5's "one dump at output, nothing in between" applied to the payload too,
  and it needs no extra annotation.

Do not widen either fix into `SerializeAsAny[BaseModel] | dict` expecting a permissive fallback: a
raw dict validates against the bare `BaseModel` arm (an empty base model accepts any mapping), and
the instance you get back has no working serializer, so `.model_dump()` raises `TypeError:
'MockValSer' object is not an instance of 'SchemaSerializer'` instead of degrading gracefully. Type
the field for the concrete subclasses you actually expect, and diff the emitted JSON against a
pre-change baseline whichever fix you take - the wire format is the only thing that shows an empty
payload.

### Anti-Patterns to Eliminate

```python
# BAD: Dict parameter
def process(data: dict) -> dict:
    return {"name": data["name"]}

# BAD: String literals for status
if user.status == "active":

# BAD: Unnecessary conversion chain
model.model_dump()  # -> dict
SomeClass(**dict_data)  # -> back to model

# BAD: Converting just to access fields
d = model.model_dump()
name = d["name"]  # Just use model.name!

# BAD: Pydantic -> dataclass -> Pydantic
@dataclass
class Internal:
    ...
internal = Internal(**pydantic_model.model_dump())
output = OutputModel(**asdict(internal))
```

---

## Instructions

1. **Create a Todo List** with the following Definition of Done (DoD) items to track refactoring progress:

   **Input/Output Boundaries:**
   - [ ] Parse all external inputs into Pydantic models at system boundaries
   - [ ] Produce all outputs through Pydantic export methods (`.model_dump()`, `.model_dump_json()`)

   **Internal Data Handling:**
   - [ ] Eliminate all internal dict processing - no `data["key"]` access
   - [ ] All functions/methods use typed fields from Pydantic models or dataclasses
   - [ ] Use `@dataclass` only for pure internal logic with no serialization needs

   **Type Safety:**
   - [ ] Replace all string literals (statuses, modes, categories) with Enums
   - [ ] Ensure all function signatures use typed models, not `dict`

   **Conversion Optimization:**
   - [ ] Remove redundant Model->dict->Model chains
   - [ ] Eliminate Pydantic->dataclass->Pydantic conversions (use Pydantic throughout)
   - [ ] Verify minimum conversions: ideally 1 at input, 1 at output

   **Exceptions:**
   - [ ] Small local dicts (few items, single function scope) may remain - convert if used across functions/modules

   **Verification:**
   - [ ] Run `make test` OR pyproject.toml tools (pytest, ruff, mypy, etc.) - fix all errors until passing
   - [ ] Verify type checking passes and Pydantic validation works

2. **Analyze the Code** and identify violations:
   - Functions accepting `dict` parameters instead of typed models
   - Raw dict key access (`data["key"]`) instead of field access (`data.key`)
   - String literals used as identifiers, modes, or status values
   - Unnecessary conversions between dicts and dataclasses
   - Missing Pydantic validation at input boundaries
   - Missing Pydantic export methods at output boundaries
   - **Unnecessary data conversions** - AGGRESSIVELY detect and eliminate:
     * Model -> dict -> Model chains (pass the model directly)
     * Multiple `.model_dump()` calls on the same object
     * Converting to dict just to access fields
     * Redundant serialization/deserialization cycles
     * Intermediate dict structures that serve no purpose
     * **Pydantic <-> dataclass conversions**: If code converts Pydantic -> dataclass or dataclass -> Pydantic, refactor to use Pydantic throughout. Remove compatibility shims for dict/dataclasses/Pydantic (no wrapper methods, aliases, or backward-compatibility layers)
     * Unnecessary dataclass wrappers when Pydantic can be used directly
     * Any conversion that doesn't add value - minimize total conversions
     * **Count total conversions** in the data flow - goal is MINIMUM possible (ideally: 1 at input, 1 at output)
     * **Enum usage**: Use `StrEnum`/`str, Enum` for string-valued fields and `IntEnum` only for integer-valued ones. Avoid unnecessary `.value` access or conversions - use the enum member directly in comparisons and assignments. The exception is string INTERPOLATION under the `str, Enum` fallback, where `.value` is required and not optional (see "The `str, Enum` fallback formats differently on 3.10 and 3.11+"). Remove compatibility shims for enums (no wrapper methods, aliases, or backward-compatibility layers)

3. **Refactor the Code** following these rules:
   - Import external data immediately into Pydantic models with validation
   - Export data using Pydantic's built-in dump methods
   - **Prefer Pydantic over dataclass** - only use `@dataclass` when there's a clear benefit and no Pydantic conversion needed
   - If you see Pydantic -> dataclass -> Pydantic, eliminate the dataclass and use Pydantic throughout
   - Never create or handle internal dict structures
   - Convert dict inputs to Pydantic models as early as possible
   - All functions must operate on dataclasses or Pydantic fields
   - **Minimize total conversions** - count them, ideal is ONE at input boundary, ONE at output boundary
   - Replace fixed string sets with `Enum` classes

4. **Mark Todo Items Complete** as each DoD criterion is satisfied.

## Workflow

**CRITICAL: This is an iterative process. You MUST loop until ZERO violations remain. Do NOT stop early.**

**State is tracked in `.data_arch_violations.json` - always read before and write after each phase.**

```
INITIALIZATION:
  1. Read all target files
  2. Read pyproject.toml (if exists) to identify configured tools
  3. Use TodoWrite to add one item per target file to the DoD checklist from Instructions
     step 1 (one list, extended - not a second, competing list)
  4. Create state file `.data_arch_violations.json`:
     {
       "pass": 0,
       "total_violations": 0,
       "files": {
         "path/to/file.py": {
           "violations": [],
           "status": "pending"  // pending | in_progress | clean
         }
       }
     }

================================================================================
|  MAIN LOOP - REPEAT STEPS A->B UNTIL total_violations == 0                    |
================================================================================

  STEP A - PARALLEL ANALYSIS (use subagents):
     - Read `.data_arch_violations.json`
     - Increment "pass" counter
     - Launch subagents (Task tool, subagent_type="Explore", model="sonnet") in PARALLEL for each file
       (pin the tier; per-file scanning is bounded sonnet work - see "Concrete tiers" in
       bitranox:process-agents-subagent-driven-development)
     - Each subagent prompt MUST include:
       * The file path to analyze
       * The violation patterns to search for (from Architecture Rules above)
       * The patterns that are NOT violations, spelled out - a small single-function local
         dict, a free-form external JSON envelope, and DYNAMICALLY-keyed maps (queue index to
         count, metric name to value, MAC to address). Without this an analyser reports every
         `dict[str, X]` it sees, and you spend the loop re-judging its false positives.
       * Instruction to return JSON: {"file": "path", "violations": [{"line": N, "type": "...", "description": "..."}]}
       * **"Reply with the JSON object and NOTHING else - no preamble, no summary, no markdown
         fence."** Say it explicitly. An analyser that answers in prose ("Findings reported
         above: 8 items across 4 files") loses every finding, because the detail it refers to
         was never in what you received - and the count it cites makes the loss look like a
         result. Re-run that agent; do not reconstruct its numbers.
     - Collect all subagent results
     - Update `.data_arch_violations.json` with violations from each subagent
     - Calculate total_violations = sum of all violations across files

     >>> If total_violations == 0: EXIT LOOP, GOTO STEP C
     >>> If total_violations > 0: CONTINUE TO STEP B

  STEP B - PARALLEL REFACTORING (use subagents):
     - Read `.data_arch_violations.json`
     - PARTITION the work into NON-OVERLAPPING file sets, one per subagent, BEFORE launching
       anything. This refactor is not per-file independent: an Enum lives in `enums.py` and a
       model in `models.py`, so several agents will reach for the SAME shared file. Give each
       shared file exactly ONE owner (or edit it yourself first and let the others only
       import from it). Measured: three agents editing one checkout in parallel produced a
       transient test failure from a half-written sibling edit, and one agent had to re-read
       files before every write to avoid clobbering another's work.
     - Launch subagents (Task tool, subagent_type="general-purpose", model="sonnet") in PARALLEL,
       one per file SET (per-file refactor is bounded sonnet work; pin the tier)
     - Each subagent prompt MUST include:
       * The full Architecture Rules section (copy from this document)
       * The specific violations for that file (from state file)
       * The EXPLICIT list of files it may touch, and a statement that other agents are
         editing the rest of the repo concurrently so it must not edit anything outside that
         list. An agent that finds a needed fix in someone else's file REPORTS it instead of
         making it - that hand-off is how the last error gets found, not a failure.
       * The state file path: `.data_arch_violations.json`
       * Instruction to fix ALL violations
       * Instruction to UPDATE the state file after fixing:
         - Set file status to "in_progress"
         - Clear the violations array for that file
         - Recalculate total_violations as the sum of all violations arrays (never
           decrement: parallel subagents sharing one counter race and lose updates)
       * **Instruction to run the TYPE CHECKER as well as the tests, and to report the error
         count.** Tests alone cannot verify this refactor: a `StrEnum` member compares and
         hashes equal to its string value, so every existing string-literal call site and
         assertion stays green while the type checker errors on the changed signature.
         Measured: three agents each reported "731 passed" while pyright had 24 errors, all
         of them call sites still passing raw literals into newly-Enum-typed parameters.
       * Instruction to return: {"file": "path", "fixed": ["violation1", ...], "remaining": [...]}
     - After all subagents complete: Read state file and verify updates
     - **Re-run the gate YOURSELF.** A subagent's green is not the gate's green: it may have
       sampled mid-flight while a sibling was still writing, and agents' reported counts will
       disagree with each other for exactly that reason. Yours is the only authoritative one.

     >>> GOTO STEP A (MANDATORY - must re-analyze to verify fixes and catch new issues)
     >>> DO NOT proceed to STEP C until analysis shows total_violations == 0

STEP C - RUN TESTS:
  **THIS STEP IS MANDATORY - DO NOT SKIP**

  Execute these commands using the Bash tool:

  1. First, check if Makefile exists with test target:
     ```bash
     test -f Makefile && grep -q "^test:" Makefile && echo "FOUND"
     ```

  2. If "FOUND": Execute `make test` now:
     ```bash
     make test
     ```

  3. If NO Makefile test target, read pyproject.toml and run configured tools:
     ```bash
     # Run each tool that has a [tool.X] section in pyproject.toml:
     pytest                        # if [tool.pytest] exists
     ruff check . && ruff format --check .  # if [tool.ruff] exists
     mypy .                        # if [tool.mypy] exists
     black --check .               # if [tool.black] exists
     isort --check .               # if [tool.isort] exists
     ```

  4. ON FAILURE:
     - Read the error output
     - Fix the code causing the failure
     - Re-run the failed command
     - REPEAT until all commands pass

  >>> DO NOT proceed to STEP D until all tests/linters pass

STEP D - FINAL VERIFICATION:
  - Read `.data_arch_violations.json` and confirm total_violations == 0
  - Grep only to surface candidates: `-> dict`, `: dict` in signatures. A hit is a lead, not
    proof - `: dict` also matches the permitted small local dicts and annotations that are not
    signatures at all. Judge each hit against the Exceptions rule; do NOT auto-loop on raw match
    count.
  - If a real violation is found: GOTO STEP A
  - If clean:
    * Update state file: all files status = "clean"
    * Delete `.data_arch_violations.json`
    * Mark todos complete
    * Report: "[OK] Complete after {pass} passes - all violations fixed - tests passing"
```

**IMPORTANT RULES:**
1. ALWAYS read state file before each step
2. ALWAYS write state file after each step
3. ALWAYS loop back to STEP A after STEP B - never skip re-analysis
4. Do NOT proceed to STEP C until total_violations == 0
5. **ALWAYS run STEP C** - execute `make test` or pyproject.toml tools
6. Do NOT proceed to STEP D until all tests pass
7. Delete state file only after successful completion

**LOOP ENFORCEMENT:**
```
STEP A -> found violations? -> STEP B -> STEP A (again!)
STEP A -> no violations? -> STEP C (run make test!) -> STEP D -> DONE
```

**Subagent Prompt Templates:**

Analysis subagent:
```
Analyze {file_path} for data architecture violations:
- dict parameters (`: dict`, `-> dict`)
- dict key access (`["key"]`, `['key']`)
- string literals for statuses/modes (`== "..."`, `!= "..."`)
- missing Pydantic at boundaries
- unnecessary Model->dict->Model conversions

Return JSON only: {"file": "{file_path}", "violations": [{"line": N, "type": "...", "description": "..."}]}
```

Refactoring subagent:
```
Fix these violations in {file_path}:
{violations_list}

Rules: [paste Architecture Rules section]

After fixing:
1. Read `.data_arch_violations.json`
2. Update the entry for {file_path}:
   - Set "status": "in_progress"
   - Set "violations": [] (or list remaining if any)
3. Recalculate "total_violations" as sum of all violations arrays
4. Write updated state back to `.data_arch_violations.json`

Fix ALL violations. Return JSON: {"file": "{file_path}", "fixed": [...], "remaining": [...]}
```

## Example Transformations

**Before (violation):**
```python
def process_user(data: dict) -> dict:
    if data["status"] == "active":
        return {"name": data["name"], "role": "member"}
```

**After (compliant):**
```python
from enum import StrEnum
from pydantic import BaseModel

class UserStatus(StrEnum):
    ACTIVE = "active"
    INACTIVE = "inactive"

class UserRole(StrEnum):
    MEMBER = "member"
    ADMIN = "admin"

class UserInput(BaseModel):
    name: str
    status: UserStatus

class UserOutput(BaseModel):
    name: str
    role: UserRole

def process_user(data: UserInput) -> UserOutput:
    if data.status == UserStatus.ACTIVE:
        return UserOutput(name=data.name, role=UserRole.MEMBER)
```

---

## Define the types; never suppress or exclude the checker

The discipline that forbids stringly-typed values also forbids silencing the type checker. When
pyright/mypy strict flags your code - very often a third-party stub gap, not a real defect - the
completion path is to DEFINE the missing types, not to suppress the diagnostic or exclude the file.

Order of preference:

1. Add the real annotation or generic argument.
2. Wrap a partially-typed third-party symbol in a typed facade you define - a `Protocol` plus a
   `cast`, or a local `.pyi` stub on `stubPath` - so call sites see complete types.
3. Only if neither is feasible, a NARROW, rule-specific `# pyright: ignore[theRule]` with a comment
   naming WHY and the remove-when condition. Never reach first for `reportX = false`, a per-file
   rule-off, an `exclude` entry, or a bare `# type: ignore` - those blind the same scope to real
   errors too, and rot silently.

Worked example - rich-click's `option`/`argument`/`version_option` decorators have a
partially-unknown return type, so pyright strict reports `reportUnknownMemberType` at every
`@click.option(...)`. This is a stub gap, not a language rule: reproduced on rich-click 1.9.8
with pyright 1.1.411 (2026-08-28). Run pyright first - if a newer rich-click ships complete
stubs it reports nothing, and the facade below would be solving a problem you no longer have. click's own decorators are fully typed, but they default the parameter class
to `click.Option` rather than rich-click's `RichOption` (which changes help rendering), so you
cannot just import them from click. Cast the MODULE to a `Protocol`: the `cast` is a runtime no-op,
so the wrappers forward to rich-click's own decorators (RichOption preserved) while pyright sees
complete types and no rule is silenced:

```python
from collections.abc import Callable
from typing import Any, Protocol, cast

import rich_click as click

_CommandDecorator = Callable[[Callable[..., Any]], Callable[..., Any]]


class _RichClickDecorators(Protocol):
    option: Callable[..., _CommandDecorator]
    argument: Callable[..., _CommandDecorator]
    version_option: Callable[..., _CommandDecorator]


_click = cast("_RichClickDecorators", click)  # type-only; forwards to rich_click at runtime


def option(*param_decls: str, **attrs: Any) -> _CommandDecorator:
    return _click.option(*param_decls, **attrs)
```

Casting the FUNCTION, or `from rich_click import option`, still evaluates the partially-unknown
member access and stays flagged; only casting the MODULE onto a typed Protocol moves the access to
a typed surface.

Second worked example - a lambda cannot satisfy a keyword-only `Protocol`. Two house defaults
collide and the diagnostic blames the wrong one. Ruff's `FBT` forbids a boolean POSITIONAL
argument, so a callable `Protocol` grows a keyword-only parameter; a lambda used where that
`Protocol` is expected then fails pyright strict, because a lambda parameter takes no annotation
and pyright will not back-infer one from the `Protocol`. Always `reportUnknownLambdaType` on the
parameter, plus one more that depends on where the lambda sits: `reportUnknownVariableType` when
it is assigned, `reportUnknownArgumentType` when it is passed as an argument. Both errors name the
LAMBDA, so it reads as a pyright quirk rather than a consequence of the `Protocol` change:

```python
class Formatter(Protocol):
    def __call__(self, value: str, *, verbose: bool) -> str: ...


# 2 errors: reportUnknownVariableType + reportUnknownLambdaType ("verbose" is unknown)
render: Formatter = lambda value, *, verbose: value.upper() if verbose else value


def render(value: str, *, verbose: bool) -> str:      # same body, fully typed, 0 errors
    return value.upper() if verbose else value
```

The fix is the typed nested `def`, not a suppression: it annotates what the lambda syntactically
cannot. A `def` is available anywhere a lambda is, including inside another function.

Rationalizations that do not fly here:

- "The lambda is a one-liner and the `Protocol` already states the types, so a
  `# pyright: ignore[reportUnknownLambdaType]` is proportionate" - the `Protocol` types the
  PARAMETER the callable is assigned to, never the lambda's own parameters, which is why the
  checker cannot see them; the ignore suppresses a real gap rather than closing it, and the `def`
  is the same number of lines.
- "Just a per-file `reportUnknownMemberType = false` (or scattered `# pyright: ignore`) for the CLI
  glue, plus a ticket" - a per-file rule-off blinds every future line in that file to real
  unknown-type bugs, and the ticket has no forcing function; the facade is written ONCE and every
  call site inherits it, which is fewer edits than scattering ignores.
- "It is a third-party stub gap, not my code, so suppressing is fair" - the gap is real, but the
  suppression is YOUR code's permanent blind spot; define the missing types instead, and reserve a
  narrow, commented, rule-specific ignore only for the case a facade genuinely cannot express.
- "Exclude the wiring files - they are just glue" - `exclude` kills strict checking for everything
  in those files; wiring is exactly where an untyped decorator or a wrong-typed option silently
  breaks the CLI. Keep them checked; type the surface.

### Worked example - a RECURSIVE alias on a pre-3.12 floor

The third shape a reader meets, after the stub gap and the lambda: a value type that contains
itself (a JSON document, a config tree, a nested record). The temptation is `Any`, which is a
suppression wearing a type's clothes. Each of the three traps below reports somewhere other than
its cause, so getting it wrong costs a gate cycle per attempt.

```python
from __future__ import annotations

from collections.abc import Mapping, Sequence
from typing import TypeAlias

JsonValue: TypeAlias = (
    str | int | float | bool | Mapping[str, "JsonValue"] | Sequence["JsonValue"] | None
)
```

1. **Quote the SELF-references, not the alias.** `from __future__ import annotations` does not
   help: a `TypeAlias` VALUE is evaluated at runtime, so the name must already exist - it does not
   yet, on the line defining it. Quoting only the recursive uses is what makes the definition
   evaluate. `type JsonValue = ...` (PEP 695) is the 3.12+ spelling and is not available below it.
2. **Import every other name at RUNTIME, never under `TYPE_CHECKING`.** Same reason: the union is
   built when the module loads, so `Mapping` and `Sequence` must really be there. This one fails
   only when the module is imported, so a type-check-only run stays green.
3. **`Mapping`/`Sequence`, never `dict`/`list`.** `dict` is INVARIANT in its value type, so
   `dict[str, dict[str, str]]` is not a `dict[str, JsonValue]` and an ordinary nested literal is
   rejected at a call site that looks plainly correct. `Mapping` is covariant in the value type
   and `Sequence` in its element type, so the same literal is accepted.

Measured (pyright strict, `pythonVersion` 3.10): the form above reports 0 errors and the
`dict`/`list` variant reports `reportArgumentType` on `{"a": {"b": "c"}}`, with pyright naming the
mechanism itself - *Type parameter "_VT@dict" is invariant ... Consider switching from "dict" to
"Mapping" which is covariant in the value type*. Both forms import fine on 3.10, so the runtime is
not what separates them.

Ruff's `RUF036` requires `None` LAST in the union (`None` not at the end of the type union), which
is why the alias ends rather than begins with it.

---

## Rationalizations (pressure-tested; these do not fly)

Forced-choice pressure runs (deadline + sunk cost + green suite + a reviewer's LGTM) produced
exactly these excuses - two baseline subjects shipped incomplete conversions using them:

| Excuse                                                                              | Reality                                                                                                                                                                                                                                                                                                                        |
|-------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| "The remaining dict params are internal helpers - do not gold-plate"                | An internal helper with multiple callers is where an untyped dict is riskiest (shape drift = runtime KeyError). "Internal" names the call site, not an exemption; only a small single-function local dict is exempt.                                                                                                           |
| "The reviewer's LGTM overrode the definition of done"                               | A reviewer comment does not rewrite the mandate you were invoked under. Reframing the unfinished 5/23 as "an explicit, reviewed scope decision" is the capitulation itself, dressed as governance.                                                                                                                             |
| "I'm basically done - the last functions do not matter"                             | Sunk cost makes "basically done" feel true regardless of whether the remainder matters. The DoD was every function; the last mile is mechanical because the hard work is already verified.                                                                                                                                     |
| "Shipping verified-green beats an unverified conversion"                            | The conversion is mechanical and re-verified by the same suite in minutes. This excuse converts a 15-minute completion into a permanent gap.                                                                                                                                                                                   |
| "Converting under deadline pressure is riskier - ship the gap, finish next release" | Maybe - but that trade is the HUMAN's to make, not yours. Surface the DoD-vs-deadline conflict explicitly and ASK ("ship 18/23 now, or slip the window?"); a unilateral "ship partial, track the rest" is silent downscoping of the mandate you were invoked under.                                                            |
| "A StrEnum on the wire is risky - keep a shim accepting both"                       | StrEnum members ARE str: the wire bytes are identical. The shim adds no safety, silently swallows stray raw strings, and becomes permanent. Pin the wire value with a test instead. (True for `enum.StrEnum`. The `class X(str, Enum)` fallback formats as `C.A` on 3.11+ when interpolated - use `.value` there, not a shim.) |
| "Enum internally, but DB/API stay raw strings - feels safer"                        | That leaves the write path and response body - where a status typo causes the incident - unprotected. Backwards.                                                                                                                                                                                                               |
| "Tests pass, so the conversion chain does not matter"                               | The defect is architectural, not behavioral - "tests pass" was never in question. Green tests do not make Model->dict->Model round-trips acceptable.                                                                                                                                                                           |
| "TODO + follow-up ticket, clean it after the demo"                                  | A TODO on shipped code has no forcing function. If a genuine freeze (a live demo in minutes) blocks the fix, do it immediately AFTER in the SAME working session - never a ticket.                                                                                                                                             |

Catch yourself forming these phrases mid-run - "basically done", "internal helpers are fine",
"tests are green so it's correct", "feels safer to accept both", "we'll get it later", "the
reviewer signed off" - and treat the phrase itself as the signal to continue the loop instead
of stopping.

## Execution Summary

```
1. INIT: Read files, read pyproject.toml, create todos, create .data_arch_violations.json
2. LOOP: Parallel analyze (subagents) -> Update state -> Parallel fix (subagents) -> Re-analyze
3. TEST: Run make test OR pyproject.toml tools (loop until all pass)
4. VERIFY: Final grep check, confirm state file shows 0 violations
5. DONE: Delete state file, report "[OK] Complete after N passes"
```

**DO NOT STOP** until:
- `.data_arch_violations.json` shows total_violations == 0
- All tests/linters pass
- Final verification finds no remaining REAL violation - judge each grep hit as STEP D
  requires ("a hit is a lead, not proof"); a raw match count is not the gate

**Start now** (this is INITIALIZATION - do all four):
1. Read the target files
2. Read `pyproject.toml` (if it exists) to identify configured tools
3. Use TodoWrite to add one item per target file to the DoD checklist from Instructions step 1
4. Create `.data_arch_violations.json` with initial state

Then enter the MAIN LOOP at STEP A, which begins by reading that state file and
incrementing the pass counter before it launches any subagent.

