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
- Pydantic at Boundaries: All external data (API requests, file reads, env vars, CLI args) must be parsed into Pydantic models immediately upon entry
- Pydantic for Export: All outputs (API responses, file writes, serialization) must use Pydantic's
.model_dump()or.model_dump_json() - No Internal Dicts: Inside the application, never use raw dicts for structured data - always typed models
- 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+) orclass X(str, Enum)so Pydantic parses and serializes them without changing the wire format; reserveIntEnumfor values that are genuinely integers on the wire. If your floor is 3.10 and you take theclass X(str, Enum)fallback, its default string form is version-dependent - read "Thestr, Enumfallback formats differently on 3.10 and 3.11+" below before you interpolate a member anywhere. - Minimize Conversions: Ideal flow is ONE parse at input, ONE dump at output - nothing in between
- 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
- 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:
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:
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
# 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
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
@dataclassonly 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 testOR pyproject.toml tools (pytest, ruff, mypy, etc.) - fix all errors until passing - Verify type checking passes and Pydantic validation works
Analyze the Code and identify violations:
- Functions accepting
dictparameters 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, Enumfor string-valued fields andIntEnumonly for integer-valued ones. Avoid unnecessary.valueaccess or conversions - use the enum member directly in comparisons and assignments. The exception is string INTERPOLATION under thestr, Enumfallback, where.valueis required and not optional (see "Thestr, Enumfallback formats differently on 3.10 and 3.11+"). Remove compatibility shims for enums (no wrapper methods, aliases, or backward-compatibility layers)
- Functions accepting
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
@dataclasswhen 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
Enumclasses
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:
- ALWAYS read state file before each step
- ALWAYS write state file after each step
- ALWAYS loop back to STEP A after STEP B - never skip re-analysis
- Do NOT proceed to STEP C until total_violations == 0
- ALWAYS run STEP C - execute
make testor pyproject.toml tools - Do NOT proceed to STEP D until all tests pass
- 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):
def process_user(data: dict) -> dict:
if data["status"] == "active":
return {"name": data["name"], "role": "member"}
After (compliant):
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:
- Add the real annotation or generic argument.
- Wrap a partially-typed third-party symbol in a typed facade you define - a
Protocolplus acast, or a local.pyistub onstubPath- so call sites see complete types. - 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 forreportX = false, a per-file rule-off, anexcludeentry, 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:
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:
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
Protocolalready states the types, so a# pyright: ignore[reportUnknownLambdaType]is proportionate" - theProtocoltypes 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 thedefis 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" -
excludekills 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.
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
)
- Quote the SELF-references, not the alias.
from __future__ import annotationsdoes not help: aTypeAliasVALUE 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. - Import every other name at RUNTIME, never under
TYPE_CHECKING. Same reason: the union is built when the module loads, soMappingandSequencemust really be there. This one fails only when the module is imported, so a type-check-only run stays green. Mapping/Sequence, neverdict/list.dictis INVARIANT in its value type, sodict[str, dict[str, str]]is not adict[str, JsonValue]and an ordinary nested literal is rejected at a call site that looks plainly correct.Mappingis covariant in the value type andSequencein 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.jsonshows 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):
- Read the target files
- Read
pyproject.toml(if it exists) to identify configured tools - Use TodoWrite to add one item per target file to the DoD checklist from Instructions step 1
- Create
.data_arch_violations.jsonwith 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.