Files
autocad-mcp-pro/docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md
T

897 KiB
Raw Blame History

v1.6 Track E — Settings and Environment Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Give a model control of the environment a drawing lives in — ISO-25/ANSI dimension, text and leader styles, a grown drawing_settings facade with a system-variable catalogue, page setup with PDF-verified plotting and five bundled templates, multiple documents, portable layer states, named views and UCS, and (live only) launching, preferences, document properties and operator prompts — as 38 tools, plus the Track A hardening fixes.

Architecture: Three new contract modules (styles, pagesetup, environment), authored standards data under engineering/standards/, a portable layer-state codec under engineering/environment/, three new server.py sections (18 Styles, 19 Page setup & templates, 20 Environment). The ezdxf backend gains a document registry so document_* works headlessly. COM-only members use @capability and both capability maps declare the keys.

Tech Stack: Python 3.11+, FastMCP 3, ezdxf 1.4, pywin32 (fake-tested + live smoke), pytest-asyncio.

Spec: docs/superpowers/specs/2026-09-16-v1.6-settings-design.md (parent docs/superpowers/specs/2026-09-15-v1.6-roadmap-design.md, track E). Track A's plan (docs/superpowers/plans/2026-09-15-v1.6-pid-implementation.md) is the style reference: every task = failing test → run → implement → run → gates → commit.

Global Constraints

  • Run every command through uv run --frozen ... (extras already installed; never uv sync).
  • The user's principle for this track: tool count is not the goal. Every tool: @cad_tool(summary=, cost=) above @mcp.tool(...), a rich alias record (discovery/aliases.py: AutoCAD command names + shop-floor synonyms), a docstring that names its refusals, membership in a pack (core or settings), and an explicit lean decision (§8.2 of the spec). No near-duplicate registrations.
  • Every tool-adding task: regenerate docs/tool-inventory.json (uv run --frozen python scripts/generate_tool_inventory.py), bump the README release-snapshot tool count, keep the SECTION header count right, bump the frozen snapshot in tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged (precedent: Track A tasks 3/4/11/13/14/17), and run the gates (tests/test_discovery_aliases.py, tests/test_tool_inventory.py, tests/test_release_consistency.py, tests/test_tool_registry.py, tests/test_capability_contract.py).
  • Validation before any write; refusals are ValueError/TypeError naming the key — with one standing exception: a symbol name (block, layer, and from S Task 10 dimstyle / textstyle / mleaderstyle) that fails security.sanitize_symbol_name raises ToolError, exactly as the existing block and layer tools do; COM paths fake-tested with the tests/test_block_define.py _Recorder pattern and executed live once by scripts/smoke_settings_com.py (Task 24).
  • Commit per task with the task's message and the two attribution lines (Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>, Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd); use -c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" if git asks for an identity.
  • Worktree groups (see §Execution): tasks inside one group are sequential; groups run in parallel worktrees and are merged by a merge agent that regenerates the three count files. A task must therefore never touch another group's files.

File Structure

Path Responsibility Group
engineering/standards/__init__.py, dimstyles.py, textstyles.py, mleaderstyles.py presets as data, whitelist, resolve_dimstyle(preset, overrides) -> dict, resolve_textstyle(...), resolve_mleaderstyle(...) S
engineering/standards/sysvars.py SYSVAR_CATALOG, describe_sysvar(name), search_sysvars(text), check_sysvar_value(name, value) C
engineering/standards/papers.py PAPER_SIZES, CTB_CATALOG, SCALES, resolve_page_setup(...) -> dict P
engineering/standards/templates.py TEMPLATE_CATALOG, resolve_template(name_or_path, engine) -> (path, source) P
engineering/standards/pdfinfo.py read_mediabox(path) -> (w_mm, h_mm) (pure PDF parse, no dependency) P
engineering/standards/apply.py apply_standard(backend, standard, layers, units) -> dict S
engineering/environment/__init__.py, layer_states.py snapshot ↔ XRECORD chunks codec, diff_snapshot(state, layers) V
backends/contracts/styles.py (new) StylesContract S
backends/contracts/pagesetup.py (new) PageSetupContract P
backends/contracts/environment.py (new) EnvironmentContract V
backends/contracts/settings.py (edit) facade keys C
backends/base.py (edit) compose the three contracts into AutoCADBackend merge task
backends/ezdxf_backend.py, backends/com_backend.py implementations (each group adds its own methods in its own clearly delimited block) all
templates/*.dxf, templates/*.dwt, templates/README.md, scripts/build_templates.py bundled templates P
scripts/smoke_settings_com.py live COM smoke final
server.py SECTION 18 (S), SECTION 19 (P), SECTION 20 (V + C's system_variable_describe, drawing_properties_*); drawing_new(template=) growth (P); drawing_settings docstring (C) per group
discovery/aliases.py 38 records (each group adds its own block, alphabetical within the block) per group
benchmarks/tasks_v4.py, benchmarks/adapters/autocad_mcp_pro.py, benchmarks/correctness_suite.py page_setup_truth, +3 checks final
docs/analysis/track-a-review-backlog.md Track A minor findings, dispositions H
tests: tests/test_standards_presets.py, tests/test_styles.py, tests/test_settings_facade.py, tests/test_sysvar_catalog.py, tests/test_drawing_properties.py, tests/test_page_setup.py, tests/test_templates.py, tests/test_batch_plot.py, tests/test_documents.py, tests/test_layer_states.py, tests/test_views_ucs.py, tests/test_environment_live.py, tests/test_track_a_hardening.py, tests/test_benchmark_v4.py (edit)

Interfaces (the contract every task builds to — copy signatures verbatim)

Styles (backends/contracts/styles.py, group S)

class StylesContract(ABC):
    @abstractmethod
    async def dimstyle_list(self) -> list[dict]: ...
    # [{"name", "current": bool, "values": {DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH,
    #    DIMDEC, DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE}}]
    @abstractmethod
    async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict: ...
    # values: the fully resolved dict from engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)
    # → {"ok": True, "name", "values", "current": bool, "textstyle_created": bool}
    @abstractmethod
    async def dimstyle_modify(self, name: str, values: dict) -> dict: ...
    # → {"ok", "name", "changed": {VAR: [old, new]}, "dimensions_using_style": [handles], "rerender_required": bool}
    @abstractmethod
    async def dimstyle_set_current(self, name: str) -> dict: ...  # → {"ok", "current", "previous"}
    @abstractmethod
    async def textstyle_list(self) -> list[dict]: ...
    # [{"name", "font", "height", "width_factor", "oblique_deg", "current": bool}]
    @abstractmethod
    async def textstyle_create(self, name: str, font: str, height: float = 0.0, width_factor: float = 1.0,
                               oblique_deg: float = 0.0, set_current: bool = False) -> dict: ...
    # → {"ok", "name", "font", "font_resolved": bool, "current"}
    @abstractmethod
    async def textstyle_set_current(self, name: str) -> dict: ...
    @abstractmethod
    async def mleaderstyle_list(self) -> list[dict]: ...  # [{"name", "arrow_size", "landing_gap", "text_style", "text_height"}]
    @capability("mleaderstyle", reason="ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's per-leader parameters")
    async def mleaderstyle_create(self, name: str, values: dict) -> dict: ...  # ezdxf only

engineering/standards/dimstyles.py: PRESETS = {"iso-25": {...}, "ansi": {...}} (values exactly as spec §4.1, DIMDSEP as a one-character string), DIM_VARIABLE_WHITELIST: frozenset, DIM_VARIABLE_RANGES: dict[str, tuple|set], resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict (raises ValueError naming the key on unknown/out-of-range; preset None means the ISO-25 base with overrides), describe_preset(name) -> dict. engineering/standards/textstyles.py: TEXT_PRESETS = {"ISOCP": ("isocp.shx", 1.0, 0.0), "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0), "ARIAL": ("arial.ttf", 1.0, 0.0), "ROMANS": ("romans.shx", 1.0, 0.0)}, resolve_font(font) -> (font_file, known: bool). engineering/standards/mleaderstyles.py: MLEADER_PRESETS = {"iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5}, "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0}}, resolve_mleaderstyle(preset, overrides). engineering/standards/apply.py: async def apply_standard(backend, standard: str, layers: bool = True, units: bool = True) -> dict → {"standard", "dimstyle": {"name", "created", "current": True}, "textstyle": {"name", "created", "current": True}, "settings": {"changed": {VAR: [old, new]}, "applied": bool}, "layers": {"layer_set", "layers": {name: "created"|"exists"|"failed: ..."}} | None} — the shape S Task 12 ships and its tests pin (settings is the two-key dict {changed, applied}, not the bare changed map); also STANDARDS, STANDARD_SYSVARS, resolve_standard(standard) -> (key, row).

Settings (group C)

  • backends/contracts/settings.py: _SETTING_MAP gains limits (kind "limits" → $LIMMIN/$LIMMAX), grid ("bool" → GRIDMODE), grid_spacing ("spacing" → GRIDUNIT, both axes), snap ("bool" → SNAPMODE), snap_spacing ("spacing" → SNAPUNIT), ortho ("bool" → ORTHOMODE), polar ("bit" → AUTOSNAP bit 8 — measured by C Task 14: the F10 toggle is AUTOSNAP bit 8, POLARMODE bit 0 only selects relative polar angles, so the spec's "POLARMODE bit 0" is corrected), polar_angle ("degrees" → POLARANG, stored in radians), psltscale ("bool" → PSLTSCALE), annotation_scale ("scale" → CANNOSCALE "1:50"; CANNOSCALEVALUE 0.02 is derived and read-only), linear_units ("lunits" enum → LUNITS 1–5), angular_units ("aunits" enum → AUNITS 0–4), dimstyle ("name" → dimstyle_set_current), textstyle ("name" → textstyle_set_current). drawing_settings(get) reports every key; ranges in _SETTING_RANGES; parse_scale(text) -> (name, paper, drawing) and scale_value(text) -> float are module-level (the ezdxf backend imports them).
  • engineering/standards/sysvars.py: SYSVAR_CATALOG: dict[str, SysVar] — 90 authored rows (SysVar(name, type: "int"|"float"|"str"|"bool"|"point"|"enum", range: tuple|None, enum: dict|None, default, meaning, saved_in: "drawing"|"registry"|"not_saved", engines: {"ezdxf": bool, "com": bool}, friendly_key: str|None, read_only: bool = False) — read_only is the one field beyond the spec's signature, trailing and defaulted: DIMSTYLE, CANNOSCALEVALUE, UCSNAME, VIEWCTR, VIEWSIZE, DWGNAME, DWGPREFIX are read-only in AutoCAD and check_sysvar_value needs to know), describe_sysvar(name) -> dict (known: False + nearest: [names] on miss), search_sysvars(text) -> list[dict], check_sysvar_value(name, value) -> None | str (message when out of range; None when fine or unknown).
  • server.py: system_variable_describe(name=None, search=None); system_set_variable calls check_sysvar_value first and refuses with its message.
  • backends/contracts/environment.py (this group owns these two members; group V owns the rest of the module — coordinate by adding them in a clearly marked # ── properties ── block):
    @abstractmethod
    async def drawing_properties_get(self) -> dict: ...
    # → {"summary": {"title","subject","author","keywords","comments"} (None each headless), "summary_available": bool, "custom": {k: v}}
    @abstractmethod
    async def drawing_properties_set(self, summary: dict | None = None, custom: dict | None = None) -> dict: ...
    # ezdxf: summary with any non-None field → raise UnsupportedCapabilityError("dwgprops"); custom → header custom_vars (replace keys given; value None deletes)
    # → {"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}

Page setup and templates (backends/contracts/pagesetup.py, group P)

class PageSetupContract(ABC):
    @abstractmethod
    async def page_setup_list(self, layout: str | None = None) -> list[dict]: ...
    # [{"layout", "paper", "size_mm": [w, h], "orientation", "plot_style", "scale", "plot_area", "device", "margins_mm": [t, b, l, r], "center": bool}]
    @abstractmethod
    async def page_setup_apply(self, layout: str, setup: dict) -> dict: ...
    # setup: resolved by engineering.standards.papers.resolve_page_setup(paper, orientation, plot_style, scale, plot_area, device, margins_mm, center)
    # → {"ok", "layout", "applied": {...setup}, "changed": {key: [old, new]}, "plot_style_known": bool}
    @abstractmethod
    async def plot_style_list(self) -> list[dict]: ...  # [{"name", "source": "catalog"|"installed"}]
    @abstractmethod
    async def drawing_template_save(self, path: str, name: str | None = None, description: str | None = None) -> dict: ...
    # ezdxf: suffix .dxf writes; suffix .dwt → raise UnsupportedCapabilityError("dwt_write"); COM: SaveAs(path, acTemplateDwg) for .dwt, DXF export for .dxf
    # → {"ok", "path", "format": "dxf"|"dwt", "backend"}

engineering/standards/papers.py: PAPER_SIZES = {"ISO_A0": (841, 1189), "ISO_A1": (594, 841), "ISO_A2": (420, 594), "ISO_A3": (297, 420), "ISO_A4": (210, 297), "ANSI_A": (216, 279), "ANSI_B": (279, 432), "ANSI_C": (432, 559), "ANSI_D": (559, 864), "ANSI_E": (864, 1118)} (portrait w×h; landscape swaps), CTB_CATALOG = ("monochrome.ctb", "acad.ctb", "Grayscale.ctb", "Screening 100%.ctb", "Screening 75%.ctb", "Screening 50%.ctb", "Screening 25%.ctb", "Fill Patterns.ctb", "DWF Virtual Pens.ctb"), SCALES = {"fit": None, "1:1": 1.0, "1:2": 0.5, "1:5": 0.2, "1:10": 0.1, "1:20": 0.05, "1:50": 0.02, "1:100": 0.01, "2:1": 2.0, "5:1": 5.0, "10:1": 10.0}, resolve_page_setup(paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True) -> dict (raises ValueError naming the field), canonical_media_name(paper, orientation) -> str ("ISO_A3_(420.00_x_297.00_MM)" — AutoCAD's spelling: landscape A3 is 420×297). engineering/standards/pdfinfo.py: read_mediabox(path) -> tuple[float, float] in mm (parses the first /MediaBox [x0 y0 x1 y1], points → mm ×25.4/72; raises ValueError when absent). engineering/standards/templates.py: TEMPLATE_CATALOG (five rows as spec §6), resolve_template(name_or_path: str, engine: str) -> tuple[str, str] ((path, "bundled"|"path"); bundled = templates/<name>.dxf for ezdxf, templates/<name>.dwt for com, falling back to the .dxf with source: "bundled_dxf" when the .dwt is missing), TEMPLATES_DIR. Server-level compositions in engineering/standards/plot.py: async def batch_plot(backend, layouts, output_dir, file_pattern="{drawing}-{layout}.pdf") -> dict → {ok, drawing, output_dir, format: "pdf", sheets: [{layout, ok, path, bytes, mediabox_mm: [w, h] | None, paper: str | None, error?}], count} — the per-sheet list is under sheets (P Task 18's key, pinned by its tests and tool docstring; F Task 25 reads report["sheets"][0]), each row's mediabox_mm from read_mediabox; safe_filename(text) -> str.

Environment (backends/contracts/environment.py, group V)

class EnvironmentContract(ABC):
    @abstractmethod
    async def document_list(self) -> list[dict]: ...  # [{"name", "path", "active": bool, "saved": bool, "entity_count"}]
    @abstractmethod
    async def document_activate(self, name_or_path: str) -> dict: ...  # → {"ok", "active", "previous"}
    @abstractmethod
    async def document_close(self, name_or_path: str | None = None, save: bool = False, discard: bool = False) -> dict: ...
    # unsaved and not save and not discard → ValueError naming the document; → {"ok", "closed", "saved": bool, "active": name|None}
    @abstractmethod
    async def layer_state_save(self, name: str, description: str | None = None) -> dict: ...  # → {"ok", "name", "layer_count", "replaced"}
    @abstractmethod
    async def layer_state_restore(self, name: str, properties: list[str] | None = None) -> dict: ...
    # properties ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"}; → {"ok", "name", "applied", "missing_layers", "new_layers"}
    @abstractmethod
    async def layer_state_list(self) -> list[dict]: ...  # [{"name", "description", "layer_count"}]
    @abstractmethod
    async def layer_state_delete(self, name: str) -> dict: ...
    @abstractmethod
    async def view_named_save(self, name: str, center=None, height: float | None = None, width: float | None = None) -> dict: ...
    # → {"ok", "name", "center": [x, y], "height", "width", "replaced"}
    @abstractmethod
    async def view_named_restore(self, name: str) -> dict: ...  # ezdxf: sets $VIEWCTR/$VIEWSIZE, "applied": "header_only"
    @abstractmethod
    async def view_named_list(self) -> list[dict]: ...
    @abstractmethod
    async def ucs_list(self) -> list[dict]: ...  # [{"name", "origin", "x_axis", "y_axis", "current": bool}] + the implicit "world"
    @abstractmethod
    async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict: ...  # non-orthogonal → ValueError with the measured angle
    @abstractmethod
    async def ucs_restore(self, name: str) -> dict: ...  # "world" resets
    @capability("live_application", reason="only a live CAD application can be launched or attached")
    async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict: ...
    # → {"launched": bool, "attached": bool, "version", "document"}
    @capability("preferences", reason="AutoCAD preferences live in the running application, not in a file")
    async def preferences_get(self, keys: list[str] | None = None) -> dict: ...  # → {"values": {key: value}, "read_only": [keys]}
    @capability("preferences", reason=...)
    async def preferences_set(self, key: str, value) -> dict: ...  # → {"ok", "key", "old", "new"}
    @capability("interactive_prompt", reason="prompting the operator needs a live AutoCAD session")
    async def user_pick_point(self, prompt: str) -> dict: ...  # → {"cancelled": bool, "x", "y"} | {"timed_out": True}
    @capability("interactive_prompt", reason=...)
    async def user_select(self, prompt: str, mode: str = "single") -> dict: ...  # → {"cancelled", "handles": [...]}
    @capability("interactive_prompt", reason=...)
    async def system_prompt_message(self, text: str) -> dict: ...  # → {"ok"}

engineering/environment/layer_states.py: DICT_NAME = "ACADMCP_LAYERSTATES", PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current"), snapshot_from_layers(layers: list[LayerInfo], current_layer: str, *, plot: dict[str, bool] | None = None, description: str | None = None) -> dict (the two keyword-only extensions V Task 22 ships: LayerInfo carries no plot flag, so the engines read layer.dxf.plot / Plottable themselves and pass it in), encode_state(state: dict) -> list[str] (JSON, ASCII-escaped chunks ≤ 255), decode_state(chunks) -> dict (ValueError on anything that is not a version-1 state), diff_snapshot(state, layers) -> (missing, new), validate_properties(properties) -> tuple[str, ...], validate_state_name(name) -> str. Preference whitelist (COM): PREFERENCE_KEYS = {"OpenSave.SaveAsType": ("enum", {...}), "OpenSave.AutoSaveInterval": ("int", (0, 600)), "OpenSave.CreateBackup": ("bool", None), "OpenSave.IncrementalSavePercent": ("int", (0, 100)), "Display.CursorSize": ("int", (1, 100)), "Drafting.AutoSnapMarkerSize": ("int", (1, 20)), "Drafting.AutoSnapTooltip": ("bool", None), "Selection.PickBoxSize": ("int", (0, 50)), "Output.DefaultPlotStyleTable": ("str", None), "Output.DefaultOutputDevice": ("str", None)} and READ_ONLY_KEYS = ("Files.SupportPath", "Files.TemplateDwgPath", "Files.PrintStyleSheetPath", "Files.PrinterConfigPath") in engineering/environment/preferences.py (pure data + validate_preference(key, value)).

Capability keys (both maps, every group's task that introduces one)

mleaderstyle (COM False), dwgprops (ezdxf False), dwt_write (ezdxf False), live_application, preferences, interactive_prompt (ezdxf False, COM True). tests/test_capability_contract.py fails until both maps agree. Map-only feature entries (no decorator): registry_sysvar (C; ezdxf False), documents, named_views, ucs (V; True on both) and layer_states (V; True on both), whose spelling is pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads and quoted by the README and CLAUDE.md, so V writes it verbatim in both maps: FeatureCapability(True, "xrecord", reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager").

Packs and profile (final task)

PACK_TOOL_NAMES["settings"] = the 22 SECTION 20 tools; LEAN_TOOL_NAMES += drawing_apply_standard, page_setup_apply, batch_plot, dimstyle_set_current, textstyle_set_current; TOOL_PACK_NAMES = ("core", "pid", "settings").

Execution

Group Tasks Worktree Files it owns
H (hardening) 1–8 track-e/hardening the files each fix names + tests/test_track_a_hardening.py + docs/analysis/track-a-review-backlog.md; Task 6 also moves the two assertion lines of benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip (the Final group's file — the fix changes the truth the adapter asserts; named in H's commit message, and F Task 25 appends _task_page_setup_truth directly after that method, so the merge is additive)
S (styles) 9–13 track-e/styles engineering/standards/{__init__,dimstyles,textstyles,mleaderstyles,apply}.py, backends/contracts/styles.py, backend blocks # ── styles (track E) ──, server.py SECTION 18, alias block, tests/test_standards_presets.py, tests/test_styles.py; Task 13 also appends five tuning cases to tests/data/discovery_golden.py (docstring → Seventy-one cases (56 tuning + 15 holdout)) — a Final-group file, so F Task 24 appends its ten after S's five and writes Eighty-one cases (66 tuning + 15 holdout)
C (settings) 14–16 track-e/settings backends/contracts/settings.py, engineering/standards/sysvars.py, properties members, server.py system_variable_describe/drawing_properties_* in SECTION 20 stub + system_set_variable check, tests/test_settings_facade.py, tests/test_sysvar_catalog.py, tests/test_drawing_properties.py
P (page setup, templates) 17–20 track-e/pagesetup engineering/standards/{papers,pdfinfo,templates,plot}.py, backends/contracts/pagesetup.py, backend blocks # ── page setup (track E) ──, templates/, scripts/build_templates.py, server.py SECTION 19 + drawing_new template resolution, tests/test_page_setup.py, tests/test_templates.py, tests/test_batch_plot.py
V (environment) 21–23 track-e/environment backends/contracts/environment.py (whole module; C's two members are added here too, C only implements them), engineering/environment/, backend blocks # ── environment (track E) ──, server.py SECTION 20, tests/test_documents.py, tests/test_layer_states.py, tests/test_views_ucs.py, tests/test_environment_live.py
Final 24–26 main backends/base.py composition (if not already merged), packs/lean, aliases golden queries, benchmark + correctness, live smoke, docs, gates

Because C and V both need EnvironmentContract: V creates the module with every member including drawing_properties_*; C adds the two implementations to both backends and registers its three tools (system_variable_describe in Task 15, which opens the stub at (1 tool), then drawing_properties_get/set in Task 16) under a # ── SECTION 20: Environment (3 tools) ── header that V's merge extends to the full count (V ends at (19 tools); merged (22 tools), resolved by F Task 24 Step 3.3, which moves all three C blocks under V's header). The merge agent resolves the header count and the three count files. backends/base.py composition of the three new contracts is done by whichever group merges first (each group's first task adds its own contract to the AutoCADBackend bases if absent — an idempotent one-line edit).

Merge notes (binding for every group and for the merge agent)

Double-owned members — exactly two, no others. (1) drawing_template_save: P Task 17 declares it (@abstractmethod in PageSetupContract) and gives both backends a stub that raises UnsupportedCapabilityError("dwt_write", "drawing_template_save: not implemented yet (Task 20)"); P Task 20 replaces both stubs with the implementations. Same group, sequential tasks — nothing for the merge agent. (2) drawing_properties_get / drawing_properties_set: V Task 21 declares them in EnvironmentContract under a temporary @capability("dwgprops", reason="drawing properties arrive with group C's merge") (so V's worktree instantiates both backends without C's code); C Task 16 implements them on both backends. The merge agent flips V's two decorators to @abstractmethod (deleting V's # TEMPORARY comment block) once C's implementations are present, and keeps exactly one dwgprops entry per capability map — C's (V's line is byte-identical to C's by construction, see below, and is deleted together with the decorators; a repeated dict key is ruff F601). Every other contract member has one declaring task and one implementing task.

One _active_vport. EzdxfBackend._active_vport(doc) (staticmethod: the *Active VPORT entry, created when absent) is defined by C Task 14 in the # ── settings (track E, group C) ── block — grid/snap live on that entry — and C's definition is the one that ships. V Task 22 needs the same helper for view_named_* and its worktree has no C code, so V carries a byte-equivalent copy marked # TEMPORARY (track-e/environment worktree only); the merge agent deletes V's copy (two definitions in one class are ruff F811, and the later one silently wins). V's callers (self._active_vport(doc)) need no change.

Method-block anchors in backends/ezdxf_backend.py and backends/com_backend.py — one distinct, non-adjacent position per group, so the four groups never touch the same lines:

Group Block banner ezdxf anchor COM anchor
S # ── styles (track E) ── immediately before # ── corner ops (i.e. directly after system_run_lisp) immediately before # ── corner ops
C # ── settings (track E, group C) ── at the very end of the class — after _create_xline, the file's last method (below the # ── premium meta-tools live on AutoCADBackend … banner) at the very end of the class, after _create_xline
V # ── environment (track E) ── immediately after the # ── layouts / paper space ── block, i.e. directly before # ── viewports ── (after layout_copy) immediately after the # ── layouts / paper space ── block, i.e. directly before # ── selection filters (M8 / F1) ── (after layout_copy)
P # ── page setup (track E) ── immediately before # ── 3D solids (unsupported headlessly …) (after the change-space block) immediately before # ── 3D solids (native ActiveX; …) (after the viewports block)

C's in-place replacement of system_get_variable / system_set_variable (Task 14) and V's two-key edit inside system_status (Task 21) are both in the # ── system ── section but 15+ lines apart; S's block starts after system_run_lisp, 20+ lines below C's replacement. Later tasks of a group append inside that group's own block (C Task 16 after _set_annotation_scale; V Tasks 22/23 after document_close; P Tasks 18/20 inside the page-setup block).

Capability-map entries (features dict of capabilities(), both backends). In each worktree the only shared anchor that exists is the "lisp" entry, so S, C and V each insert directly after "lisp" there and the merge agent resolves that one hunk by concatenating in this order, by key name — S's entries directly after "lisp", C's after S's last key, V's after C's last key: "lisp" → S "mleaderstyle" → C "registry_sysvar", "dwgprops" → V "documents", "layer_states", "named_views", "ucs", "live_application", "preferences", "interactive_prompt" (V's own "dwgprops" line dropped, see above). P anchors after an existing key nobody else touches — "paper_space" — so its "dwt_write" entry never conflicts. The two dwgprops lines are byte-identical: V Task 21 copies C Task 16's FeatureCapability(False, reason="summary_fields_need_live_autocad;custom_properties_supported") (ezdxf) and FeatureCapability(True, "native") (COM).

Textual overlaps that are conflicts but not decisions. server.py: every group inserts its SECTION immediately before the # ── RESOURCES banner (after pid_tag_parse) — merged order is SECTION 18 (S) → SECTION 19 (P) → SECTION 20 (C's three blocks folded under V's header, F Task 24 Step 3.3); in discovery/aliases.py the S, P and V alias blocks all land after the P&ID block at the end of TOOL_ALIASES (merged order S → P → V, each block alphabetical within itself), while C edits existing records in place (drawing_settings, system_variable_describe next to system_set_variable, the DWGPROPS line of SHARED_ACAD_COMMANDS); the SHARED_ACAD_COMMANDS tuple gains S's DIMSTYLE / MLEADERSTYLE / STYLE, P's PAGESETUP / PLOT / SAVEAS, C's DWGPROPS and V's CLOSE / LAYERSTATE / OPTIONS / UCS / VIEW — alphabetical position each, so the hunks interleave but do not contradict. README "What you get" table: P Task 19 inserts a Templates row after Paper space and V Task 21 inserts an Environment row after Paper space — same insertion point; merged order is Paper space → Templates → Environment (F Task 26 later adds the Styles row and the final Environment wording). .gitattributes: P Task 19's templates/*.dwt binary and F Task 25's *.dwt binary overlap harmlessly — keep both. tests/test_tool_registry.py snapshot, README.md tool count, docs/tool-inventory.json and the SECTION 20 header count are the three-plus-one count files the merge agent re-derives (F Task 24 pins the final numbers: 204 tools, 23 groups, (22 tools)). tests/data/discovery_golden.py: S Task 13's five cases land first, F Task 24's ten after them.


v1.6 Track E — Group H: Track A hardening (Tasks 1–8)

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Group: H (hardening) · Worktree: track-e/hardening · Parent plan: docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md (its Global Constraints, File Structure, Interfaces and Execution sections are binding here) · Spec: docs/superpowers/specs/2026-09-16-v1.6-settings-design.md §2 (wave 0) · Baseline: main at ab258d1 (166 tools, 2423 collected tests).

Goal: Close the seven Track A review findings that are real defects — on both engines, each refused before any write and pinned by a test — and file every remaining minor finding with a disposition.

Architecture: No new tools, no new contract members except one optional kwarg (block_define(create_layers=False)). Shared validation lives in backends/block_specs.py (both engines import it); the P&ID fixes live in engineering/pid/{lines,drawlines,spec}.py. Headless tests run against a real ezdxf document; COM against a recording fake ActiveX surface (tests/test_block_define.py::_Recorder pattern, grown into _FakeObject / _FakeBlocks / _FakeLayers / _FakeSpace in the new test module).

Tech Stack: Python 3.11+, ezdxf 1.4.4, pywin32 (fake-tested), pytest-asyncio.

Group constraints (in addition to the parent plan's Global Constraints)

  • Run everything through uv run --frozen ....
  • Every fix: refusal is measured (the plan quotes the probe), named (ValueError/TypeError naming the key or path), raised before any write, and tested on both engines.
  • No file owned by another group is edited. The one deliberate exception is recorded in Task 6 (benchmarks/adapters/autocad_mcp_pro.py, owned by the final group): the fix changes the truth that adapter asserts, so the adapter's two assertion lines move with it; the merge agent is told in the commit message.
  • The README collected tests figure is regenerated by the merge agent (parent plan, Execution §), so every gate step below deselects tests/test_release_consistency.py::test_document_numbers_match_the_tree; it fails only on that figure and every other test in the file must pass.
  • Tool steps keep the user's principle: the touched tools keep their alias record (grown where the fix adds vocabulary), their docstring names the refusals, and pack / lean membership is stated (unchanged in every task here: block_* and entity_create_block_ref are core and not lean; pid_line_draw is pid and lean; pid_from_spec is pid and not lean).
  • Commit per task with the message given and the two attribution lines:
    Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd
    
    (-c user.name="Umut CAN" -c user.email="umutcan.edizaslan@gmail.com" if git asks for an identity.)

Measured defects (2026-09-16, ezdxf 1.4.4, main ab258d1)

# Probe Result on main
1 await b.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2) handle 33, block_name 'NO_SUCH_BLOCK', modelspace 0→1; doc.audit() reports 0 errors and the modelspace is empty afterwards (the INSERT is silently removed). block_insert("*Model_Space", 0, 0) → handle 35 (a reference cycle).
2 insert TB with {"TAG": "P-101"}, block_explode(handle) inserted_handles == [LINE] only — the value P-101 is gone. ent.virtual_entities() yields ['LINE'] (no ATTRIB, no ATTDEF).
3 block_insert("TB", 0, 0, attributes={"TAG": None}) block_get_attributes == {"TAG": "None"}; attributes=[("TAG","x")] → AttributeError: 'list' object has no attribute 'items' after add_blockref wrote the INSERT.
4 block_define("PID_NAN", CIRC, base_x=nan, base_y=inf) ok: True, BLOCK base point Vec3(nan, inf, 0). A primitive on NOT_A_LAYER is written with no layer-table entry; doc.audit() 0 errors.
5 run_spec with equipment[0].x = nan INSERT placed at [nan, 0]; with draw_line monkeypatched to raise asyncio.CancelledError: 5 entities left, transaction_depth 1. _num(nan) returns nan.
6 pneumatic line from a bubble line_number "1", a "1" label on PROCESS-LINE-TEXT, next process line is seq 2. Bubble at (100,160) with route=[[130,160],[130,100]] → first vertex [100,155] (exit towards the far end), first segment 4.93 mm from the bubble centre (r = 5).
7 payload {"seq": True, "from": "garbage", "to": None} on a P&ID-layer polyline, then draw_line TypeError: string indices must be integers, not 'str'. A bulge −1 semicircle (0,0)→(100,0) and a utility line at x=30, y 20→60: crossings 0 (the chord never crosses; the arc does).

Task 1: entity_create_block_ref refuses an undefined (or layout) block before writing, on both engines

The Track A fix (6f4bda5) gated block_insert only; its sibling entity_create_block_ref still returns a handle for a typo'd name and doc.audit() later deletes the INSERT. Both engines get one shared gate per engine, used by both placing tools, and the gate also refuses the layout blocks *Model_Space / *Paper_Space (they are in doc.blocks; inserting one is the reference cycle AutoCAD's INSERT refuses).

Files:

  • Modify: backends/ezdxf_backend.py (new _require_block after _require_doc ≈ line 1085; entity_create_block_ref ≈ line 2993; block_insert ≈ line 4468)
  • Modify: backends/com_backend.py (new module function _require_block_defined before _BUILTIN_LINETYPES ≈ line 195; entity_create_block_ref ≈ line 2471; block_insert ≈ line 3239)
  • Modify: server.py (entity_create_block_ref docstring ≈ line 1660)
  • Create: tests/test_track_a_hardening.py

Interfaces:

  • Produces: EzdxfBackend._require_block(name) -> BlockLayout and com_backend._require_block_defined(doc, name) -> Block. Both raise TypeError("block name must be a non-empty string"), ValueError(f"block {name!r} is not defined") (the message tests/test_block_insert_attributes.py already pins) or ValueError(f"block {name!r} is a layout block and cannot be inserted"), before any write.

  • Consumes: ezdxf BlockLayout.block_record.is_any_layout (measured: True for *Model_Space, doc.blocks membership is case-insensitive); ActiveX Blocks.Item(name) raises for an unknown name, Block.IsLayout.

  • Step 1: Write the failing tests (the module's fakes serve Tasks 1–4)

# tests/test_track_a_hardening.py
"""Track A hardening (v1.6 track E, wave 0): the block-tool defects the Track A
review found, fixed on both engines.

Headless tests run against a real ezdxf document; the live engine runs against
a fake ActiveX surface that records every call, so the tests assert the call
shape (what is dispatched, in which order, and — for a refusal — that nothing
is dispatched at all).
"""

from __future__ import annotations

import types

import pytest

from backends.base import EntityInfo

pytestmark = pytest.mark.asyncio


def _define_tagged_block(backend, name: str = "TB") -> None:
    blk = backend._doc.blocks.new(name=name)
    blk.add_line((0, 0), (10, 0))
    blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})


# ── fake ActiveX surface ──────────────────────────────────────────────────────


class _FakeObject:
    """An ActiveX object with only the members it is given; property writes are recorded."""

    def __init__(self, object_name: str, handle: str, **members):
        object.__setattr__(self, "ObjectName", object_name)
        object.__setattr__(self, "Handle", handle)
        object.__setattr__(self, "writes", [])
        object.__setattr__(self, "deleted", False)
        for name, value in members.items():
            object.__setattr__(self, name, value)

    def __setattr__(self, name, value):
        self.writes.append((name, value))
        object.__setattr__(self, name, value)

    def __getattr__(self, name):
        raise AttributeError(name)

    def Delete(self):
        object.__setattr__(self, "deleted", True)


class _FakeBlock:
    def __init__(self, name: str, is_layout: bool = False):
        self.Name = name
        self.IsLayout = is_layout
        self.Count = 0
        self.calls: list[tuple[str, tuple]] = []

    def __getattr__(self, attr):
        if attr.startswith("Add"):

            def _call(*args):
                self.calls.append((attr, args))
                return _FakeObject("AcDbEntity", f"M{len(self.calls)}")

            return _call
        raise AttributeError(attr)


class _FakeBlocks:
    def __init__(self, *names: str):
        self.blocks: dict[str, _FakeBlock] = {
            "*Model_Space": _FakeBlock("*Model_Space", is_layout=True),
        }
        for name in names:
            self.blocks[name] = _FakeBlock(name)

    def Add(self, base_point, name):
        self.blocks[name] = _FakeBlock(name)
        self.blocks[name].base_point = tuple(base_point.value)
        return self.blocks[name]

    def Item(self, name):
        if name not in self.blocks:
            raise RuntimeError(f"no block {name}")
        return self.blocks[name]


class _FakeLayers:
    def __init__(self, *names: str):
        self.names = {"0", *names}
        self.added: list[str] = []

    def Item(self, name):
        if name not in self.names:
            raise RuntimeError(f"no layer {name}")
        return types.SimpleNamespace(Name=name)

    def Add(self, name):
        self.names.add(name)
        self.added.append(name)
        return types.SimpleNamespace(Name=name)


class _FakeSpace:
    """The active layout block: records InsertBlock / AddText and hands back styled objects."""

    def __init__(self, attributes: dict[str, str] | None = None):
        self.calls: list[tuple[str, tuple]] = []
        self.attributes = attributes or {}
        self.inserted: list[_FakeObject] = []

    def InsertBlock(self, point, name, sx, sy, sz, rotation):
        self.calls.append(("InsertBlock", (tuple(point.value), name, sx, sy, sz, rotation)))
        attrs = tuple(
            _FakeObject("AcDbAttribute", f"A{i}", TagString=tag, TextString=value)
            for i, (tag, value) in enumerate(self.attributes.items())
        )
        ref = _FakeObject(
            "AcDbBlockReference", f"R{len(self.inserted)}", Name=name, GetAttributes=lambda: attrs
        )
        self.inserted.append(ref)
        return ref

    def AddText(self, text, point, height):
        self.calls.append(("AddText", (text, tuple(point.value), height)))
        return _FakeObject("AcDbText", f"T{len(self.calls)}")


@pytest.fixture
def com(monkeypatch):
    """A ComBackend over a recording fake: ``document`` and ``space`` are inspectable."""
    from backends import com_backend as module

    document = types.SimpleNamespace(Blocks=_FakeBlocks("TB"), Layers=_FakeLayers(), objects={})
    document.HandleToObject = lambda handle: document.objects[handle]
    space = _FakeSpace()
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    monkeypatch.setattr(module, "_msp", lambda: space)
    monkeypatch.setattr(module, "_regen", lambda: None)
    # The real converter reads a dozen ActiveX members (bounding box, block
    # geometry, ...); the fixture only needs the handle and the name back.
    monkeypatch.setattr(
        module,
        "_entity_info",
        lambda ent: EntityInfo(
            handle=ent.Handle,
            type="INSERT",
            layer="0",
            color=256,
            linetype="ByLayer",
            visible=True,
            properties={"block_name": ent.Name},
        ),
    )
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document, space


# ── Task 1: entity_create_block_ref refuses an undefined block ────────────────


async def test_block_ref_of_an_undefined_block_is_refused_before_writing(backend):
    before = len(list(backend._msp()))
    with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
        await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2)
    assert len(list(backend._msp())) == before, "a refused insert must not write"
    assert "NO_SUCH_BLOCK" not in backend._doc.blocks


async def test_block_ref_of_a_defined_block_still_inserts(backend):
    _define_tagged_block(backend)
    ref = await backend.entity_create_block_ref("TB", 5, 5, rotation=90)
    info = await backend.entity_get(ref.handle)
    assert info.type == "INSERT" and info.properties["block_name"] == "TB"
    assert info.properties["rotation_deg"] == pytest.approx(90.0)


@pytest.mark.parametrize("name", ["*Model_Space", "*Paper_Space"])
async def test_layout_blocks_are_refused_by_both_placing_tools(backend, name):
    with pytest.raises(ValueError, match="layout block"):
        await backend.entity_create_block_ref(name, 0, 0)
    with pytest.raises(ValueError, match="layout block"):
        await backend.block_insert(name, 0, 0)
    assert backend._doc.audit().errors == [], "nothing was written, nothing to fix"


async def test_a_non_string_block_name_is_a_type_error_on_both_placing_tools(backend):
    with pytest.raises(TypeError, match="non-empty string"):
        await backend.entity_create_block_ref(None, 0, 0)
    with pytest.raises(TypeError, match="non-empty string"):
        await backend.block_insert("", 0, 0)


async def test_com_block_ref_probes_the_block_table_before_insert_block(com):
    backend, document, space = com
    with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
        await backend.entity_create_block_ref("NO_SUCH_BLOCK", 1, 2)
    assert space.calls == [], "InsertBlock must not be dispatched for an unknown name"
    with pytest.raises(ValueError, match="layout block"):
        await backend.entity_create_block_ref("*Model_Space", 0, 0)
    assert space.calls == []

    ref = await backend.entity_create_block_ref("TB", 1, 2, 2.0, 2.0, 90.0)
    assert ref.properties["block_name"] == "TB"
    assert space.calls == [
        ("InsertBlock", ((1.0, 2.0, 0.0), "TB", 2.0, 2.0, 1.0, pytest.approx(1.5708, abs=1e-4)))
    ]


async def test_com_block_insert_shares_the_same_gate(com):
    backend, document, space = com
    with pytest.raises(ValueError, match="'NO_SUCH_BLOCK' is not defined"):
        await backend.block_insert("NO_SUCH_BLOCK", 1, 2, attributes={"TAG": "P-1"})
    assert space.calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_track_a_hardening.py -v Expected: 6 FAIL, 1 PASS. test_block_ref_of_an_undefined_block_is_refused_before_writing, the two test_layout_blocks_… cases and test_com_block_ref_… fail with Failed: DID NOT RAISE ValueError (ezdxf returns a handle; the COM fake's InsertBlock is dispatched and recorded); test_a_non_string_block_name_… fails with ezdxf.lldxf.const.DXFValueError: Block name as string required instead of TypeError; test_com_block_insert_shares_the_same_gate fails DID NOT RAISE (the fake's InsertBlock accepts any name). test_block_ref_of_a_defined_block_still_inserts passes.

  • Step 3: ezdxf — one gate for both placing tools

In backends/ezdxf_backend.py, insert after _require_doc (directly before def _msp(self):):

    def _require_block(self, name):
        """The block definition ``name`` names, or a refusal before any write.

        ``add_blockref`` never checks the name and ``add_auto_attribs`` skips
        autofill when ``Insert.block()`` resolves to None, so a typo'd name
        used to yield a dangling INSERT that dropped its attribute values and
        that ``doc.audit()`` later deletes. A layout block (``*Model_Space``,
        ``*Paper_Space``) is in ``doc.blocks`` too, but inserting one is the
        reference cycle AutoCAD's INSERT refuses, so it is refused here by
        name. Shared by ``block_insert`` and ``entity_create_block_ref`` so the
        two tools that place a block reference agree on what a typo does.
        """
        doc = self._require_doc()
        if not isinstance(name, str) or not name.strip():
            raise TypeError("block name must be a non-empty string")
        if name not in doc.blocks:
            raise ValueError(f"block {name!r} is not defined")
        blk = doc.blocks.get(name)
        if blk.block_record.is_any_layout:
            raise ValueError(f"block {name!r} is a layout block and cannot be inserted")
        return blk

In entity_create_block_ref, add the gate as the first statement after msp = self._msp():

        def _sync():
            msp = self._msp()
            self._require_block(name)
            ent = msp.add_blockref(

In block_insert, replace the inline check

            # `add_blockref` never checks the name, and `add_auto_attribs`
            # silently skips autofill when `Insert.block()` resolves to None,
            # so a typo'd name used to yield a dangling INSERT that dropped its
            # attribute values and that `doc.audit()` later deletes. Refuse
            # before writing, as COM's `InsertBlock` does for an unknown name.
            if name not in self._doc.blocks:
                raise ValueError(f"block {name!r} is not defined")
            ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs)

with

            self._require_block(name)
            ref = msp.add_blockref(name, (float(x), float(y)), dxfattribs=attribs)
  • Step 4: COM — the same gate as a module function

In backends/com_backend.py, insert before _BUILTIN_LINETYPES = {"continuous", "bylayer", "byblock"}:

def _require_block_defined(doc, name):
    """``doc.Blocks.Item(name)``, or a ``ValueError`` before ``InsertBlock`` runs.

    ``InsertBlock`` with an unknown name raises a bare COM error after the call
    has already been dispatched; refusing here names the block, costs one
    ``Blocks.Item`` probe and matches the headless engine's message. A layout
    block (``IsLayout``) cannot be inserted into itself — same refusal by name.
    Shared by ``block_insert`` and ``entity_create_block_ref``.
    """
    if not isinstance(name, str) or not name.strip():
        raise TypeError("block name must be a non-empty string")
    try:
        block = doc.Blocks.Item(name)
    except Exception as exc:
        raise ValueError(f"block {name!r} is not defined") from exc
    try:
        is_layout = bool(block.IsLayout)
    except Exception:
        is_layout = False
    if is_layout:
        raise ValueError(f"block {name!r} is a layout block and cannot be inserted")
    return block

In both entity_create_block_ref and block_insert, make the gate the first statement of _sync (before mspace = _msp()):

        def _sync():
            _require_block_defined(_acad_doc(), name)
            mspace = _msp()
            ref = mspace.InsertBlock(
  • Step 5: The tool docstring names the refusals

In server.py, entity_create_block_ref — replace

    """Insert a block reference (instance of an existing block definition)."""
    await ctx.debug(f"Inserting block '{name}' at ({x},{y})")

with

    """Insert a block reference (instance of an existing block definition).

    Refused before any write, on both engines: a name that is not a block
    definition in this drawing (`block 'X' is not defined` — check
    `block_list`), a layout block (`*Model_Space` / `*Paper_Space`, a
    reference cycle) and a non-string name. The same gate as `block_insert`;
    this tool is the no-attributes form.
    """
    await ctx.debug(f"Inserting block '{name}' at ({x},{y})")

Alias record: entity_create_block_ref already carries acad=("INSERT",) (shared with block_insert and pid_symbol_insert through SHARED_ACAD_COMMANDS) and four synonyms — unchanged. Pack core, not lean — unchanged.

  • Step 6: Run the tests and their neighbours

Run: uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_insert_attributes.py tests/test_block_define.py tests/test_layer_block.py tests/test_pid_insert.py -v Expected: all PASS (7 in the new module; test_insert_of_undefined_block_is_refused_before_writing keeps matching 'NO_SUCH_BLOCK' is not defined).

  • Step 7: Gates

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: ruff clean; all PASS. (The deselected test fails only on the README collected tests figure, which the merge agent regenerates.)

  • Step 8: Commit
git add backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_track_a_hardening.py
git commit -m "fix(blocks): entity_create_block_ref refuses an undefined or layout block before writing, on both engines

Track A's task-1 gate covered block_insert only; the sibling tool still
returned a handle for a typo'd name that doc.audit() later deleted, and
both tools accepted *Model_Space (a reference cycle). One gate per engine
(_require_block / _require_block_defined) is now shared by both placing
tools: non-string name -> TypeError, unknown -> ValueError naming it,
layout block -> ValueError, all before add_blockref / InsertBlock.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 2: block_explode keeps ATTRIB text on both engines (the BURST rule)

Measured: ezdxf Insert.virtual_entities() yields the definition's geometry only (['LINE'] for a block with a LINE and an ATTDEF), so the exploded symbol lost its tag value. ezdxf's own explode_block_reference documents the intended rule — "attached ATTRIB entities are converted to TEXT entities, this is the behavior of the BURST command of the AutoCAD Express Tools" — and reuses the ATTRIB's handle for the TEXT; the implementation below builds the TEXT explicitly (msp.add_text with the ATTRIB's WCS insert/align_point, height, rotation, style, layer, halign/valign) so no handle is recycled, and marks an invisible ATTRIB's TEXT invisible (dxf.invisible = 1) instead of making hidden data appear. Measured on the prototype: alignment CENTER survives, doc.audit() reports 0 errors, the TEXTs survive a write/read round trip.

On COM the brief's premise ("Explode already returns ATTDEF-derived TEXT") does not match AutoCAD's documented behaviour: EXPLODE keeps the attribute definitions (ATTDEF placeholders showing the tag name) and discards the values, and ActiveX Explode() additionally leaves the original reference in place. So the COM path applies the same BURST rule explicitly: read GetAttributes() before exploding, delete every AcDbAttributeDefinition the explode returns, AddText one TEXT per captured ATTRIB, delete the reference. The fake asserts exactly that call shape; the final group's live smoke (scripts/smoke_settings_com.py, Task 24) runs it once against AutoCAD.

Files:

  • Modify: backends/ezdxf_backend.py (block_explode ≈ line 4507)
  • Modify: backends/com_backend.py (block_explode ≈ line 3275)
  • Modify: server.py (block_explode docstring ≈ line 3018)
  • Modify: discovery/aliases.py (block_explode record ≈ line 222)
  • Test: tests/test_track_a_hardening.py (append)

Interfaces:

  • Produces: block_explode(handle) -> {"ok": True, "exploded_handle": handle, "inserted_handles": [geometry handles], "attribute_texts": [one TEXT handle per ATTRIB], "backend": "ezdxf"|"com"} on both engines. Refusal (unchanged type): RuntimeError("Entity <h> is not a block reference (INSERT)") before any write — on COM now checked on ObjectName instead of letting Explode() fail on a non-INSERT.

  • Step 1: Append the failing tests

# tests/test_track_a_hardening.py (append)


# ── Task 2: block_explode keeps ATTRIB text ──────────────────────────────────


async def test_explode_emits_a_text_per_attrib_at_the_attribs_placement(backend):
    from ezdxf.enums import TextEntityAlignment

    blk = backend._doc.blocks.new(name="TB2")
    blk.add_line((0, 0), (10, 0))
    tag = blk.add_attdef("TAG", insert=(0, 3), text="", dxfattribs={"height": 2.5})
    tag.set_placement((0, 3), align=TextEntityAlignment.CENTER)
    hidden = blk.add_attdef("LINK", insert=(0, -3), text="L-1", dxfattribs={"height": 2.0})
    hidden.is_invisible = True
    ref = await backend.block_insert(
        "TB2", 10, 10, scale_x=2.0, scale_y=2.0, rotation=30.0, attributes={"TAG": "P-101"}
    )
    raw_ref = backend._doc.entitydb[ref.handle]
    expected = {a.dxf.tag: (tuple(a.dxf.insert)[:2], a.dxf.height) for a in raw_ref.attribs}

    result = await backend.block_explode(ref.handle)

    assert result["ok"] is True and result["exploded_handle"] == ref.handle
    assert result["backend"] == "ezdxf"
    assert [(await backend.entity_get(h)).type for h in result["inserted_handles"]] == ["LINE"]
    assert len(result["attribute_texts"]) == 2
    texts = {}
    for handle in result["attribute_texts"]:
        info = await backend.entity_get(handle)
        assert info.type == "TEXT"
        texts[info.properties["text"]] = info
    tag_text = texts["P-101"]
    assert tuple(tag_text.properties["insertion"]) == pytest.approx(expected["TAG"][0])
    assert tag_text.properties["height"] == expected["TAG"][1] == 5.0
    assert tag_text.properties["rotation"] == pytest.approx(30.0)
    raw = backend._doc.entitydb[tag_text.handle]
    assert raw.get_align_enum().name == "CENTER", "alignment survives the conversion"
    assert raw.dxf.invisible == 0
    assert backend._doc.entitydb[texts["L-1"].handle].dxf.invisible == 1
    with pytest.raises(RuntimeError, match="not found"):
        await backend.entity_get(ref.handle)
    assert backend._doc.audit().errors == []


async def test_explode_without_attribs_reports_an_empty_list(backend):
    line = await backend.entity_create_line(0, 0, 10, 0)
    await backend.block_create_from_entities("PLAIN", [line.handle])
    ref = await backend.block_insert("PLAIN", 0, 0)
    result = await backend.block_explode(ref.handle)
    assert result["attribute_texts"] == [] and len(result["inserted_handles"]) == 1


async def test_explode_refuses_a_non_insert_without_writing(backend):
    line = await backend.entity_create_line(0, 0, 10, 0)
    before = len(list(backend._msp()))
    with pytest.raises(RuntimeError, match="not a block reference"):
        await backend.block_explode(line.handle)
    assert len(list(backend._msp())) == before


async def test_com_explode_deletes_attdefs_adds_text_per_attrib_and_deletes_the_reference(com):
    backend, document, space = com
    attdef = _FakeObject("AcDbAttributeDefinition", "D1")
    line = _FakeObject("AcDbLine", "L1")
    visible = _FakeObject(
        "AcDbAttribute",
        "A1",
        TagString="TAG",
        TextString="P-101",
        InsertionPoint=(7.0, 15.2, 0.0),
        Height=5.0,
        Rotation=0.5236,
        Layer="PID-TAG",
        Invisible=False,
    )
    hidden = _FakeObject(
        "AcDbAttribute",
        "A2",
        TagString="LINK",
        TextString="L-1",
        InsertionPoint=(13.0, 4.8, 0.0),
        Height=4.0,
        Rotation=0.5236,
        Layer="0",
        Invisible=True,
    )
    ref = _FakeObject(
        "AcDbBlockReference",
        "2F",
        Name="TB",
        GetAttributes=lambda: (visible, hidden),
        Explode=lambda: (line, attdef),
    )
    document.objects["2F"] = ref

    result = await backend.block_explode("2F")

    assert result == {
        "ok": True,
        "exploded_handle": "2F",
        "inserted_handles": ["L1"],
        "attribute_texts": ["T1", "T2"],
        "backend": "com",
    }
    assert attdef.deleted is True, "the value-less ATTDEF placeholder is removed"
    assert line.deleted is False
    assert ref.deleted is True, "ActiveX Explode leaves the reference; BURST removes it"
    assert [c for c in space.calls] == [
        ("AddText", ("P-101", (7.0, 15.2, 0.0), 5.0)),
        ("AddText", ("L-1", (13.0, 4.8, 0.0), 4.0)),
    ]


async def test_com_explode_refuses_a_non_insert_before_any_call(com):
    backend, document, space = com
    document.objects["3A"] = _FakeObject("AcDbLine", "3A")
    with pytest.raises(RuntimeError, match="not a block reference"):
        await backend.block_explode("3A")
    assert space.calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_track_a_hardening.py -k explode -v Expected: 4 FAIL, 1 PASS. ezdxf: KeyError: 'exploded_handle' and KeyError: 'attribute_texts'; COM: the result dict has only ok/exploded_handle (assertion diff) and the non-INSERT case raises AttributeError: Explode from the fake instead of the refusal. test_explode_refuses_a_non_insert_without_writing already passes.

  • Step 3: ezdxf — a TEXT per ATTRIB

In backends/ezdxf_backend.py, replace the whole block_explode method

    async def block_explode(self, handle) -> dict:
        def _sync():
            ent = self._get_entity(handle)
            if ent.dxftype() != "INSERT":
                raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
            msp = self._msp()
            # Decompose: add individual entities to modelspace
            inserted = []
            for sub in ent.virtual_entities():
                sub_copy = sub.copy()
                msp.add_entity(sub_copy)
                inserted.append(sub_copy.dxf.handle)
            msp.delete_entity(ent)
            self._mark_dirty()
            return {"ok": True, "inserted_handles": inserted}

        return await self._async(_sync)

with

    #: ATTRIB DXF attributes carried onto the TEXT that replaces it on explode.
    #: ``insert``/``align_point`` are already WCS on an attached ATTRIB.
    _ATTRIB_TO_TEXT = (
        "layer",
        "color",
        "style",
        "height",
        "rotation",
        "width",
        "oblique",
        "halign",
        "valign",
        "text_generation_flag",
        "insert",
        "align_point",
    )

    async def block_explode(self, handle) -> dict:
        """Explode an INSERT into its members; ATTRIB values survive as TEXT.

        ``virtual_entities()`` yields the definition's geometry only — no
        ATTDEF, no ATTRIB — so an exploded tagged symbol used to lose its tag
        text silently. This is the BURST rule (Express Tools): every attached
        ATTRIB becomes a TEXT at the same WCS placement, height, rotation,
        style and layer, carrying the *value*; an invisible ATTRIB becomes an
        invisible TEXT rather than appearing. The new handles are reported as
        ``attribute_texts``, separate from the geometry's ``inserted_handles``.
        """

        def _sync():
            ent = self._get_entity(handle)
            if ent.dxftype() != "INSERT":
                raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
            msp = self._msp()
            inserted = []
            for sub in ent.virtual_entities():
                sub_copy = sub.copy()
                msp.add_entity(sub_copy)
                inserted.append(sub_copy.dxf.handle)
            attribute_texts = []
            for attrib in ent.attribs:
                dxfattribs = {
                    key: attrib.dxf.get(key)
                    for key in self._ATTRIB_TO_TEXT
                    if attrib.dxf.hasattr(key)
                }
                text = msp.add_text(attrib.dxf.text, dxfattribs=dxfattribs)
                if attrib.is_invisible:
                    text.dxf.invisible = 1
                attribute_texts.append(text.dxf.handle)
            msp.delete_entity(ent)
            self._mark_dirty()
            return {
                "ok": True,
                "exploded_handle": handle,
                "inserted_handles": inserted,
                "attribute_texts": attribute_texts,
                "backend": "ezdxf",
            }

        return await self._async(_sync)
  • Step 4: COM — capture, explode, drop ATTDEFs, AddText, delete the reference

In backends/com_backend.py, replace the whole block_explode method

    async def block_explode(self, handle) -> dict:
        def _sync():
            doc = _acad_doc()
            ent = doc.HandleToObject(handle)
            ent.Explode()
            return {"ok": True, "exploded_handle": handle}

        return await self._run(_sync)

with

    async def block_explode(self, handle) -> dict:
        """Explode an INSERT through ActiveX; ATTRIB values survive as TEXT.

        AutoCAD's EXPLODE keeps the attribute *definitions* (the tag names as
        ATTDEF entities) and discards the values; ActiveX ``Explode()`` does
        the same and, unlike the command, leaves the original reference in
        place. This is the BURST rule instead: each ATTRIB's value, placement,
        height, rotation and layer are read before the explode, the ATTDEFs
        the explode returns are deleted, one ``AddText`` per ATTRIB carries the
        value (an invisible ATTRIB becomes an invisible TEXT), and the
        reference itself is deleted. Unit-tested against a fake ActiveX
        surface; ``Explode()``'s return shape is the ActiveX documented one
        (an array of the new objects) and is exercised live by the settings
        smoke.
        """

        def _sync():
            doc = _acad_doc()
            ent = doc.HandleToObject(handle)
            if ent.ObjectName != "AcDbBlockReference":
                raise RuntimeError(f"Entity {handle} is not a block reference (INSERT)")
            captured = []
            try:
                attrs = ent.GetAttributes()
            except Exception as exc:
                log.debug("GetAttributes failed before explode: %s", exc)
                attrs = ()
            for attr in attrs:
                try:
                    invisible = bool(attr.Invisible)
                except Exception:
                    invisible = False
                captured.append(
                    {
                        "text": str(attr.TextString),
                        "insertion": tuple(attr.InsertionPoint),
                        "height": float(attr.Height),
                        "rotation": float(attr.Rotation),
                        "layer": str(attr.Layer),
                        "invisible": invisible,
                    }
                )
            exploded = ent.Explode()
            inserted = []
            for obj in exploded or ():
                if obj.ObjectName == "AcDbAttributeDefinition":
                    obj.Delete()  # the tag placeholder EXPLODE leaves; the value is below
                    continue
                inserted.append(str(obj.Handle))
            mspace = _msp()
            attribute_texts = []
            for item in captured:
                ins = item["insertion"]
                text = mspace.AddText(
                    item["text"],
                    _apoint(ins[0], ins[1], ins[2] if len(ins) > 2 else 0.0),
                    item["height"],
                )
                text.Rotation = item["rotation"]
                text.Layer = item["layer"]
                if item["invisible"]:
                    text.Visible = False
                attribute_texts.append(str(text.Handle))
            ent.Delete()
            _regen()
            return {
                "ok": True,
                "exploded_handle": handle,
                "inserted_handles": inserted,
                "attribute_texts": attribute_texts,
                "backend": "com",
            }

        return await self._run(_sync)
  • Step 5: Tool docstring and alias record

In server.py, block_explode — replace

    """Explode a block reference into its individual component entities."""
    await ctx.warning(f"Exploding block reference {handle}")

with

    """Explode a block reference into its component entities, keeping its
    attribute text (the Express Tools BURST rule, on both engines).

    Every attached ATTRIB becomes a TEXT with the same value, placement,
    height, rotation and layer (an invisible attribute becomes an invisible
    TEXT); AutoCAD's plain EXPLODE would keep only the tag-name placeholders
    and drop the values. Returns `inserted_handles` (the geometry),
    `attribute_texts` (one TEXT handle per attribute), `exploded_handle` and
    `backend`. Refused: a handle that is not a block reference (nothing is
    written). Not undoable except through `drawing_undo` / a transaction.
    """
    await ctx.warning(f"Exploding block reference {handle}")

In discovery/aliases.py, replace the block_explode record with (BURST is the Express Tools command whose behaviour this now is; no other record claims it):

    "block_explode": ToolAliases(
        acad=("EXPLODE", "BURST"),
        synonyms=(
            "explode",
            "break a block apart",
            "ungroup",
            "split a block into entities",
            "flatten a symbol",
            "burst a block",
            "explode and keep the attribute text",
        ),
    ),

Pack core, not lean — unchanged; cost="destructive" unchanged.

  • Step 6: Run the tests

Run: uv run --frozen pytest tests/test_track_a_hardening.py tests/test_layer_block.py tests/test_discovery_aliases.py -v Expected: all PASS (tests/test_layer_block.py::test_block_explode only checks ok).

  • Step 7: Gates

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py discovery tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: all PASS (docs/tool-inventory.json is unchanged: the summary line did not move, and python scripts/generate_tool_inventory.py --check prints ok).

  • Step 8: Commit
git add backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_track_a_hardening.py
git commit -m "fix(blocks): block_explode keeps attribute text as TEXT (BURST rule) on both engines

virtual_entities() yields geometry only, so an exploded tagged symbol lost
its tag value silently. Every attached ATTRIB now becomes a TEXT at the
same WCS placement/height/rotation/style/layer (invisible stays
invisible), reported as attribute_texts. COM reads GetAttributes before
Explode, deletes the ATTDEF placeholders Explode returns, AddText per
value, and deletes the reference ActiveX Explode leaves behind.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 3: attribute values are validated before any write — null / bool / objects refused by tag, numbers written as plain text

Measured: block_insert("TB", 0, 0, attributes={"TAG": None}) writes the text None (True → True, a dict → its repr) on both engines, and a non-mapping ([("TAG", "x")]) fails with AttributeError after add_blockref has written the INSERT (the modelspace grows, _mark_dirty is skipped). One shared validator in backends/block_specs.py runs before the write on both engines and in both tools.

Files:

  • Modify: backends/block_specs.py (new validate_attribute_values, before solid_vertices)
  • Modify: backends/ezdxf_backend.py (block_insert, block_set_attributes)
  • Modify: backends/com_backend.py (block_insert, block_set_attributes)
  • Modify: server.py (block_insert, block_set_attributes docstrings)
  • Test: tests/test_track_a_hardening.py (append)

Interfaces:

  • Produces: validate_attribute_values(attributes, where="attributes") -> dict[str, str] — None → {}; a non-dict → TypeError("attributes must be an object of {TAG: value} pairs"); a non-string/empty tag → TypeError("attributes: every tag must be a non-empty string"); a string value → itself after the module's _one_line rule (TypeError("attributes['TAG'] must be a single line …")); an int or finite float → str(value) (101 → "101", 2.5 → "2.5"); bool, None, list, dict → TypeError("attributes['TAG'] must be a string or a number, got <type>"); NaN/inf → TypeError("attributes['TAG'] must be finite").

  • Consumes: _one_line (same module, Track A Task 2).

  • Step 1: Append the failing tests

# tests/test_track_a_hardening.py (append)


# ── Task 3: attribute values are validated before any write ──────────────────


@pytest.mark.parametrize(
    "value, fragment",
    [
        (None, "NoneType"),
        (True, "bool"),
        ({"a": 1}, "dict"),
        ([1, 2], "list"),
        (float("nan"), "finite"),
        ("two\nlines", "single line"),
    ],
)
async def test_insert_refuses_a_non_text_attribute_value_by_tag(backend, value, fragment):
    _define_tagged_block(backend)
    before = len(list(backend._msp()))
    with pytest.raises(TypeError, match=r"attributes\['TAG'\]") as exc:
        await backend.block_insert("TB", 0, 0, attributes={"TAG": value})
    assert fragment in str(exc.value)
    assert len(list(backend._msp())) == before, "a refused insert must not write"


async def test_insert_refuses_a_non_mapping_before_writing(backend):
    _define_tagged_block(backend)
    before = len(list(backend._msp()))
    with pytest.raises(TypeError, match="attributes must be an object"):
        await backend.block_insert("TB", 0, 0, attributes=[("TAG", "x")])
    assert len(list(backend._msp())) == before


async def test_insert_writes_numbers_as_plain_text(backend):
    _define_tagged_block(backend)
    ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 101})
    assert await backend.block_get_attributes(ref.handle) == {"TAG": "101"}
    ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": 2.5})
    assert await backend.block_get_attributes(ref.handle) == {"TAG": "2.5"}


async def test_set_attributes_refuses_by_tag_and_leaves_the_value_alone(backend):
    _define_tagged_block(backend)
    ref = await backend.block_insert("TB", 0, 0, attributes={"TAG": "P-101"})
    with pytest.raises(TypeError, match=r"attributes\['TAG'\] must be a string or a number"):
        await backend.block_set_attributes(ref.handle, {"TAG": None})
    assert await backend.block_get_attributes(ref.handle) == {"TAG": "P-101"}
    result = await backend.block_set_attributes(ref.handle, {"TAG": 7})
    assert result == {"ok": True, "updated_tags": ["TAG"]}
    assert await backend.block_get_attributes(ref.handle) == {"TAG": "7"}


async def test_com_insert_refuses_before_insert_block_and_writes_numbers_as_text(com):
    backend, document, space = com
    space.attributes = {"TAG": ""}
    with pytest.raises(TypeError, match=r"attributes\['TAG'\]"):
        await backend.block_insert("TB", 0, 0, attributes={"TAG": None})
    assert space.calls == [], "the refusal fires before InsertBlock"

    await backend.block_insert("TB", 0, 0, attributes={"TAG": 101})
    assert [name for name, _ in space.calls] == ["InsertBlock"]
    attr = space.inserted[0].GetAttributes()[0]
    assert attr.TextString == "101" and ("TextString", "101") in attr.writes


async def test_com_set_attributes_refuses_before_touching_the_reference(com):
    backend, document, space = com
    attr = _FakeObject("AcDbAttribute", "A1", TagString="TAG", TextString="P-101")
    calls = []

    def get_attributes():
        calls.append("GetAttributes")
        return (attr,)

    document.objects["2F"] = _FakeObject(
        "AcDbBlockReference", "2F", Name="TB", GetAttributes=get_attributes
    )
    with pytest.raises(TypeError, match=r"attributes\['TAG'\]"):
        await backend.block_set_attributes("2F", {"TAG": True})
    assert calls == [] and attr.TextString == "P-101"
    assert await backend.block_set_attributes("2F", {"TAG": 2.5}) == {
        "ok": True,
        "updated_tags": ["TAG"],
    }
    assert attr.TextString == "2.5"
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_track_a_hardening.py -k "attribute or set_attributes or non_mapping or numbers" -v Expected: 10 FAIL, 1 PASS. The six parametrized cases, both set_attributes tests and the COM insert test fail with Failed: DID NOT RAISE TypeError (None is written as "None", True as "True"); test_insert_refuses_a_non_mapping_before_writing fails with AttributeError: 'list' object has no attribute 'items' — raised after the write, which is the defect. test_insert_writes_numbers_as_plain_text already passes (str(101)), and stays as the regression pin.

  • Step 3: The shared validator

In backends/block_specs.py, insert before def solid_vertices(...):

def validate_attribute_values(attributes, where: str = "attributes") -> dict[str, str]:
    """``{TAG: value}`` for ``block_insert`` / ``block_set_attributes`` as the
    strings that will be written, refused before any write.

    ``str(value)`` used to be applied to whatever arrived, so a JSON ``null``
    became the text ``None``, ``true`` became ``True`` and an object became its
    Python repr — silently, on both engines. A string is taken as is (one line,
    the DXF rule ``_one_line`` enforces); an int or a finite float is written
    with ``str`` (``101`` → ``"101"``, ``2.5`` → ``"2.5"``); ``bool``, ``None``,
    lists and objects are refused by tag. ``None`` for the whole mapping means
    "no attributes".
    """
    if attributes is None:
        return {}
    if not isinstance(attributes, dict):
        raise TypeError(f"{where} must be an object of {{TAG: value}} pairs")
    out: dict[str, str] = {}
    for tag, value in attributes.items():
        if not isinstance(tag, str) or not tag.strip():
            raise TypeError(f"{where}: every tag must be a non-empty string")
        label = f"{where}[{tag!r}]"
        if isinstance(value, str):
            out[tag] = _one_line(value, label)
        elif isinstance(value, bool) or value is None or not isinstance(value, (int, float)):
            raise TypeError(f"{label} must be a string or a number, got {type(value).__name__}")
        else:
            if isinstance(value, float) and not math.isfinite(value):
                raise TypeError(f"{label} must be finite")
            out[tag] = str(value)
    return out
  • Step 4: ezdxf — validate first, write the validated strings

In backends/ezdxf_backend.py, block_insert: replace

        attributes=None,
        layer=None,
    ) -> EntityInfo:
        def _sync():
            msp = self._msp()
            attribs: dict = {

with

        attributes=None,
        layer=None,
    ) -> EntityInfo:
        from backends.block_specs import validate_attribute_values

        values = validate_attribute_values(attributes)

        def _sync():
            msp = self._msp()
            attribs: dict = {

and, in the same method, replace

            if attributes:
                # `add_auto_blockref` wraps the INSERT in an anonymous *U block,
                # so the returned handle names `*U1` and carries no ATTRIBs.
                # `add_auto_attribs` attaches ATTRIBs to this INSERT for every
                # ATTDEF the definition has; values for tags it lacks are ignored.
                ref.add_auto_attribs({str(k): str(v) for k, v in attributes.items()})

with

            if values:
                # `add_auto_blockref` wraps the INSERT in an anonymous *U block,
                # so the returned handle names `*U1` and carries no ATTRIBs.
                # `add_auto_attribs` attaches ATTRIBs to this INSERT for every
                # ATTDEF the definition has; values for tags it lacks are ignored.
                ref.add_auto_attribs(values)

block_set_attributes: replace the whole method with

    async def block_set_attributes(self, handle, attributes) -> dict:
        from backends.block_specs import validate_attribute_values

        values = validate_attribute_values(attributes)

        def _sync():
            ent = self._get_entity(handle)
            if ent.dxftype() != "INSERT":
                raise RuntimeError(f"Entity {handle} is not a block reference")
            updated = []
            for attrib in ent.attribs:
                tag = attrib.dxf.tag
                if tag in values:
                    attrib.dxf.text = values[tag]
                    updated.append(tag)
            self._mark_dirty()
            return {"ok": True, "updated_tags": updated}

        return await self._async(_sync)
  • Step 5: COM — the same, before InsertBlock / GetAttributes

In backends/com_backend.py, block_insert: replace

        attributes=None,
        layer=None,
    ) -> EntityInfo:
        def _sync():
            _require_block_defined(_acad_doc(), name)
            mspace = _msp()

with

        attributes=None,
        layer=None,
    ) -> EntityInfo:
        from backends.block_specs import validate_attribute_values

        values = validate_attribute_values(attributes)

        def _sync():
            _require_block_defined(_acad_doc(), name)
            mspace = _msp()

and replace

            if attributes:
                try:
                    attrs = ref.GetAttributes()
                    for attr in attrs:
                        tag = attr.TagString
                        if tag in attributes:
                            attr.TextString = str(attributes[tag])

with

            if values:
                try:
                    attrs = ref.GetAttributes()
                    for attr in attrs:
                        tag = attr.TagString
                        if tag in values:
                            attr.TextString = values[tag]

block_set_attributes: replace the whole method with

    async def block_set_attributes(self, handle, attributes) -> dict:
        from backends.block_specs import validate_attribute_values

        values = validate_attribute_values(attributes)

        def _sync():
            doc = _acad_doc()
            ref = doc.HandleToObject(handle)
            attrs = ref.GetAttributes()
            updated = []
            for attr in attrs:
                tag = attr.TagString
                if tag in values:
                    attr.TextString = values[tag]
                    updated.append(tag)
            return {"ok": True, "updated_tags": updated}

        return await self._run(_sync)
  • Step 6: Tool docstrings name the value rule

In server.py, block_insert — replace

    """Insert a block and optionally set attribute values."""
    await ctx.info(f"Inserting block '{name}' at ({x},{y})")

with

    """Insert a block and optionally set attribute values.

    Refused before any write, on both engines: an undefined or layout block
    name, and an attribute value that is not text — a string is written as
    is (one line), an int or finite float as its plain digits (`101` →
    `"101"`), while `null`, booleans, lists and objects are refused naming
    the tag (they used to be written as their Python repr). Values for tags
    the block does not define are ignored.
    """
    await ctx.info(f"Inserting block '{name}' at ({x},{y})")

block_set_attributes — replace

    """Update attribute values in a block reference."""
    return await _backend(ctx).block_set_attributes(handle, attributes)

with

    """Update attribute values in a block reference.

    Same value rule as `block_insert`: strings and numbers are written,
    `null` / booleans / lists / objects are refused naming the tag before
    anything changes. `updated_tags` lists the tags that exist on the
    reference and were written; unknown tags are ignored.
    """
    return await _backend(ctx).block_set_attributes(handle, attributes)

Alias records (INSERT / EATTEDIT, ATTEDIT and their synonyms) and pack/lean membership are unchanged.

  • Step 7: Run the tests

Run: uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_insert_attributes.py tests/test_block_specs.py tests/test_pid_insert.py tests/test_layer_block.py -v Expected: all PASS (pid_symbol_insert writes strings only, so engineering/pid/insert.py is unaffected).

  • Step 8: Gates

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: all PASS.

  • Step 9: Commit
git add backends/block_specs.py backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_track_a_hardening.py
git commit -m "fix(blocks): attribute values are validated before any write; null/bool/objects refused by tag

block_insert and block_set_attributes applied str() to whatever arrived, so
a JSON null became the text None and a non-mapping failed after the INSERT
was written. validate_attribute_values (backends/block_specs.py) runs first
on both engines: strings pass the one-line rule, ints and finite floats are
written as plain digits, everything else is a TypeError naming the tag.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 4: block_define refuses a non-finite base point and a missing primitive layer (unless create_layers=True)

Measured: block_define("PID_NAN", CIRC, base_x=nan, base_y=inf) returns ok: True and the BLOCK record carries Vec3(nan, inf, 0); a primitive on NOT_A_LAYER is written with no layer-table entry and doc.audit() reports nothing. On COM obj.Layer = name for an unknown layer runs mid-loop after Blocks.Add, so a refusal there would leave the definition half written. Both engines now check the base point with the module's _scalar rule and probe the layer table before the first write; create_layers=True creates the missing layers (engine defaults) and reports them. The kwarg is optional, so every Track A caller (engineering/pid/insert.py::ensure_block passes 0.0, 0.0 on layer 0) and the parent plan's Interfaces stay valid.

Files:

  • Modify: backends/block_specs.py (new validate_base_point, before validate_attribute_values)
  • Modify: backends/contracts/blocks.py (block_define signature + docstring)
  • Modify: backends/ezdxf_backend.py (block_define ≈ line 4670)
  • Modify: backends/com_backend.py (block_define ≈ line 3431)
  • Modify: server.py (block_define tool: new parameter, docstring)
  • Modify: tests/test_block_define.py (the pinned result dict gains layers_created)
  • Test: tests/test_track_a_hardening.py (append)

Interfaces:

  • Produces: validate_base_point(base_x, base_y) -> tuple[float, float] (TypeError naming 'base_x' / 'base_y': non-number, bool, non-finite, overflowing int — _scalar gains the OverflowError guard, so every block_define scalar benefits). block_define(name, entities, attdefs=None, base_x=0.0, base_y=0.0, overwrite=False, create_layers=False) -> {ok, name, entity_count, attdef_count, replaced, layers_created: [names], backend}. Refusal: ValueError("block_define: layer(s) 'A', 'B' do not exist in the drawing; create them with layer_create or pass create_layers=true"), before blocks.new / Blocks.Add.

  • Consumes: ezdxf doc.layers (in is case-insensitive) / doc.layers.add(name); ActiveX Layers.Item(name) (raises for unknown) / Layers.Add(name).

  • Step 1: Append the failing tests

# tests/test_track_a_hardening.py (append)


# ── Task 4: block_define validates the base point and the layer table ────────

CIRC = [{"type": "circle", "cx": 0, "cy": 0, "r": 1}]
ON_LAYER = [{"type": "line", "x1": 0, "y1": 0, "x2": 1, "y2": 1, "layer": "NOT_A_LAYER"}]


@pytest.mark.parametrize(
    "kwargs, key",
    [
        ({"base_x": float("nan")}, "base_x"),
        ({"base_y": float("inf")}, "base_y"),
        ({"base_x": "3"}, "base_x"),
        ({"base_y": True}, "base_y"),
        ({"base_x": 10**400}, "base_x"),
    ],
)
async def test_define_refuses_a_bad_base_point_before_writing(backend, kwargs, key):
    with pytest.raises(TypeError, match=f"'{key}'"):
        await backend.block_define("PID_BASE", CIRC, **kwargs)
    assert "PID_BASE" not in backend._doc.blocks


async def test_define_refuses_a_missing_layer_and_names_it(backend):
    with pytest.raises(ValueError, match="'NOT_A_LAYER' do not exist") as exc:
        await backend.block_define("PID_LYR", ON_LAYER)
    assert "create_layers=true" in str(exc.value)
    assert "PID_LYR" not in backend._doc.blocks
    assert "NOT_A_LAYER" not in backend._doc.layers


async def test_define_creates_the_layer_on_request_and_reports_it(backend):
    result = await backend.block_define("PID_LYR", ON_LAYER, create_layers=True)
    assert result["layers_created"] == ["NOT_A_LAYER"] and result["ok"] is True
    assert "NOT_A_LAYER" in backend._doc.layers
    member = next(iter(backend._doc.blocks.get("PID_LYR")))
    assert member.dxf.layer == "NOT_A_LAYER"


async def test_define_on_an_existing_layer_reports_nothing_created(backend):
    await backend.layer_create("PID-EQUIP")
    spec = [{**ON_LAYER[0], "layer": "PID-EQUIP"}]
    result = await backend.block_define("PID_OK", spec)
    assert result["layers_created"] == []
    result = await backend.block_define("PID_ZERO", CIRC)
    assert result["layers_created"] == [], "layer 0 always exists"


async def test_com_define_probes_layers_before_blocks_add(com):
    backend, document, space = com
    with pytest.raises(ValueError, match="'NOT_A_LAYER' do not exist"):
        await backend.block_define("PID_LYR", ON_LAYER)
    assert "PID_LYR" not in document.Blocks.blocks and document.Layers.added == []

    result = await backend.block_define("PID_LYR", ON_LAYER, create_layers=True)
    assert result["layers_created"] == ["NOT_A_LAYER"] and result["backend"] == "com"
    assert document.Layers.added == ["NOT_A_LAYER"]
    assert [name for name, _ in document.Blocks.blocks["PID_LYR"].calls] == ["AddLine"]


async def test_com_define_refuses_a_bad_base_point_before_blocks_add(com):
    backend, document, space = com
    with pytest.raises(TypeError, match="'base_y'"):
        await backend.block_define("PID_BASE", CIRC, base_y=float("nan"))
    assert "PID_BASE" not in document.Blocks.blocks
    result = await backend.block_define("PID_BASE", CIRC, base_x=1.5, base_y=2.5)
    assert document.Blocks.blocks["PID_BASE"].base_point == (1.5, 2.5, 0.0)
    assert result["layers_created"] == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_track_a_hardening.py -k define -v Expected: 10 FAIL. Four base-point cases and the COM base-point test fail DID NOT RAISE TypeError (a NaN base point is written), the 10**400 case with OverflowError: int too large to convert to float; test_define_refuses_a_missing_layer_and_names_it and the COM layer probe fail DID NOT RAISE ValueError; test_define_creates_the_layer_on_request_and_reports_it fails with TypeError: EzdxfBackend.block_define() got an unexpected keyword argument 'create_layers'; test_define_on_an_existing_layer_reports_nothing_created with KeyError: 'layers_created'.

  • Step 3: The shared base-point validator (and _scalar learns about overflow)

In backends/block_specs.py, _scalar let a 400-digit int escape as OverflowError (wave-2 finding) — replace

    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError(f"{where} must be a number, got {value!r}")
    number = float(value)
    if not math.isfinite(number):
        raise TypeError(f"{where} must be finite")

with

    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError(f"{where} must be a number, got {value!r}")
    try:
        number = float(value)
    except OverflowError as exc:  # a 400-digit JSON integer is an int, not a float
        raise TypeError(f"{where} must be finite") from exc
    if not math.isfinite(number):
        raise TypeError(f"{where} must be finite")

Then insert before def validate_attribute_values(...) (Task 3):

def validate_base_point(base_x, base_y) -> tuple[float, float]:
    """``block_define``'s base point as finite floats; ``TypeError`` names the key.

    Only ``float()`` was applied before, so ``float("nan")`` was written into
    the BLOCK record headlessly and would reach AutoCAD as a NaN VARIANT.
    """
    return (
        _scalar(base_x, "block_define: 'base_x'"),
        _scalar(base_y, "block_define: 'base_y'"),
    )
  • Step 4: The contract

In backends/contracts/blocks.py, replace the block_define member with:

    @abstractmethod
    async def block_define(
        self,
        name: str,
        entities: list[dict],
        attdefs: list[dict] | None = None,
        base_x: float = 0.0,
        base_y: float = 0.0,
        overwrite: bool = False,
        create_layers: bool = False,
    ) -> dict:
        """Create (or with ``overwrite`` replace the contents of) a block
        definition from typed primitive specs and ATTDEF specs — see
        ``backends/block_specs.py`` for the spec vocabulary.

        Refusals, all before any write: a malformed spec (``TypeError`` naming
        ``entities[i]``/``attdefs[i]`` and the key), a non-finite or non-numeric
        ``base_x``/``base_y`` (``TypeError`` naming it), a name clash without
        ``overwrite`` (``ValueError``), and a primitive ``layer`` that does not
        exist in the drawing (``ValueError`` listing the missing names) unless
        ``create_layers`` is true, in which case the missing layers are created
        with the engine's defaults and listed in ``layers_created``. Returns
        ``{ok, name, entity_count, attdef_count, replaced, layers_created,
        backend}``."""
        ...
  • Step 5: ezdxf

In backends/ezdxf_backend.py, block_define — replace the signature and docstring head

        base_x=0.0,
        base_y=0.0,
        overwrite=False,
    ) -> dict:
        """A block definition with ATTDEFs from typed specs.

        Validation runs before any write, so a malformed entry leaves no
        definition behind. ``overwrite`` replaces the *contents* of an existing
        definition rather than deleting it: existing INSERTs keep pointing at
        the name and show the new geometry (deleting a referenced block is
        refused by both engines; replacing contents works on both).
        """
        from ezdxf.enums import TextEntityAlignment

        from backends.block_specs import (
            solid_vertices,
            validate_attdef_specs,
            validate_entity_specs,
        )

with

        base_x=0.0,
        base_y=0.0,
        overwrite=False,
        create_layers=False,
    ) -> dict:
        """A block definition with ATTDEFs from typed specs.

        Validation runs before any write, so a malformed entry leaves no
        definition behind: specs, the base point (``base_x``/``base_y`` must be
        finite numbers — ``float("nan")`` used to be written into the BLOCK
        record) and the layer table (a primitive's ``layer`` must exist, or
        ``create_layers`` must be true — a dangling layer reference passes
        ``doc.audit()`` silently). ``overwrite`` replaces the *contents* of an
        existing definition rather than deleting it: existing INSERTs keep
        pointing at the name and show the new geometry (deleting a referenced
        block is refused by both engines; replacing contents works on both).
        """
        from ezdxf.enums import TextEntityAlignment

        from backends.block_specs import (
            solid_vertices,
            validate_attdef_specs,
            validate_base_point,
            validate_entity_specs,
        )

Then replace

        clean_name = sanitize_symbol_name(name, kind="block")
        ents = validate_entity_specs(entities)
        atts = validate_attdef_specs(attdefs or [])
        align_map = {
            "left": TextEntityAlignment.LEFT,

with

        clean_name = sanitize_symbol_name(name, kind="block")
        ents = validate_entity_specs(entities)
        atts = validate_attdef_specs(attdefs or [])
        base = (*validate_base_point(base_x, base_y), 0.0)
        wanted_layers = sorted({spec["layer"] for spec in ents if spec["layer"] != "0"})
        align_map = {
            "left": TextEntityAlignment.LEFT,

and, inside _sync, replace

            replaced = existing is not None
            base = (float(base_x), float(base_y), 0.0)
            if existing is None:
                blk = doc.blocks.new(name=clean_name, base_point=base)

with

            replaced = existing is not None
            missing_layers = [layer for layer in wanted_layers if layer not in doc.layers]
            if missing_layers and not create_layers:
                raise ValueError(
                    "block_define: layer(s) "
                    + ", ".join(repr(layer) for layer in missing_layers)
                    + " do not exist in the drawing; create them with layer_create "
                    "or pass create_layers=true"
                )
            for layer in missing_layers:
                doc.layers.add(layer)
            if existing is None:
                blk = doc.blocks.new(name=clean_name, base_point=base)

and in the return dict add "layers_created": missing_layers, between "replaced": replaced, and "backend": "ezdxf",.

  • Step 6: COM

In backends/com_backend.py, block_define — replace the signature and docstring head

        base_x=0.0,
        base_y=0.0,
        overwrite=False,
    ) -> dict:
        """A block definition with ATTDEFs from typed specs, through ActiveX.

        ``Blocks.Add`` creates the definition; each primitive is one ``Add*``
        call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite
        deletes the existing definition's members and re-adds — the name and
        its INSERTs survive. Unit-tested against a fake ActiveX surface;
        executed live by ``scripts/smoke_pid_com.py``.
        """
        from backends.block_specs import (
            solid_vertices,
            validate_attdef_specs,
            validate_entity_specs,
        )

with

        base_x=0.0,
        base_y=0.0,
        overwrite=False,
        create_layers=False,
    ) -> dict:
        """A block definition with ATTDEFs from typed specs, through ActiveX.

        ``Blocks.Add`` creates the definition; each primitive is one ``Add*``
        call on the Block object, each ATTDEF one ``AddAttribute``. Overwrite
        deletes the existing definition's members and re-adds — the name and
        its INSERTs survive. The base point must be finite (a NaN would reach
        AutoCAD as a VARIANT) and every primitive layer must exist
        (``Layers.Item`` probe before ``Blocks.Add`` — ``obj.Layer = name`` for
        an unknown layer would otherwise fail mid-loop with the definition half
        written) unless ``create_layers`` adds them first. Unit-tested against
        a fake ActiveX surface; executed live by ``scripts/smoke_pid_com.py``.
        """
        from backends.block_specs import (
            solid_vertices,
            validate_attdef_specs,
            validate_base_point,
            validate_entity_specs,
        )

Then replace

        clean_name = sanitize_symbol_name(name, kind="block")
        ents = validate_entity_specs(entities)
        atts = validate_attdef_specs(attdefs or [])
        # acAlignmentLeft / Center / Right / MiddleCenter
        align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10}

with

        clean_name = sanitize_symbol_name(name, kind="block")
        ents = validate_entity_specs(entities)
        atts = validate_attdef_specs(attdefs or [])
        base_xy = validate_base_point(base_x, base_y)
        wanted_layers = sorted({spec["layer"] for spec in ents if spec["layer"] != "0"})
        # acAlignmentLeft / Center / Right / MiddleCenter
        align_map = {"left": 0, "center": 1, "right": 2, "middle_center": 10}

and, inside _sync, replace

            replaced = existing is not None
            base = _apoint(float(base_x), float(base_y), 0.0)
            if existing is None:
                block = doc.Blocks.Add(base, clean_name)

with

            replaced = existing is not None
            missing_layers = []
            for layer in wanted_layers:
                try:
                    doc.Layers.Item(layer)
                except Exception:
                    missing_layers.append(layer)
            if missing_layers and not create_layers:
                raise ValueError(
                    "block_define: layer(s) "
                    + ", ".join(repr(layer) for layer in missing_layers)
                    + " do not exist in the drawing; create them with layer_create "
                    "or pass create_layers=true"
                )
            for layer in missing_layers:
                doc.Layers.Add(layer)
            base = _apoint(base_xy[0], base_xy[1], 0.0)
            if existing is None:
                block = doc.Blocks.Add(base, clean_name)

and in the return dict add "layers_created": missing_layers, between "replaced": replaced, and "backend": "com",.

  • Step 7: The tool grows the kwarg; the docstring names every refusal

In server.py, block_define — replace

    overwrite: Annotated[
        bool, "Replace the contents of an existing definition of this name"
    ] = False,
    ctx: Context = None,
) -> dict:
    """Create a block definition with attribute definitions from typed specs.

    The whole request is validated before anything is written: one malformed
    entry refuses the call and leaves no definition behind. `overwrite=true`
    replaces the *contents* of an existing block so its INSERTs keep pointing
    at the name and show the new geometry; `replaced` reports it. Geometry
    inside the block is ByBlock so the INSERT's layer supplies colour and
    lineweight. Insert with `block_insert(attributes={TAG: value})`.
    """
    await ctx.info(f"Defining block '{name}' from {len(entities)} primitives")
    return await _backend(ctx).block_define(name, entities, attdefs, base_x, base_y, overwrite)

with

    overwrite: Annotated[
        bool, "Replace the contents of an existing definition of this name"
    ] = False,
    create_layers: Annotated[
        bool, "Create primitive layers that do not exist yet (default: refuse and name them)"
    ] = False,
    ctx: Context = None,
) -> dict:
    """Create a block definition with attribute definitions from typed specs.

    The whole request is validated before anything is written: one malformed
    entry refuses the call and leaves no definition behind. Refusals: a
    malformed primitive or ATTDEF (names `entities[i]`/`attdefs[i]` and the
    key), a non-finite base point (names `base_x`/`base_y`), a name clash
    without `overwrite`, an anonymous `*` name, and a primitive `layer` that
    is not in the drawing's layer table (names the missing layers) unless
    `create_layers=true`, which creates them and lists them in
    `layers_created`. `overwrite=true` replaces the *contents* of an existing
    block so its INSERTs keep pointing at the name and show the new geometry;
    `replaced` reports it. Geometry inside the block is ByBlock so the
    INSERT's layer supplies colour and lineweight. Insert with
    `block_insert(attributes={TAG: value})`.
    """
    await ctx.info(f"Defining block '{name}' from {len(entities)} primitives")
    return await _backend(ctx).block_define(
        name, entities, attdefs, base_x, base_y, overwrite, create_layers
    )

Alias record (BLOCK, BEDIT, ATTDEF + five synonyms), pack core, not lean — unchanged. docs/tool-inventory.json records no parameters and the summary is unchanged, so it does not move.

  • Step 8: The Track A pin gains the new key

In tests/test_block_define.py::test_defines_geometry_and_attdefs, the exact result dict gains one line — replace

        "replaced": False,
        "backend": "ezdxf",
    }

with

        "replaced": False,
        "layers_created": [],
        "backend": "ezdxf",
    }
  • Step 9: Run the tests

Run: uv run --frozen pytest tests/test_track_a_hardening.py tests/test_block_define.py tests/test_block_specs.py tests/test_pid_insert.py tests/test_pid_symbols.py tests/test_capability_contract.py -v Expected: all PASS (both backends still instantiate against the grown contract; the P&ID catalogue defines every block on layer 0, so ensure_block is unaffected).

  • Step 10: Gates

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check backends server.py tests/test_track_a_hardening.py tests/test_block_define.py && uv run --frozen python scripts/generate_tool_inventory.py --check && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: ruff clean; ok: docs/tool-inventory.json matches the live surface; all PASS.

  • Step 11: Commit
git add backends/block_specs.py backends/contracts/blocks.py backends/ezdxf_backend.py backends/com_backend.py server.py tests/test_block_define.py tests/test_track_a_hardening.py
git commit -m "fix(blocks): block_define refuses a non-finite base point and a missing layer unless create_layers

base_x/base_y were only float()-cast, so a NaN base point was written into
the BLOCK record; a primitive on a layer the drawing lacks was written as a
dangling reference doc.audit() never flags (and on COM would fail mid-loop
after Blocks.Add). validate_base_point names the key; the layer table is
probed before the first write and the missing names are refused, or created
and reported as layers_created when create_layers=true (new optional kwarg,
contract + both engines + tool).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 5: pid_from_spec — _num refuses non-finite numbers by path; run_spec rolls back on BaseException

Measured on main: validate_spec accepts equipment[0].x = nan and the INSERT is placed at [nan, 0] (the later draw_line refuses with lines[0]: start.x is nan, so the path named is the wrong one); 10**400 escapes as OverflowError; with draw_line monkeypatched to raise asyncio.CancelledError, run_spec leaves 5 entities in the drawing and transaction_depth 1 — except Exception does not catch a BaseException, and on COM that is an open undo mark that refuses every later pid_from_spec.

Files:

  • Modify: engineering/pid/spec.py (_num ≈ line 137; run_spec ≈ line 422)
  • Modify: server.py (pid_from_spec docstring ≈ line 6780)
  • Test: tests/test_pid_from_spec.py (append)

Interfaces:

  • Produces: _num(value, where) -> float raising ValueError(f"{where} must be a finite number") for NaN/±inf/overflowing ints (the existing must be a number message for non-numbers is kept); run_spec rolls back on any BaseException and re-raises it.

  • Step 1: Append the failing tests

# tests/test_pid_from_spec.py (append)


# ── Track A hardening (track E wave 0, Task 5) ───────────────────────────────


@pytest.mark.parametrize(
    "mutate, fragment",
    [
        (lambda s: s["equipment"][0].update({"x": float("nan")}), "equipment[0].x"),
        (lambda s: s["valves"][0].update({"y": float("inf")}), "valves[0].y"),
        (lambda s: s["instruments"][0].update({"rotation": 10**400}), "instruments[0].rotation"),
        (lambda s: s["lines"][0].update({"stub": float("-inf")}), "lines[0].stub"),
    ],
)
async def test_non_finite_numbers_are_refused_by_path_before_any_write(backend, mutate, fragment):
    before = await backend.entity_count()
    spec = copy.deepcopy(EXAMPLE_SPEC)
    mutate(spec)
    with pytest.raises(ValueError, match="finite") as exc:
        await run_spec(backend, spec)
    assert fragment in str(exc.value)
    assert await backend.entity_count() == before
    assert (await backend.system_status())["transaction_depth"] == 0


async def test_a_cancellation_mid_run_rolls_the_sheet_back(backend, monkeypatch):
    """``CancelledError`` is a ``BaseException``; ``except Exception`` let the
    placed symbols stay inside an open transaction."""
    import asyncio

    from engineering.pid import spec as spec_module

    before = await backend.entity_count()

    async def cancelled(*args, **kwargs):
        raise asyncio.CancelledError()

    monkeypatch.setattr(spec_module, "draw_line", cancelled)
    with pytest.raises(asyncio.CancelledError):
        await run_spec(backend, EXAMPLE_SPEC)
    assert await backend.entity_count() == before, "the placed symbols were rolled back"
    assert (await backend.system_status())["transaction_depth"] == 0, "no open transaction"
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_pid_from_spec.py -v Expected: 5 FAIL, 9 PASS — 14 collected items, counted by pytest item not by function: the file's existing nine (four plain tests plus test_validation_names_the_path parametrized five ways) keep passing; the five appended items (the four parametrized non-finite cases plus the cancellation test) fail. The nan/inf cases fail because the refusal names lines[0]: start.x / lines[0]: end.y (raised by route() after the symbol was placed and rolled back) instead of equipment[0].x / valves[0].y; 10**400 fails with OverflowError: int too large to convert to float; the stub case names lines[0]: stub must be a finite length (from route), not the spec path; the cancellation test fails assert 5 == 0 — five symbols left in the drawing.

  • Step 3: Finite numbers, named by path

In engineering/pid/spec.py, add import math after import copy:

import copy
import math
from typing import TYPE_CHECKING

and replace

def _num(value, where: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{where} must be a number")
    return float(value)

with

def _num(value, where: str) -> float:
    """A finite number, or a ``ValueError`` naming the path.

    ``float("nan")`` used to pass and be placed as an INSERT at ``[nan, 0]``;
    the transaction committed and ``build_graph`` then crashed on the drawing
    with no path in its message. Only in-process callers can deliver a
    non-finite (the MCP pipeline coerces them first), but ``run_spec`` is a
    public interface and its docstring promises nothing malformed reaches
    the drawing.
    """
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{where} must be a number")
    try:
        number = float(value)
    except OverflowError as exc:  # a 400-digit int is a number but not a float
        raise ValueError(f"{where} must be a finite number") from exc
    if not math.isfinite(number):
        raise ValueError(f"{where} must be a finite number")
    return number
  • Step 4: Roll back on anything

In run_spec, replace

            crossings_total += drawn["crossings"]
    except Exception:
        await backend.transaction_rollback()
        raise
    await backend.transaction_commit()

with

            crossings_total += drawn["crossings"]
    except BaseException:
        # ``Exception`` alone let a client cancellation (``CancelledError`` is
        # a ``BaseException``) leave the placed symbols in the drawing inside
        # an open transaction — on COM with the undo mark still open, so every
        # later ``pid_from_spec`` was refused until a manual rollback.
        await backend.transaction_rollback()
        raise
    await backend.transaction_commit()

(await inside the handler is safe: once CancelledError has been thrown into the coroutine the task's cancel flag is cleared, so the rollback runs to completion before the re-raise; the test proves it.)

  • Step 5: The tool docstring names both

In server.py, pid_from_spec — replace

    drawn port-to-port, inside one transaction: any bad item rolls the whole
    sheet back and the error names it (`lines[2].to`). The response carries the
    graph read back from the drawing and the P&ID critique issues, so the
    caller sees dangling ends or duplicate tags in the same round trip.
    `dry_run` returns the planned vertices and crossings without touching the
    drawing.
    """

with

    drawn port-to-port, inside one transaction: any bad item rolls the whole
    sheet back and the error names it (`lines[2].to`); so does an interruption
    (a client cancellation mid-run leaves no half-drawn sheet and no open
    transaction). A non-finite coordinate, rotation, scale or stub is refused
    by path before anything is placed. The response carries the graph read
    back from the drawing and the P&ID critique issues, so the caller sees
    dangling ends or duplicate tags in the same round trip. `dry_run` returns
    the planned vertices and crossings without touching the drawing.
    """

Alias record (four synonyms, acad=()), pack pid, not lean — unchanged.

  • Step 6: Run the tests

Run: uv run --frozen pytest tests/test_pid_from_spec.py tests/test_pid_critique.py tests/test_pid_graph.py -q Expected: all PASS.

  • Step 7: Gates and commit

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check engineering server.py tests/test_pid_from_spec.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: all PASS.

git add engineering/pid/spec.py server.py tests/test_pid_from_spec.py
git commit -m "fix(pid): pid_from_spec refuses non-finite numbers by path and rolls back on any interruption

_num let NaN/inf (and a 400-digit int, as OverflowError) through, so an
INSERT was placed at [nan, 0] and the refusal, when it came, named the
line instead of equipment[0].x. run_spec caught Exception only, so a
CancelledError mid-run left the placed symbols inside an open transaction
(an open undo mark on COM). Finite check in _num; except BaseException.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 6: pid_line_draw — signal lines carry no fabricated number; a bubble exits towards the first waypoint

Measured on main: a pneumatic line from a bubble gets line_number "1", a "1" TEXT on PROCESS-LINE-TEXT, and the next process line is seq 2 — format_line_number drops the empty fields and leaves the bare sequence, but an ISA-5.1 signal line has no pipe line number. And _anchor snaps a radial port's exit towards the far end even when route is a waypoint list: bubble (100,160) r 5, route=[[130,160],[130,100]] → first vertex [100,155], first segment 4.93 mm from the centre (through the bubble). The dry run in spec.py::_plan_lines has the same anchor rule, so the pure helper aim_points serves both.

Files:

  • Modify: engineering/pid/lines.py (new aim_points, before label_placement)
  • Modify: engineering/pid/drawlines.py (_anchor, draw_line)
  • Modify: engineering/pid/spec.py (_plan_lines anchor)
  • Modify: server.py (pid_line_draw docstring), discovery/aliases.py (pid_line_draw synonyms)
  • Modify: tests/test_pid_deliverables.py::test_line_list_rows (its pinned numbering was the defect)
  • Modify: benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip (two assertion lines — final group's file, see the group constraints; the change is additive to the region the final group edits — F Task 25 appends _task_page_setup_truth directly after this method — and is named in the commit message for the merge agent; the skeleton's H ownership row lists this edit)
  • Test: tests/test_pid_line_draw.py (append)

Interfaces:

  • Produces: aim_points(start, end, mode) -> (aim_for_start, aim_for_end) — the other end, or with a waypoint list the first waypoint for the start and the last for the end (ValueError naming waypoints[i] on a non-finite one, the existing _finite_point rule). draw_line for LINE_CLASSES[cls].kind == "signal": line_number stays None unless given verbatim, label_handle is None unless a verbatim number was given, payload seq is None, the process seq is untouched.

  • Consumes: LineClass.kind ("process" | "signal"), _finite_point, snap_axis.

  • Step 1: Append the failing tests

# tests/test_pid_line_draw.py (append)


# ── Track A hardening (track E wave 0, Task 6) ───────────────────────────────


async def _bubble_and_valve(backend):
    cv = await place_symbol(backend, "globe", 100, 100, tag="FCV-1", actuator="diaphragm")
    fic = await place_symbol(
        backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary"
    )
    return cv, fic


async def test_signal_lines_carry_no_fabricated_number_and_do_not_consume_the_sequence(backend):
    """A signal line has no pipe line number: no auto number, no label, and the
    process sequence is left alone."""
    cv, fic = await _bubble_and_valve(backend)
    pump, vessel = await _pump_and_vessel(backend)
    signal = await draw_line(
        backend, {"handle": fic["handle"]}, {"handle": cv["handle"], "port": "signal"}, "pneumatic"
    )
    assert signal["line_number"] is None and signal["label_handle"] is None
    payload = await read_payload(backend, signal["handle"])
    assert payload["number"] is None and payload["seq"] is None
    labels = await backend.entity_count(type_filter="TEXT", layer_filter="PROCESS-LINE-TEXT")
    assert labels == 0, "no label text was written for the signal line"

    process = await draw_line(
        backend,
        {"handle": pump["handle"], "port": "discharge"},
        {"handle": vessel["handle"], "port": "N3"},
        size="100",
        service="P",
    )
    assert process["line_number"] == "100-P-1", "the signal line did not consume seq 1"
    assert (await read_payload(backend, process["handle"]))["seq"] == 1

    # A signal line given size/service still gets no auto number (kind rules).
    electric = await draw_line(
        backend, {"handle": fic["handle"]}, {"x": 160, "y": 160}, "electric", size="IA", service="S"
    )
    assert electric["line_number"] is None and electric["label_handle"] is None

    # A verbatim number is honoured and labelled, seq stays None.
    named = await draw_line(
        backend, {"handle": fic["handle"]}, {"x": 40, "y": 160}, "electric", line_number="IA-7"
    )
    assert named["line_number"] == "IA-7" and named["label_handle"]
    assert (await backend.entity_get(named["label_handle"])).properties["text"] == "IA-7"
    assert (await read_payload(backend, named["handle"]))["seq"] is None

    # The next process line is 2, not 4.
    second = await draw_line(
        backend,
        {"handle": vessel["handle"], "port": "N4"},
        {"x": 300, "y": 128},
        size="80",
        service="P",
    )
    assert second["line_number"] == "80-P-2"


async def test_a_bubble_exit_aims_at_the_first_waypoint(backend):
    """With waypoints the radial port must leave towards the first waypoint;
    aiming at the far end put the exit on the wrong side and the first segment
    cut through the bubble (measured: 4.93 mm from the centre of a r=5 bubble)."""
    fic = await place_symbol(
        backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary"
    )
    result = await draw_line(
        backend,
        {"handle": fic["handle"]},
        {"x": 100, "y": 100},
        "electric",
        route_mode=[[130, 160], [130, 100]],
    )
    assert result["vertices"] == [[105.0, 160.0], [130.0, 160.0], [130.0, 100.0], [100.0, 100.0]]
    # Every vertex of the drawn line is on or outside the bubble.
    for x, y in result["vertices"]:
        assert ((x - 100) ** 2 + (y - 160) ** 2) ** 0.5 >= 5.0 - 1e-9


async def test_a_bubble_entry_aims_at_the_last_waypoint(backend):
    fic = await place_symbol(
        backend, "instrument", 100, 160, tag="FIC-1", type="dcs", location="primary"
    )
    result = await draw_line(
        backend,
        {"x": 100, "y": 100},
        {"handle": fic["handle"]},
        "electric",
        route_mode=[[130, 100], [130, 160]],
    )
    assert result["vertices"] == [[100.0, 100.0], [130.0, 100.0], [130.0, 160.0], [105.0, 160.0]]


async def test_dry_run_plans_the_same_bubble_exit_as_the_real_run(backend):
    from engineering.pid.spec import run_spec

    spec = {
        "instruments": [
            {
                "id": "FIC-1",
                "type": "dcs",
                "location": "primary",
                "x": 100,
                "y": 160,
                "tag": "FIC-1",
            }
        ],
        "valves": [
            {
                "id": "FCV-1",
                "symbol": "globe",
                "actuator": "diaphragm",
                "x": 100,
                "y": 100,
                "tag": "FCV-1",
            }
        ],
        "lines": [
            {
                "from": "FIC-1",
                "to": "FCV-1.signal",
                "class": "electric",
                "route": [[130, 160], [130, 109]],
            }
        ],
    }
    planned = await run_spec(backend, spec, dry_run=True)
    assert planned["lines"][0]["vertices"][0] == [105.0, 160.0]
    drawn = await run_spec(backend, spec)
    edge = drawn["graph"]["edges"][0]
    assert edge["line_number"] is None, "signal lines from a spec are unnumbered too"
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_pid_line_draw.py -k "signal_lines_carry or bubble or dry_run_plans" -v Expected: 4 FAIL, 1 PASS (the Track A test_signal_line_from_a_bubble_gets_markers_and_no_arrow also matches and keeps passing). …no_fabricated_number… fails on assert ('1' is None); both waypoint tests fail on the first/last vertex [100.0, 155.0] != [105.0, 160.0]; the dry-run test fails on [100.0, 155.0] == [105.0, 160.0].

  • Step 3: The pure aim rule

In engineering/pid/lines.py, insert before def label_placement(...):

def aim_points(start, end, mode) -> tuple[Point, Point]:
    """What each end of a line aims at: the other end, or — with waypoints —
    the first waypoint for the start and the last one for the end.

    A radial (bubble) port leaves its circle along the axis towards its aim
    point. Aiming at the far end while the route is a waypoint list put the
    exit on the wrong side of the bubble, so the first segment cut through it.
    """
    if isinstance(mode, (list, tuple)) and len(mode) > 0:
        return _finite_point("waypoints[0]", mode[0]), _finite_point(
            f"waypoints[{len(mode) - 1}]", mode[-1]
        )
    return _finite_point("end", end), _finite_point("start", start)
  • Step 4: draw_line — aim, and the signal rule

In engineering/pid/drawlines.py, add aim_points, to the .lines import block (alphabetical, before count_crossings,):

from .lines import (
    AXIS,
    DEFAULT_NUMBER_FORMAT,
    LINE_CLASSES,
    PID_LINE_LAYERS,
    aim_points,
    count_crossings,
    format_line_number,
    label_placement,
    marker_positions,
    route,
    snap_axis,
)

Replace _anchor's head

def _anchor(endpoint: dict, other: dict) -> tuple[tuple[float, float], float | None]:
    """Where the line actually starts/ends: on the circle for radial ports."""
    if endpoint["radius"] > 0:
        axis = snap_axis(other["x"] - endpoint["x"], other["y"] - endpoint["y"])

with

def _anchor(endpoint: dict, aim: tuple[float, float]) -> tuple[tuple[float, float], float | None]:
    """Where the line actually starts/ends: on the circle for radial ports,
    leaving along the axis towards ``aim`` (see ``aim_points``)."""
    if endpoint["radius"] > 0:
        axis = snap_axis(aim[0] - endpoint["x"], aim[1] - endpoint["y"])

In draw_line, replace

    s_point, s_dir = _anchor(start, end)
    e_point, e_dir = _anchor(end, start)
    path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode)

with

    s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), route_mode)
    s_point, s_dir = _anchor(start, s_aim)
    e_point, e_dir = _anchor(end, e_aim)
    path = route(s_point, s_dir, e_point, e_dir, stub=stub, mode=route_mode)

and replace

    seq = max_seq + 1
    if line_number is None:
        fmt = number_format or DEFAULT_NUMBER_FORMAT
        fields = {
            "size": size,
            "service": service,
            "seq": seq,
            "spec": spec,
            "insulation": insulation,
        }
        line_number = format_line_number(fmt, **fields) or None

with

    if cls.kind == "signal":
        # An ISA-5.1 signal line carries no pipe line number: no fabricated
        # number, no label, and the process sequence is left alone. A
        # verbatim ``line_number`` is still honoured (and labelled).
        seq = None
    else:
        seq = max_seq + 1
        if line_number is None:
            fmt = number_format or DEFAULT_NUMBER_FORMAT
            fields = {
                "size": size,
                "service": service,
                "seq": seq,
                "spec": spec,
                "insulation": insulation,
            }
            line_number = format_line_number(fmt, **fields) or None

(line_payload(..., seq, ...) already accepts seq: int | None; the label block if label and line_number: already skips a None number.)

  • Step 5: The dry run aims the same way

In engineering/pid/spec.py, replace the import line

from .lines import AXIS, LINE_CLASSES, count_crossings, route, snap_axis

with

from .lines import AXIS, LINE_CLASSES, aim_points, count_crossings, route, snap_axis

and in _plan_lines replace

    def anchor(ep: dict, other: dict):
        if ep["radius"] > 0:
            axis = snap_axis(other["x"] - ep["x"], other["y"] - ep["y"])
            ux, uy = AXIS[axis]
            return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis
        return (ep["x"], ep["y"]), ep["direction_deg"]

    planned: list[dict] = []
    for index, (where, line) in enumerate(_all_lines(norm)):
        try:
            start, end = endpoint(line["from"]), endpoint(line["to"])
            s, sd = anchor(start, end)
            e, ed = anchor(end, start)
            vertices = route(
                s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=line.get("route", "auto")
            )

with

    def anchor(ep: dict, aim):
        if ep["radius"] > 0:
            axis = snap_axis(aim[0] - ep["x"], aim[1] - ep["y"])
            ux, uy = AXIS[axis]
            return (ep["x"] + ux * ep["radius"], ep["y"] + uy * ep["radius"]), axis
        return (ep["x"], ep["y"]), ep["direction_deg"]

    planned: list[dict] = []
    for index, (where, line) in enumerate(_all_lines(norm)):
        try:
            start, end = endpoint(line["from"]), endpoint(line["to"])
            mode = line.get("route", "auto")
            s_aim, e_aim = aim_points((start["x"], start["y"]), (end["x"], end["y"]), mode)
            s, sd = anchor(start, s_aim)
            e, ed = anchor(end, e_aim)
            vertices = route(s, sd, e, ed, stub=float(line.get("stub", 5.0)), mode=mode)
  • Step 6: The line-list test and the benchmark adapter assert the new truth

tests/test_pid_deliverables.py::test_line_list_rows pinned the defect ("3" for the pneumatic line). Replace

    numbered = [r for r in rows if r["line_number"]]
    # The signal line carries no size/service/spec, so its default-format
    # number is the bare sequence "3" (empty fields drop out, spec 6.4) — and
    # a natural sort files 3 before 100.
    assert [r["line_number"] for r in numbered] == ["3", "100-P-1-CS1", "100-P-2-CS1"]
    signal = numbered[0]
    assert signal["line_class"] == "pneumatic" and signal["number_source"] == "xdata"
    assert signal["from_tag"] == "FIC-101" and signal["to_tag"] == "FCV-101"
    assert signal["to_port"] == "signal"
    first = numbered[1]
    assert first["from_tag"] == "P-101" and first["from_port"] == "discharge"
    assert first["to_tag"] == "FCV-101" and first["length_mm"] > 0
    assert rows[-1]["line_number"] is None, "untagged rows sort last"
    assert rows[-1]["from_tag"] == "V-201" and rows[-1]["from_port"] == "N4"
    assert rows[-1]["to_tag"] is None and rows[-1]["number_source"] is None

with

    numbered = [r for r in rows if r["line_number"]]
    # An ISA-5.1 signal line carries no pipe line number (track E, Task 6):
    # the pneumatic line is unnumbered and did not consume a sequence number,
    # so the two process lines are 1 and 2.
    assert [r["line_number"] for r in numbered] == ["100-P-1-CS1", "100-P-2-CS1"]
    signal = next(r for r in rows if r["line_class"] == "pneumatic")
    assert signal["line_number"] is None and signal["number_source"] is None
    assert signal["from_tag"] == "FIC-101" and signal["to_tag"] == "FCV-101"
    assert signal["to_port"] == "signal"
    first = numbered[0]
    assert first["from_tag"] == "P-101" and first["from_port"] == "discharge"
    assert first["to_tag"] == "FCV-101" and first["length_mm"] > 0
    unnumbered = [r for r in rows if r["line_number"] is None]
    assert rows[-2:] == unnumbered, "untagged rows sort last"
    hand_drawn = next(r for r in unnumbered if r["from_tag"] == "V-201")
    assert hand_drawn["from_port"] == "N4"
    assert hand_drawn["to_tag"] is None and hand_drawn["number_source"] is None

benchmarks/adapters/autocad_mcp_pro.py::_task_pid_roundtrip asserts two digit-only sequenced rows (r["line_number"].isdigit() would now raise on the signal row's None). Replace

        # The spec numbers its two process lines; the signal line and the
        # connector line are left to the default ``number_format``, which with
        # no size/service/spec/insulation collapses to the bare sequence
        # number. Both must come back through XDATA, not a label search.
        authored = "100-P-1001-CS1"
        numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines}
        authored_ok = (
            numbers.get(("P-101", "FCV-101")) == authored
            and numbers.get(("FCV-101", "V-201")) == authored
        )
        sequenced = [r for r in lines if r["line_number"] != authored]
        sequenced_ok = len(sequenced) == 2 and all(
            r["line_number"].isdigit() and r["number_source"] == "xdata" for r in sequenced
        )

with

        # The spec numbers its two process lines; the connector line is left
        # to the default ``number_format``, which with no size/service/spec/
        # insulation collapses to the bare sequence number, and it must come
        # back through XDATA, not a label search. The electric signal line is
        # unnumbered by rule (ISA-5.1 signal lines carry no pipe line number)
        # and must not have consumed a sequence number: the connector is "3".
        authored = "100-P-1001-CS1"
        numbers = {(r["from_tag"], r["to_tag"]): r["line_number"] for r in lines}
        authored_ok = (
            numbers.get(("P-101", "FCV-101")) == authored
            and numbers.get(("FCV-101", "V-201")) == authored
        )
        others = [r for r in lines if r["line_number"] != authored]
        signal = [r for r in others if r["line_class"] == "electric"]
        sequenced = [r for r in others if r["line_class"] != "electric"]
        sequenced_ok = (
            len(signal) == 1
            and signal[0]["line_number"] is None
            and len(sequenced) == 1
            and sequenced[0]["line_number"] == "3"
            and sequenced[0]["number_source"] == "xdata"
        )

Then prove the benchmark still passes: uv run --frozen python benchmarks/run_competitors.py --matrix v4 --server autocad-mcp-pro --task pid_roundtrip → "passed": 1, "authored_line_numbers_kept": true, "unnumbered_lines_sequenced": true (measured on the prototype).

  • Step 7: Tool docstring and alias record

In server.py, pid_line_draw — replace the docstring's last two lines

    line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`,
    `L`, `o`) as small blocks on segments >= 15 mm; electric is dashed by layer.
    """

with

    line (branching is legal). Signal classes get ISA-5.1 markers (`//`, `X`,
    `L`, `o`) as small blocks on segments >= 15 mm; electric is dashed by layer.
    A signal line carries no pipe line number: it gets no auto-built number
    and no label, and does not consume the process sequence — a verbatim
    `line_number` is still written and labelled. With waypoints, a bubble's
    exit leaves towards the first waypoint (and the entry arrives from the
    last one); `crossings` follows existing arcs, not their chords.
    """

(The last clause is delivered by Task 7, the next commit on this branch.) In discovery/aliases.py, the pid_line_draw record gains two synonyms — replace

            "connect the pump to the vessel",
            "pneumatic signal",
        ),
    ),

with

            "connect the pump to the vessel",
            "pneumatic signal",
            "unnumbered signal line",
            "route a line through waypoints",
        ),
    ),

Pack pid, lean — unchanged.

  • Step 8: Run the tests

Run: uv run --frozen pytest tests/test_pid_line_draw.py tests/test_pid_lines.py tests/test_pid_deliverables.py tests/test_pid_from_spec.py tests/test_pid_graph.py tests/test_pid_critique.py tests/test_benchmark_v4.py -q Expected: all PASS.

  • Step 9: Gates and commit

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check engineering server.py discovery benchmarks tests/test_pid_line_draw.py tests/test_pid_deliverables.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: all PASS.

git add engineering/pid/lines.py engineering/pid/drawlines.py engineering/pid/spec.py server.py discovery/aliases.py tests/test_pid_line_draw.py tests/test_pid_deliverables.py benchmarks/adapters/autocad_mcp_pro.py
git commit -m "fix(pid): signal lines carry no fabricated number; a bubble exits towards the first waypoint

An ISA-5.1 signal line has no pipe line number, yet every pneumatic /
electric line got the bare sequence as its number and a label, and ate a
process sequence number. Signal classes now leave line_number None (a
verbatim one is still honoured), seq None, no label. A radial port aimed
at the far end even with waypoints, so the first segment cut through the
bubble; aim_points (lines.py) aims at the first/last waypoint for both
draw_line and the dry run.

Merge note: benchmarks/adapters/autocad_mcp_pro.py (final group's file)
had its two _task_pid_roundtrip assertion lines moved to the new truth —
the signal row is unnumbered, the connector row is 3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 7: pid_line_draw — tolerant existing-line payloads; crossings follow arcs, not chords

Measured on main: a P&ID-layer polyline carrying {"seq": True, "from": "garbage", "to": None} makes the next draw_line raise TypeError: string indices must be integers, not 'str' (a reader result must never raise); and an existing line with a bulge −1 semicircle (0,0)→(100,0) (apex (50,50)) is tested as its chord, so a utility line at x = 30 from y = 20 to 60 reports crossings 0 although it crosses the arc at y ≈ 45.8.

Bulge flattening is pure arithmetic in engineering/pid/lines.py (the package rule since engineering/measure.py: no ezdxf in the engineering package) using the same formulas measure.py and graph._point_segment_distance already use; the test pins it against ezdxf.math.bulge_to_arc + ConstructionArc.flattening as the oracle — measured on the prototype: identical vertex counts (13/13/9/7/19/6 for bulges 1, −1, 0.5, −0.3, 2, 0.2 at sagitta 0.05 on a 10 mm chord) and positions to < 1e-14.

Files:

  • Modify: engineering/pid/lines.py (new FLATTEN_SAGITTA, flatten_bulges, before _orient)
  • Modify: engineering/pid/drawlines.py (new _bulges, payload_port_refs, payload_seq; existing_pid_lines rows gain bulges; draw_line uses them)
  • Test: tests/test_pid_lines.py (append; add import math), tests/test_pid_line_draw.py (append)

Interfaces:

  • Produces: flatten_bulges(points, bulges, sagitta=FLATTEN_SAGITTA) -> list[Point] (straight edges pass through; a short bulges list is zero-padded; < 2 points returned as is). payload_port_refs(payload) -> list[tuple[str, str]] (only from/to that are dicts with string handle and string port), payload_seq(payload) -> int | None (type(seq) is int, so True and "9" are not sequence numbers). existing_pid_lines rows: {"handle", "vertices", "bulges", "payload"} (vertices unchanged — the COM test from Track A keeps passing).

  • Consumes: info.properties["bulges"] (both engines report it next to points since Track A Task 14; COM pays GetBulge only when Length exceeds the chord walk).

  • Step 1: Append the failing tests

tests/test_pid_lines.py — add import math above import pytest (a blank line between), then append:

# tests/test_pid_lines.py (append)


# ── Track A hardening (track E wave 0, Task 7): bulge flattening ─────────────


@pytest.mark.parametrize("bulge", [1.0, -1.0, 0.5, -0.3, 2.0, 0.2])
def test_flatten_bulges_matches_ezdxf_flattening(bulge):
    """Pinned against ezdxf's own arc flattening: same vertex count, same points."""
    from ezdxf.math import ConstructionArc, Vec2, bulge_to_arc

    from engineering.pid.lines import FLATTEN_SAGITTA, flatten_bulges

    a, b = (0.0, 0.0), (10.0, 0.0)
    mine = flatten_bulges([a, b], [bulge])
    centre, start_rad, end_rad, radius = bulge_to_arc(Vec2(a), Vec2(b), bulge)
    arc = ConstructionArc(
        center=centre,
        radius=radius,
        start_angle=math.degrees(start_rad),
        end_angle=math.degrees(end_rad),
    )
    reference = [(p.x, p.y) for p in arc.flattening(FLATTEN_SAGITTA)]
    assert len(mine) == len(reference)
    assert mine[0] == a and mine[-1] == b
    assert all(math.hypot(p[0] - centre.x, p[1] - centre.y) == pytest.approx(radius) for p in mine)
    assert mine[len(mine) // 2] == pytest.approx(reference[len(reference) // 2])


def test_flatten_bulges_passes_straight_edges_through_and_pads_short_bulge_lists():
    from engineering.pid.lines import flatten_bulges

    pts = [(0.0, 0.0), (10.0, 0.0), (10.0, 10.0)]
    assert flatten_bulges(pts, []) == pts
    assert flatten_bulges(pts, [0.0, 0.0, 0.0]) == pts
    flat = flatten_bulges(pts, [0.0, 1.0])
    assert flat[0] == (0.0, 0.0) and flat[1] == (10.0, 0.0) and flat[-1] == (10.0, 10.0)
    assert len(flat) > 3, "the second edge became a semicircle of chords"
    assert flatten_bulges([(1.0, 1.0)], [1.0]) == [(1.0, 1.0)]

tests/test_pid_line_draw.py — append:

# tests/test_pid_line_draw.py (append)


# ── Track A hardening (track E wave 0, Task 7) ───────────────────────────────


async def test_foreign_line_payloads_are_tolerated_not_raised(backend):
    """A hand-edited payload with a non-dict ``from``, a ``None`` ``to`` and a
    bool ``seq`` used to escape as ``TypeError: string indices must be
    integers``; reader results never raise."""
    poly = await backend.entity_create_polyline([[0, 0], [10, 0]], layer="PROCESS-PIPING-MAIN")
    await write_payload(
        backend,
        poly.handle,
        {"v": 1, "kind": "line", "seq": True, "from": "garbage", "to": None, "number": 7},
    )
    other = await backend.entity_create_polyline([[0, 5], [10, 5]], layer="PROCESS-PIPING-MAIN")
    await write_payload(
        backend,
        other.handle,
        {"v": 1, "kind": "line", "seq": "9", "from": {"handle": 3, "port": "x"}, "to": 4},
    )
    result = await draw_line(
        backend, {"x": 0, "y": 50}, {"x": 50, "y": 50}, size="100", service="P"
    )
    assert result["line_number"] == "100-P-1", "neither True nor '9' counts as a sequence number"
    assert result["port_reuse"] == []


def test_payload_helpers_accept_only_well_formed_values():
    from engineering.pid.drawlines import payload_port_refs, payload_seq

    good = {"from": {"handle": "2A", "port": "discharge"}, "to": {"handle": "2B", "port": None}}
    assert payload_port_refs(good) == [("2A", "discharge")]
    assert payload_port_refs("garbage") == [] and payload_port_refs(None) == []
    assert payload_port_refs({"from": "x", "to": 5}) == []
    assert payload_seq({"seq": 7}) == 7
    assert payload_seq({"seq": True}) is None and payload_seq({"seq": "9"}) is None
    assert payload_seq({"seq": 7.0}) is None and payload_seq(None) is None


async def test_crossings_follow_a_bulged_line_not_its_chord(backend):
    """A foreign P&ID line with a semicircular jump (bulge -1 from (0,0) to
    (100,0) bows to +Y, apex (50,50)): a vertical utility line at x=30 from
    y=20 to y=60 crosses the arc once at y≈45.8 and its chord never."""
    msp = backend._msp()
    msp.add_lwpolyline(
        [(0, 0, 0, 0, -1.0), (100, 0, 0, 0, 0)],
        format="xyseb",
        dxfattribs={"layer": "PROCESS-PIPING-MAIN"},
    )
    backend._mark_dirty()
    lines = await existing_pid_lines(backend)
    assert lines[0]["vertices"] == [(0.0, 0.0), (100.0, 0.0)]
    assert lines[0]["bulges"] == [-1.0, 0.0]
    result = await draw_line(backend, {"x": 30, "y": 20}, {"x": 30, "y": 60}, line_class="utility")
    assert result["crossings"] == 1
    below = await draw_line(backend, {"x": 70, "y": -20}, {"x": 70, "y": -60}, line_class="utility")
    assert below["crossings"] == 0, "the chord side of the arc is empty"


async def test_com_existing_lines_carry_bulges(monkeypatch):
    payload = line_payload("process_major", "100-P-7", "100", "P", None, None, 7, None, None)
    poly = _com_pid_polyline("3E", (0.0, 0.0, 100.0, 0.0), payload)
    poly.Length = 157.0796  # a semicircle: longer than the 100 mm chord walk
    poly.GetBulge = lambda index: -1.0 if index == 0 else 0.0
    backend = _fake_com(monkeypatch, [poly])
    lines = await existing_pid_lines(backend)
    assert lines[0]["vertices"] == [(0.0, 0.0), (100.0, 0.0)]
    assert lines[0]["bulges"] == [-1.0, 0.0]
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_pid_lines.py tests/test_pid_line_draw.py -k "flatten or payload or bulge" -v Expected: 11 FAIL. The seven test_pid_lines.py tests fail with ImportError: cannot import name 'FLATTEN_SAGITTA' / 'flatten_bulges' from 'engineering.pid.lines'; test_foreign_line_payloads_are_tolerated_not_raised with TypeError: string indices must be integers, not 'str'; test_payload_helpers_… with ImportError: cannot import name 'payload_port_refs'; both bulge tests with KeyError: 'bulges'.

  • Step 3: Pure flattening in lines.py

In engineering/pid/lines.py, insert before def _orient(a: Point, b: Point, c: Point) -> float::

#: Max chord deviation (mm) when a bulged polyline edge is flattened for the
#: crossing test — a tenth of the thinnest ISO 128 lineweight.
FLATTEN_SAGITTA = 0.05


def flatten_bulges(points, bulges, sagitta: float = FLATTEN_SAGITTA) -> list[Point]:
    """The polyline as straight segments, every bulged edge replaced by chords
    that deviate from the arc by at most ``sagitta``.

    Same arc arithmetic as ``engineering/measure.py`` (sweep ``4·atan(bulge)``,
    radius ``chord / 2·sin(|sweep|/2)``) and ``graph._point_segment_distance``
    (centre on the chord's left normal, ``radius - sagitta`` carrying it across
    the chord past a semicircle). Checked against ``ezdxf.math.bulge_to_arc``
    + ``ConstructionArc.flattening`` in ``tests/test_pid_lines.py``: identical
    vertex count and positions to 1e-12 for bulges 0.2 … 2.0 of either sign.
    A straight edge (bulge 0) is passed through; ``bulges`` shorter than the
    edge count is padded with zeros.
    """
    pts = [(float(x), float(y)) for x, y in points]
    if len(pts) < 2:
        return pts
    out: list[Point] = []
    for i, (a, b) in enumerate(zip(pts, pts[1:], strict=False)):
        out.append(a)
        bulge = float(bulges[i]) if i < len(bulges) and bulges[i] else 0.0
        chord = _dist(a, b)
        if abs(bulge) < 1e-12 or chord < 1e-12:
            continue
        theta = 4.0 * math.atan(bulge)  # signed sweep, radians
        radius = chord / (2.0 * math.sin(abs(theta) / 2.0))
        sag = abs(bulge) * chord / 2.0
        nx, ny = -(b[1] - a[1]) / chord, (b[0] - a[0]) / chord  # left of a→b
        offset = (radius - sag) * (1.0 if bulge > 0 else -1.0)
        cx = (a[0] + b[0]) / 2.0 + offset * nx
        cy = (a[1] + b[1]) / 2.0 + offset * ny
        start = math.atan2(a[1] - cy, a[0] - cx)
        if sagitta >= radius:
            segments = 1
        else:
            segments = max(1, math.ceil(abs(theta) / (2.0 * math.acos(1.0 - sagitta / radius))))
        for k in range(1, segments):
            angle = start + theta * k / segments
            out.append((cx + radius * math.cos(angle), cy + radius * math.sin(angle)))
    out.append(pts[-1])
    return out
  • Step 4: drawlines.py — tolerant readers, bulges on every row, flattened crossings

Add flatten_bulges, to the .lines import block (after count_crossings,):

    aim_points,
    count_crossings,
    flatten_bulges,
    format_line_number,

Insert after _vertices (before async def existing_pid_lines):

def _bulges(info, count: int) -> list[float]:
    """Per-vertex bulges as the engine reports them (both engines put a
    ``bulges`` list next to ``points``); straight when it reports none."""
    raw = info.properties.get("bulges") or []
    out = []
    for value in raw[:count]:
        try:
            out.append(float(value or 0.0))
        except (TypeError, ValueError):
            out.append(0.0)
    return out + [0.0] * (count - len(out))


def payload_port_refs(payload) -> list[tuple[str, str]]:
    """``(handle, port)`` for each well-formed ``from``/``to`` in a line payload.

    A foreign or hand-edited payload can carry anything under those keys
    (``"garbage"``, ``None``, a number); reading it used to raise
    ``TypeError`` out of ``draw_line``. Reader results never raise: a
    malformed end is simply not a reference.
    """
    refs = []
    if not isinstance(payload, dict):
        return refs
    for key in ("from", "to"):
        ref = payload.get(key)
        if not isinstance(ref, dict):
            continue
        handle, port = ref.get("handle"), ref.get("port")
        if isinstance(handle, str) and isinstance(port, str):
            refs.append((handle, port))
    return refs


def payload_seq(payload) -> int | None:
    """The payload's sequence number when it is a genuine int (``True`` is not)."""
    if not isinstance(payload, dict):
        return None
    seq = payload.get("seq")
    return seq if type(seq) is int else None

In existing_pid_lines, extend the docstring's last sentence and the row — replace

    on the other — every auto-built number would restart at 1 and no crossing
    or port reuse would ever be reported there.
    """

with

    on the other — every auto-built number would restart at 1 and no crossing
    or port reuse would ever be reported there. ``vertices`` are the raw
    vertices; ``bulges`` (one per vertex, DXF convention) let the crossing test
    follow an arc instead of its chord.
    """

and replace

            payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None
            out.append({"handle": info.handle, "vertices": _vertices(info), "payload": payload})

with

            payload = await read_payload(backend, info.handle) if info.type in _POLY_TYPES else None
            vertices = _vertices(info)
            out.append(
                {
                    "handle": info.handle,
                    "vertices": vertices,
                    "bulges": _bulges(info, len(vertices)),
                    "payload": payload,
                }
            )

In draw_line, replace

    existing = await existing_pid_lines(backend)
    crossings = count_crossings(path, [ln["vertices"] for ln in existing])
    used: set[tuple[str, str]] = set()
    max_seq = 0
    for ln in existing:
        payload = ln["payload"] or {}
        for key in ("from", "to"):
            ref = payload.get(key)
            if ref:
                used.add((ref["handle"], ref["port"]))
        if isinstance(payload.get("seq"), int):
            max_seq = max(max_seq, payload["seq"])

with

    existing = await existing_pid_lines(backend)
    crossings = count_crossings(
        path, [flatten_bulges(ln["vertices"], ln["bulges"]) for ln in existing]
    )
    used: set[tuple[str, str]] = set()
    max_seq = 0
    for ln in existing:
        used.update(payload_port_refs(ln["payload"]))
        seq = payload_seq(ln["payload"])
        if seq is not None:
            max_seq = max(max_seq, seq)

The tool docstring already states the arc rule (Task 6, Step 7); alias record, pack pid, lean — unchanged.

  • Step 5: Run the tests

Run: uv run --frozen pytest tests/test_pid_lines.py tests/test_pid_line_draw.py tests/test_pid_graph.py tests/test_pid_from_spec.py tests/test_pid_deliverables.py -q Expected: all PASS (the Track A COM test test_existing_lines_are_seen_on_the_com_engine still passes: vertices is unchanged and the new key is additive).

  • Step 6: Gates and commit

Run: uv run --frozen ruff check . && uv run --frozen ruff format --check engineering tests/test_pid_lines.py tests/test_pid_line_draw.py && uv run --frozen pytest tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree Expected: all PASS.

git add engineering/pid/lines.py engineering/pid/drawlines.py tests/test_pid_lines.py tests/test_pid_line_draw.py
git commit -m "fix(pid): existing line payloads are read tolerantly and crossings follow arcs, not chords

A foreign or hand-edited ACADMCP_PID payload (from: 'garbage', seq: True)
made the next pid_line_draw raise TypeError; payload_port_refs /
payload_seq now accept only well-formed values and a reader result never
raises. An existing polyline with a bulge was tested as its chord, so a
line through the arc reported crossings 0; flatten_bulges (pure, pinned
against ezdxf.math's flattening) feeds count_crossings on both engines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 8: docs/analysis/track-a-review-backlog.md — every minor finding with a disposition

The Track A review rounds produced ~170 minor findings (wave 1 and wave 2 as files in the orchestrator's scratchpad, waves 3–4 inside the workflow result payloads); the seven that are real defects are Tasks 1–7 of this plan. None of the findings is stored in the repository — the merge commits carry no body — so this task files all of them, de-duplicated (the Task 14 rounds repeated one another), one line each, with one of four dispositions: fixed-in-task-N (this plan), fixed-in-track-A (already resolved on main by a later Track A commit — the file cites the evidence), accepted-limitation (reason), deferred-1.7. Findings that were only about reporting (an unlisted file, an unreported deviation, a plan/spec wording gap) are filed as accepted with the reason, so the list is complete rather than curated.

Files:

  • Create: docs/analysis/track-a-review-backlog.md

Interfaces: none (documentation). The docs/analysis/ folder already holds the recon/diagnostics/roadmap dossiers, so no index needs updating; scripts/check_doc_numbers.py quotes no figure from it.

  • Step 1: Write the backlog
# Track A (v1.6 P&ID) — review backlog

**Source:** the plan-compliance and adversarial review rounds of Track A (2026-09-15/16),
waves 1–4. Blocking findings were fixed inside Track A (`fix(task-N)` commits) and are
not listed. Every *minor* finding is here, de-duplicated, with one disposition:

- **fixed-in-task-N** — Track E group H (`docs/superpowers/plans/_settings_part_H.md`)
- **fixed-in-track-A** — resolved on main by a later Track A commit (evidence cited)
- **accepted-limitation** — kept as is, with the reason
- **deferred-1.7** — a real item for the 1.7 roadmap

Reading key: `W1-T1-03` = wave 1, Track A Task 1, finding 3. Files are repository-relative.

## Wave 1

#### Track A Task 1 — `block_insert(attributes=…)` (ezdxf)

| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T1-01 | backends/ezdxf_backend.py | The undefined-block gate (`6f4bda5`) was added without being listed as a deviation. | accepted-limitation (reporting gap; the gate is kept and shared by Task 1) |
| W1-T1-02 | plan | Branch forked before the docs commits; no code diverged. | accepted-limitation (process; merged clean) |
| W1-T1-03 | backends/ezdxf_backend.py | `attributes` was coerced after `add_blockref`, so a non-mapping failed after the write and skipped `_mark_dirty`. | fixed-in-task-3 |
| W1-T1-04 | backends/ezdxf_backend.py | `str(v)` wrote Python reprs (`None`, `True`, a dict) into ATTRIBs on both engines. | fixed-in-task-3 |
| W1-T1-05 | backends/ezdxf_backend.py | Tag matching is exact-case; `{"tag": …}` silently drops against ATTDEF `TAG`. | deferred-1.7 (normalise keys to upper case; the server's own ATTDEF tags are upper-case by `ATTDEF_TAG_RE`) |
| W1-T1-06 | backends/ezdxf_backend.py | `*Model_Space` / `*Paper_Space` pass the existence check and insert a reference cycle. | fixed-in-task-1 |
| W1-T1-07 | backends/ezdxf_backend.py | `entity_create_block_ref` still accepts an undefined block; `doc.audit()` deletes the INSERT later. | fixed-in-task-1 |
| W1-T1-08 | tests/test_block_insert_attributes.py | `attributes=None` yields no ATTRIBs headlessly while COM's `InsertBlock` instantiates ATTDEF defaults; refusal types diverged (ValueError vs RuntimeError). | refusal type: fixed-in-task-1 (COM raises the same ValueError); default ATTRIBs: deferred-1.7 (engine divergence on ATTDEF defaults) |
| W1-T1-09 | backends/ezdxf_backend.py | `block_explode` drops the ATTRIB text. | fixed-in-task-2 |

#### Track A Task 2 — `backends/block_specs.py`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T2-01 | security.py | `illegal_symbol_name_chars` added outside the task's file list, unreported. | accepted-limitation (reporting gap; harmless, tested) |
| W1-T2-02 | backends/block_specs.py | `fullmatch`, layer-character gate and `_one_line` are stricter than the plan stated. | accepted-limitation (all serve the validate-before-write rule; pinned by tests) |
| W1-T2-03 | plan / spec | Spec §4.7 per-entity `color`/`linetype`/`lineweight` overrides were dropped by the plan; unknown keys are silently ignored. | deferred-1.7 (carry the three keys through or refuse unknown keys by index) |
| W1-T2-04 | backends/block_specs.py | `_scalar` let `10**400` escape as `OverflowError` instead of the named `TypeError`. | fixed-in-task-4 |
| W1-T2-05 | backends/block_specs.py | A non-list `entities` (dict, str, generator) is refused as "empty". | deferred-1.7 |
| W1-T2-06 | backends/block_specs.py | `_layer` gates characters but not the 255-char length rule. | deferred-1.7 |
| W1-T2-07 | backends/block_specs.py | Refusal messages echo the caller's value unbounded (`got {value!r}`; measured 2 MB messages). | deferred-1.7 (truncate with reprlib) |
| W1-T2-08 | backends/block_specs.py | Same as W1-T2-03 (unknown keys such as `rotation` silently dropped). | deferred-1.7 (with W1-T2-03) |

#### Track A Task 6 — ISA-5.1 tag grammar

| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T6-01 | engineering/pid/tags.py | Three grammar changes (X user-defined, no output after V, O/C only after ZS) unreported. | accepted-limitation (the fixture is the contract; documented in `describe_tables()['rules']`) |
| W1-T6-02 | engineering/pid/tags.py | Two of those go past the spec §8 wording. | accepted-limitation (spec text to be reconciled by the 1.7 docs pass) |
| W1-T6-03 | spec | Spec example meanings (`Flow rate`/`Indicate`/`Control`) differ from the tables (`Flow`/`Indicator`/`Controller`). | accepted-limitation (keys match; consumers pin the tables) |
| W1-T6-04 | engineering/pid/tags.py | The "unexpected letter" error names the first trailing letter, not the offender (`LAHX-1` → reports H). | deferred-1.7 |
| W1-T6-05 | engineering/pid/tags.py | `equipment_prefixes={}` falls back to the default map (`or` instead of `is None`); non-string values pass through. | deferred-1.7 (also W4-T19-05) |
| W1-T6-06 | tests/data/isa51_tags.json | No fixture row for K (rate of change / control station), M trailing, O readout, B/N/X user-defined readouts. | deferred-1.7 (≈5 rows) |
| W1-T6-07 | engineering/pid/tags.py | O/C trailing narrowed to Z-first tags beyond the spec text. | accepted-limitation (documented resolution of a spec/fixture conflict) |

#### Track A Task 12 — `engineering/pid/lines.py`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W1-T12-01 | engineering/pid/lines.py | `axis_direction`, the `string.Formatter` parser and finiteness checks were added unreported. | accepted-limitation (reporting gap; pinned by tests) |
| W1-T12-02 | engineering/pid/__init__.py | Created outside the task's file list (branch predated Task 5). | accepted-limitation (byte-identical to Task 5's) |
| W1-T12-03 | engineering/pid/lines.py | `direct` and waypoint modes accept a negative / NaN `stub`. | deferred-1.7 (hoist the stub check above the mode branch) |
| W1-T12-04 | engineering/pid/lines.py | A malformed waypoint (`{"x":…}`, a flat list) escapes as `KeyError` / `TypeError` instead of `ValueError` naming `waypoints[i]`. | deferred-1.7 (also W3-T13-11) |
| W1-T12-05 | engineering/pid/lines.py | `count_crossings` undercounts a crossing that lands exactly on the other polyline's vertex. | deferred-1.7 (reported metric; orthogonal auto routes cannot produce it) |
| W1-T12-06 | engineering/pid/lines.py | A format spec containing `-` (`{seq:-05d}`) is refused with a misleading message. | accepted-limitation (a refusal, not a wrong number; hyphen inside a field spec is unsupported) |
| W1-T12-07 | engineering/pid/lines.py | `label_placement([(0,0)])` raises an unnamed error; a closed-loop waypoint route is accepted; `{{seq}}` is copied through. | accepted-limitation (unreachable through the tools; cosmetic) |

## Wave 2

#### Track A Task 3 — `block_define`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T3-01 | README.md | Snapshot bumped while lines 23/30/106 still said 154. | fixed-in-track-A (Task 21 README rewrite: "166 tools in 20 groups") |
| W2-T3-02 | tests/test_tool_registry.py | Frozen snapshot edited outside the file list. | accepted-limitation (required by the gate; the precedent every later task follows) |
| W2-T3-03 | backends/ezdxf_backend.py | `base_x`/`base_y` only `float()`-cast; NaN written into the BLOCK record. | fixed-in-task-4 |
| W2-T3-04 | backends/ezdxf_backend.py | A primitive layer is written without a layer-table entry; COM would fail mid-loop. | fixed-in-task-4 |
| W2-T3-05 | README.md | Same as W2-T3-01. | fixed-in-track-A |
| W2-T3-06 | backends/ezdxf_backend.py | ATTDEF linetype stays BYLAYER on ezdxf vs ByBlock on COM; neither sets lineweight ByBlock. | deferred-1.7 (cosmetic; layer-0 members resolve to the INSERT's layer) |
| W2-T3-07 | backends/ezdxf_backend.py | `attdefs or []` turns `{}`/`''` into "no ATTDEFs"; a non-str `name` raises AttributeError. | deferred-1.7 |
| W2-T3-08 | backends/ezdxf_backend.py | `block_insert` without `attributes` carries none of the ATTDEF defaults (COM does). | deferred-1.7 (with W1-T1-08) |

#### Track A Task 4 — XDATA

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T4-01 | discovery/aliases.py | `entity_get_xdata` ships `XDLIST` only (XDATA would break the single-route gate). | accepted-limitation (still discoverable; reporting gap) |
| W2-T4-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W2-T4-03 | backends/ezdxf_backend.py | APPID names are case-sensitive headlessly; `APP_A` and `app_a` become two groups and double-count the budget. | deferred-1.7 |
| W2-T4-04 | backends/com_backend.py | A no-op removal registers the APPID on COM but not on ezdxf. | deferred-1.7 |
| W2-T4-05 | backends/xdata_specs.py | 1004 binary chunks decode to the repr of a bytes object. | deferred-1.7 (hex string) |
| W2-T4-06 | backends/xdata_specs.py | The reserved-app rule blocks reading `ACAD` by name while the unfiltered read returns it; other AutoCAD-owned apps are writable. | deferred-1.7 |
| W2-T4-07 | README.md | Snapshot paragraph contradicted itself; collected-tests drift. | fixed-in-track-A (Task 21) |

#### Track A Task 5 — `engineering/pid/xdata.py`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T5-01 | engineering/pid/__init__.py | Listed as "create" but already existed. | accepted-limitation (nothing missing) |
| W2-T5-02 | tests/test_pid_xdata.py | Sync tests under a module-level `pytest.mark.asyncio` emit PytestWarnings. | deferred-1.7 (hygiene sweep across the pid test modules) |
| W2-T5-03 | spec | Spec payload (`actuator`, 3-element ports, no `seq`) vs plan (`variant`, 5-element ports, `seq`). | accepted-limitation (plan governs; spec text to reconcile) |
| W2-T5-04 | engineering/pid/xdata.py | `encode_payload` uses `allow_nan=True`; a NaN would be serialised as a non-RFC literal. | deferred-1.7 (`allow_nan=False`; no current caller can deliver one after Task 5) |
| W2-T5-05 | engineering/pid/xdata.py | `symbol_payload(params={})` is replaced by the spec's params (truthiness). | deferred-1.7 |
| W2-T5-06 | engineering/pid/xdata.py | Duplicate port names collapse silently (last wins). | deferred-1.7 (refuse in `make_spec`) |
| W2-T5-07 | engineering/pid/xdata.py | A non-dict payload is written and can never be read back. | deferred-1.7 |
| W2-T5-08 | tests/test_pid_xdata.py | No multi-chunk payload round trip through a backend. | deferred-1.7 (test gap; the path was verified by hand) |

#### Track A Task 7 — symbol framework

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T7-01 | engineering/pid/symbols.py | Per-type outlines (no circle for computer/plc) deviate from the plan snippet, unreported. | accepted-limitation (the spec's ISA table governs; tests added) |
| W2-T7-02 | tests/test_pid_symbols.py | Unused import dropped, `math` added. | accepted-limitation (no functional effect) |
| W2-T7-03 | engineering/pid/symbols.py | Rear-location dashed lines stop up to 1 mm short on the right. | accepted-limitation (cosmetic; no port, bbox or gate depends on it) |
| W2-T7-04 | engineering/pid/symbols.py | A single radial `signal` port cannot describe the hexagon/square outlines (0.67 mm gap on vertical lines into `computer`). | accepted-limitation (spec §4.4 model; a per-axis reach is a 1.7 item) |
| W2-T7-05 | engineering/pid/symbols.py | `all_specs()` swallows builder ValueErrors and nothing pins the catalogue size. | deferred-1.7 (assert `len(all_specs())`) |
| W2-T7-06 | engineering/pid/symbols.py | `register()` overwrites silently; `list_symbols()` returns variants by reference. | deferred-1.7 |
| W2-T7-07 | engineering/pid/geometry.py | `bbox_of` samples arcs at 1° and can under-report by 3.8e-5·r. | accepted-limitation (sub-lineweight) |

#### Track A Task 8 — valves

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T8-01 | plan | The plan file was edited to match the shipped geometry, unreported. | accepted-limitation (the original geometry floated the dome; evidence in the commit) |
| W2-T8-02 | engineering/pid/symbols_valves.py | `check`/`relief` advertise actuator variants they refuse. | deferred-1.7 (variants `["none"]` for those bodies) |
| W2-T8-03 | engineering/pid/symbols_valves.py | The diaphragm *body* dome floats 1 mm above the bow-tie. | deferred-1.7 (cosmetic) |
| W2-T8-04 | engineering/pid/symbols_valves.py | The `signal` port and the TAG/DESC text share x = 0 above the actuator; a signal line crosses the tag. | accepted-limitation (spec §4.7 placement; a tag/line overlap critique is a 1.7 item, with W2-T9-05) |

#### Track A Task 9 — equipment

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T9-01 | engineering/pid/symbols_equipment.py | `pd_pump` ports are `in`/`out`, spec says `suction`/`discharge`. | accepted-limitation (plan governs; spec text to reconcile; `pid_from_spec` names ports explicitly) |
| W2-T9-02 | engineering/pid/symbols_equipment.py | `spectacle_blind` cites ISO 10628-2 instead of PIP PIC001; no spec names its figure/table. | deferred-1.7 (provenance strings) |
| W2-T9-03 | engineering/pid/symbols_equipment.py | Same as W2-T9-01. | accepted-limitation |
| W2-T9-04 | engineering/pid/symbols_equipment.py | `build_equipment` ignores `shape` for non-reducers and its signature is narrower than `**options`. | accepted-limitation (`resolve()` validates options; builders are internal) |
| W2-T9-05 | engineering/pid/symbols_equipment.py | TAG/DESC sit on the outward ray of every +Y port. | accepted-limitation (with W2-T8-04) |
| W2-T9-06 | engineering/pid/symbols_equipment.py | `air_cooler` fan cross pokes 0.046 mm outside its circle. | deferred-1.7 (cosmetic, ±1.75) |
| W2-T9-07 | engineering/pid/symbols_equipment.py | Seven origins are not the bbox centre (spec §4.2). | accepted-limitation (deliberate mounting points; spec text to reconcile) |

#### Track A Task 10 — vessels

| ID | File | Finding | Disposition |
|---|---|---|---|
| W2-T10-01 | engineering/pid/geometry.py | `scan_hits` added outside the file list, unreported. | accepted-limitation (additive, tested) |
| W2-T10-02 | engineering/pid/symbols_vessels.py | Stricter size rules and outline-based stubs, unreported. | accepted-limitation (evidence in the commit; plan tests unchanged) |
| W2-T10-03 | spec | `PID_VESSEL_VERTICAL_` vs `PID_VESSEL_VERTICAL_VESSEL_`. | accepted-limitation (plan's general rule governs) |
| W2-T10-04 | engineering/pid/symbols_vessels.py | Non-finite sizes/fractions pass `validate_vessel_params` (NaN spec, `RuntimeError` for inf). | deferred-1.7 (`math.isfinite` next to `> 0`) |
| W2-T10-05 | engineering/pid/symbols_vessels.py | Column tray lines poke through dished heads (up to 1.8 mm). | deferred-1.7 (clip to the outline with `scan_hits`) |
| W2-T10-06 | engineering/pid/symbols_vessels.py | Nozzle fractions near 0/1 on curved/sloped sides give degenerate long stubs from a corner. | deferred-1.7 |
| W2-T10-07 | engineering/pid/symbols_vessels.py | `trays` is unbounded (1 000 000 trays build in 6.3 s / 272 MB). | deferred-1.7 (cap by spacing ≥ 1 mm) |
| W2-T10-08 | engineering/pid/symbols_vessels.py | Two nozzles at the same side+fraction duplicate the stub; `-0.0` hashes to a different block name. | deferred-1.7 |

## Waves 3–4

#### Track A Task 11 — `pid_symbol_list` / `pid_symbol_insert`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T11-01 | engineering/layers.py | `PROCESS-LINE-TEXT` inserted at position 9; spec said "16th entry". | accepted-limitation (count matches; ordinal wording only) |

#### Track A Task 13 — `pid_line_draw`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T13-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T13-02 | discovery/aliases.py | `PLINE` added to `SHARED_ACAD_COMMANDS`. | accepted-limitation (required by the single-route gate; documented in the allowlist comment) |
| W3-T13-03 | backends/com_backend.py | `e924b5d` touched three files outside the list, unreported (COM `AcDbPolyline` naming, mirrored ports). | accepted-limitation (both were defects; tests added) |
| W3-T13-04 | server.py | `>=` for `≥` in a docstring. | accepted-limitation (cosmetic) |
| W3-T13-05 | engineering/pid/drawlines.py | Line sequence is per current space, not per document (spec §6.3). | deferred-1.7 (document-wide scan of existing lines) |
| W3-T13-06 | engineering/pid/drawlines.py | Every signal line got a fabricated number and label and consumed the process sequence. | fixed-in-task-6 |
| W3-T13-07 | engineering/pid/drawlines.py | A bubble's exit aimed at the far end even with waypoints, so the first segment cut through the bubble. | fixed-in-task-6 |
| W3-T13-08 | engineering/pid/drawlines.py | A foreign/hand-edited payload with a non-dict `from`/`to` raised `TypeError` out of `draw_line`. | fixed-in-task-7 |
| W3-T13-09 | engineering/pid/drawlines.py | (a) crossings tested against chords, not arcs; (b) COM heavy 2DPOLYLINE `Coordinates` read with stride 2. | (a) fixed-in-task-7; (b) deferred-1.7 (heavy polylines the tool never creates) |
| W3-T13-10 | README.md | Snapshot line edited without the collected-tests figure. | fixed-in-track-A (Task 21) |
| W3-T13-11 | server.py | An unknown endpoint handle and a flat waypoint list surface as "Internal error" rather than a named refusal. | deferred-1.7 (with W1-T12-04) |

#### Track A Task 14 — `pid_graph`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T14-01 | engineering/pid/graph.py | Block-name keywords match whole tokens, not substrings (spec §9.2); `GATEVALVE` no longer classifies. | accepted-limitation (the plan's own test demands it; documented in code) |
| W3-T14-02 | backends/ezdxf_backend.py | A public `bulges` key was added to every LWPOLYLINE `entity_get`, unreported. | accepted-limitation (additive; spec §9.4 "geometry from the drawing"; now consumed by Task 7) |
| W3-T14-03 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T14-04 | engineering/pid/graph.py | Edge dict carries `length_approximate` and (with geometry) `bulges` beyond the spec. | accepted-limitation (additive) |
| W3-T14-05 | tests/test_pid_graph.py | Three sync tests under the asyncio mark warn. | deferred-1.7 (hygiene, with W2-T5-02) |
| W3-T14-06 | engineering/pid/graph.py | `_Edge.segments` omits a closed polyline's closing edge while `length` includes it. | deferred-1.7 |
| W3-T14-07 | engineering/pid/graph.py | The dangling `nearest` hint never names a radial (bubble) port. | deferred-1.7 |
| W3-T14-08 | engineering/pid/graph.py | Text-tag fallback searched a circle, not "above the box". | fixed-in-track-A (`d17a261`, `6ad3734`: tags above the box, `LINE_NUMBER_RE` excluded) |
| W3-T14-09 | engineering/pid/graph.py | `scope="all"` restores `current` but not the paper-space layout binding. | deferred-1.7 |
| W3-T14-10 | engineering/pid/graph.py | A −Z-extruded catalogue INSERT reported mirror-image ports at confidence 1.0. | fixed-in-track-A (`6ad3734`: both engines report `mirrored`; `transform_port(mirrored=)`) |
| W3-T14-11 | engineering/pid/graph.py | A `PID_*` INSERT whose XDATA was stripped falls to the 0.6 heuristic with no ports (spec says catalogue 1.0). | deferred-1.7 |
| W3-T14-12 | README.md | Release-consistency gate red on the collected-tests figure. | fixed-in-track-A (Task 21) |
| W3-T14-13 | engineering/pid/symbols.py | `6ad3734` touched five files outside the list; `transform_port` grew `mirrored`/`strict`. | accepted-limitation (justified; interface kept by keyword defaults) |
| W3-T14-14 | engineering/pid/graph.py | `_text_near` accepts a baseline half a height inside the box and excludes line-number texts — beyond the spec wording. | accepted-limitation (documented, tested) |
| W3-T14-15 | engineering/pid/graph.py | Node dicts gained `y_scale`, `mirrored`, `notes`, `space`, `description`; junctions `space`. | accepted-limitation (additive) |
| W3-T14-16 | docs (plan/spec) | `ruff format --check` was red on the plan and spec Markdown. | fixed-in-track-A (Task 21 formatted them) |
| W3-T14-17 | spec | §9.1 model not extended for the added node/junction keys. | deferred-1.7 (docs) |
| W3-T14-18 | engineering/pid/symbols.py | `strict=False` keeps a diagonal port's local angle under non-uniform scale. | accepted-limitation (unreachable: all 243 catalogue ports are axis-aligned) |
| W3-T14-19 | backends/ezdxf_backend.py | `mirrored` is `True` for a tilted INSERT with a negative-z normal (COM twin too). | deferred-1.7 (`flat and z < 0`) |

#### Track A Task 15 — deliverables

| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T15-01 | tests/test_pid_deliverables.py | Four plan deviations (line-list rows, `_far_ends`, CSV refusal, `deliverable()` ValueError) unreported. | accepted-limitation (each stronger than the plan; the plan's own numbers were wrong) |
| W3-T15-02 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W3-T15-03 | tests/_probe_task15.py | A stray probe file appeared during review. | fixed-in-track-A (not in the tree) |
| W3-T15-04 | engineering/pid/deliverables.py | `natural_key` crashes on `str.isdigit()` runs that are not `\d` (①, ²). | deferred-1.7 (`isdecimal`) |
| W3-T15-05 | engineering/pid/deliverables.py | `_far_ends` resolves junctions at the ends of the instrument's line only, not on it. | deferred-1.7 |
| W3-T15-06 | engineering/pid/deliverables.py | `_tag_of` writes the internal junction id into a tag column; untagged node ends are `None` vs handle in the index. | deferred-1.7 |
| W3-T15-07 | engineering/pid/deliverables.py | `write_csv` edge cases: 0-byte file for zero rows, `csv_path=''` treated as None, directory path escapes as PermissionError, dead `mkdir`, late validation. | deferred-1.7 |
| W3-T15-08 | engineering/pid/deliverables.py | CSV formula injection: cells starting with `=+-@` are written verbatim. | deferred-1.7 (first item of the 1.7 deliverables pass) |
| W3-T15-09 | server.py | The three deliverable tools expose only `tolerance`/`scope`, not `label_search`/`include_foreign`. | deferred-1.7 |
| W3-T15-10 | engineering/pid/deliverables.py | `signal_lines` counts every edge on a bubble regardless of class. | deferred-1.7 |

#### Track A Task 16 — critique focuses

| ID | File | Finding | Disposition |
|---|---|---|---|
| W3-T16-01 | tests/test_pid_critique.py | The refine test calls `refine_drawing` (no backend `drawing_refine` exists), unreported. | accepted-limitation (the plan's call could not run; stricter test) |
| W3-T16-02 | engineering/pid/critique.py | `has_pid_content` guard instead of `not graph['nodes']`, unreported. | accepted-limitation (spec intent; pinned by tests) |
| W3-T16-03 | engineering/pid/critique.py | `_incompatible` skips inferred ports; `_dangling` reports unknown_block ends — beyond the plan. | accepted-limitation (spec §9.4 / §11.1; tested) |
| W3-T16-04 | tests/test_pid_critique.py | Two sync tests warn under the asyncio mark. | deferred-1.7 (hygiene) |
| W3-T16-05 | engineering/pid/critique.py | `_untagged` tests the composed tag, not FUNC/LOOP; an empty LOOP files as `pid_illegal_tag`. | deferred-1.7 |
| W3-T16-06 | engineering/pid/critique.py | A mechanical block whose name holds a keyword token (`OIL_FILTER`) counts as P&ID content and can fail finalize. | accepted-limitation (spec §9.2 mandates the heuristic; README "Known limitations" states it) |
| W3-T16-07 | engineering/pid/critique.py | `_duplicate_tags` scores a text-derived (guessed) tag as a gate-failing error. | deferred-1.7 (warning for `tag_source: text`) |

#### Track A Task 17 — `pid_from_spec`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T17-01 | tests/test_pid_from_spec.py | Four reported deviations verified legitimate (save_path, score dict, async test, snapshot). | accepted-limitation |
| W4-T17-02 | engineering/pid/spec.py | The module is a refactor of the plan's listing; interfaces preserved. | accepted-limitation |
| W4-T17-03 | README.md | `pid-showcase.png` rendered but not referenced. | fixed-in-track-A (README line 20 embeds it) |
| W4-T17-04 | tests/test_release_consistency.py | Collected-tests drift. | fixed-in-track-A (Task 21) |
| W4-T17-05 | engineering/pid/spec.py | `_num` accepts NaN/inf; an INSERT lands at `[nan, 0]` and `build_graph` crashes. | fixed-in-task-5 |
| W4-T17-06 | engineering/pid/spec.py | `run_spec` catches `Exception` only; a cancellation leaves an open transaction. | fixed-in-task-5 |
| W4-T17-07 | engineering/pid/spec.py | `dry_run` skips `place_symbol`'s option checks (`scale=-1` approved; wrong item blamed). | deferred-1.7 (`validate_spec` owns scale/fail/actuator rules) |
| W4-T17-08 | engineering/pid/spec.py | `dry_run` crossings count the spec's own lines only, unlabelled. | deferred-1.7 (`crossings_scope` field) |
| W4-T17-09 | engineering/pid/spec.py | Nested transactions: ezdxf nests, COM refuses; `drawing_plan` is set before the refusal. | deferred-1.7 |
| W4-T17-10 | engineering/pid/spec.py | The `sheet` block is not validated (unknown layer set / sheet size / keys accepted). | deferred-1.7 |
| W4-T17-11 | engineering/pid/spec.py | String-typed optional keys unvalidated (`arrow: "no"` truthy; `number: 123` bare TypeError; `id` with a dot). | deferred-1.7 |
| W4-T17-12 | README.md | README showcase half incomplete; gate red. | fixed-in-track-A (Task 21) |

#### Track A Task 19 — tag parse, resources, prompt

| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T19-01 | tests/test_tool_registry.py | Snapshot edited outside the file list. | accepted-limitation (gate precedent) |
| W4-T19-02 | scripts/render_pid_catalog.py | Renders through its own matplotlib path instead of `view_screenshot`, unreported. | accepted-limitation (byte-reproducible output; interface kept) |
| W4-T19-03 | README.md | Lines 23/106 still said 154 / 6 resources. | fixed-in-track-A (Task 21) |
| W4-T19-04 | server.py | The prompt's OFF-PAGE example emits a tag warning every time it is followed (`TO P&ID-002` parsed as ISA-5.1). | deferred-1.7 (skip `parse_tag` for connectors at insert) |
| W4-T19-05 | engineering/pid/tags.py | `equipment_prefixes={}` discarded; non-string values pass. | deferred-1.7 (with W1-T6-05) |
| W4-T19-06 | README.md | Release gate red on the collected-tests figure. | fixed-in-track-A (Task 21) |

#### Track A Task 18 — `TOOL_PACKS`

| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T18-01 | tests/test_tool_packs.py | A sync test warns under the asyncio mark. | deferred-1.7 (hygiene) |
| W4-T18-02 | README.md / config.py / .env.example | Token table and prose said 47 while lean has 50. | README: fixed-in-track-A (Task 21); `config.py:99` / `.env.example:52` "~47": deferred-1.7 (docs) |
| W4-T18-03 | server.py | `TOOL_PACKS=all,mech3000` drops the unknown entry without a warning or `ignored`. | deferred-1.7 |
| W4-T18-04 | server.py | `_enabled_packs()` runs twice per apply; unknown entries warn twice and duplicate in `ignored`. | deferred-1.7 |
| W4-T18-05 | tests/test_tool_packs.py | The pack test compares against a hand-copied set, never the registry. | deferred-1.7 (assert against `_registered_tools()` tags) |
| W4-T18-06 | server.py | Under `TOOL_PACKS=core` the P&ID prompt and resources are still advertised. | accepted-limitation (spec §12 scopes packs to tools) |

#### Track A Task 20 — benchmark v4

| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T20-01 | scripts/check_doc_numbers.py | The gate short-circuited on the 29-vs-26 A/B drift. | fixed-in-track-A (Task 21 regenerated the A/B report) |
| W4-T20-02 | CLAUDE.md | An extra clause beyond the dictated sentence. | accepted-limitation (accurate) |
| W4-T20-03 | benchmarks/README.md | "16/16 on the v4 matrix" has no published artifact behind it. | deferred-1.7 (publish a v4 reference report at the 1.6 release) |
| W4-T20-04 | tests/test_benchmark_v4.py | No test executes `_task_pid_roundtrip`. | deferred-1.7 (Task 6 of the hardening plan runs it by hand) |
| W4-T20-05 | benchmarks/README.md | A/B rows said 29 checks over a 26-check report. | fixed-in-track-A (Task 21) |

#### Track A Task 21 — docs and smoke

| ID | File | Finding | Disposition |
|---|---|---|---|
| W4-T21-01 | scripts/smoke_pid_com.py | Not re-run live during review (no AutoCAD running). | accepted-limitation (the implementer's live run is recorded in the CHANGELOG) |
| W4-T21-02 | README.md | "Known limitations of the P&ID track" was not a plan item. | accepted-limitation (claims verified against the code) |
| W4-T21-03 | docs (plan) | The plan file was ruff-formatted outside the file list. | accepted-limitation (mechanical) |
| W4-T21-04 | benchmarks/README.md | Tables moved for the new gate labels. | accepted-limitation (required by `check_doc_numbers.py`) |
| W4-T21-05 | CHANGELOG.md | "12.5 % short on a semicircular jump" is the v1.5 square figure; the tested geometry is 10.25 %. | deferred to the final docs task of Track E (CHANGELOG `Unreleased` edit) |
| W4-T21-06 | README.md | The snapshot is labelled v1.5 while quoting the 1.6-dev surface. | accepted-limitation (pinned to pyproject's minor until the 1.6 bump) |
| W4-T21-07 | scripts/smoke_pid_com.py | Exit code checks `dangling` only; critique/confidence/xdata are printed, not asserted. | deferred-1.7 |
| W4-T21-08 | CHANGELOG.md | `LAYER_SET_INTENT_MISMATCH` is claimed for `drawing_plan`, which only appends a warning string. | deferred to the final docs task of Track E |

## Totals

165 rows. Fixed in this track (Tasks 1–7): 15 · fixed inside Track A: 17 · accepted with a reason: 62 · deferred (1.7 or the Track E docs task): 71.
  • Step 2: Check the numbers and the links

Run:

uv run --frozen python - <<'EOF'
import re, pathlib
text = pathlib.Path("docs/analysis/track-a-review-backlog.md").read_text(encoding="utf-8")
rows = [line for line in text.splitlines() if re.match(r"\| W\d-T\d+-\d+ \|", line)]
ids = [row.split("|")[1].strip() for row in rows]
assert len(ids) == len(set(ids)), "duplicate ids"
kinds = {"fixed-in-task": 0, "fixed-in-track-A": 0, "accepted-limitation": 0, "deferred": 0}
for row in rows:
    disposition = row.rsplit("|", 2)[1]
    for key in kinds:
        if key in disposition:
            kinds[key] += 1
            break
    else:
        raise SystemExit(f"no disposition: {row}")
print(len(rows), kinds)
EOF

Expected: 165 {'fixed-in-task': 15, 'fixed-in-track-A': 17, 'accepted-limitation': 62, 'deferred': 71} — the Totals line; no duplicate ids.

  • Step 3: Commit
git add docs/analysis/track-a-review-backlog.md
git commit -m "docs(analysis): Track A review backlog — every minor finding with a disposition

Waves 1-4 of the Track A review produced ~170 minor findings that lived
only in the orchestrator's scratchpad. Filed de-duplicated, one line each:
fixed-in-task-N (this track's hardening tasks), fixed-in-track-A (with the
commit), accepted-limitation (with the reason) or deferred-1.7.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Group H exit

After Task 8 the track-e/hardening worktree carries eight commits and no file outside its ownership except the two adapter lines named in Task 6. Before handing the branch to the merge agent run the whole suite once:

uv run --frozen pytest -q --deselect tests/test_release_consistency.py::test_document_numbers_match_the_tree

Expected: everything green (measured on the prototype: 2473 passed, 1 xfailed; the tree collects 2476 tests, 53 more than the README's 2423 — the merge agent regenerates that figure with the other count files).

Track E — Group S (styles): Tasks 9–13

Part of docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md. Its Global Constraints, File Structure, Interfaces and Execution sections are binding here; this part writes the five tasks of group S in full. Worktree: track-e/styles. Every command runs through uv run --frozen.

Group S owns: engineering/standards/{__init__,dimstyles,textstyles,mleaderstyles,apply}.py, backends/contracts/styles.py, the two backend blocks headed # ── styles (track E) ──, server.py SECTION 18, the # ── Styles (track E, group S) ── alias block, tests/test_standards_presets.py, tests/test_styles.py. The shared count files (docs/tool-inventory.json, the README snapshot line, tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged) are bumped per task exactly as Track A did; the merge agent regenerates them once more after the groups merge.

Two edits outside the delimited blocks, decided here and flagged for the merge agent (Task 10, steps 6–7): the five headless dimension creators pass dimstyle=_DIMSTYLE_NAME (a literal "Standard") and the two text creators pass no style at all, so $DIMSTYLE / $TEXTSTYLE would be header-only no-ops headlessly — exactly the silent success v1.5's honesty pass exists to delete. The task replaces those seven literals with _current_dimstyle_name(doc) / _current_textstyle_name(doc) (one-line edits at the call sites, nothing else in those methods changes). No other group edits those lines.

Measured on 2026-09-16 with ezdxf 1.4.4 (Python 3.11), and relied on below:

  • ezdxf.new() writes $DIMSTYLE = "ISO-25" into a fresh header with no such table entry (doc.dimstyles holds only Standard). Creating a style literally named ISO-25 would therefore make it current by accident; dimstyle_create pins the header to the previously effective style when set_current is false.
  • The header fold (_with_header_dimvars) compares $DIM* against the stored style value and folds any difference onto the next dimension. After dimstyle_set_current("ISO-25") without a header sync the fresh header's $DIMDSEP 46 won and the dimension rendered 33.33; with the style's stored values loaded into $DIM* (what a live seat does on -DIMSTYLE Restore) the fold is empty and the dimension renders 33,33 at 2.5 mm text, _CLOSEDFILLED arrows at xscale 2.5. ANSI renders 33.33 at 3.0 with 3.0 arrows.
  • A DIMSTYLE entry stores dimtxsty as a name (dxf.get("dimtxsty") == "ISOCP"), round-trips through write/read, and hasattr is true once set; the DXF schema defaults (acdb_dimstyle.attribs[x].default) for the seventeen reported variables are 2.5, 2.5, 1.25, 0.625, 0.625, 1, 0, 0, 2, 44, 2, 8, "", None, -2, -2, 1.
  • doc.header["$DIMDSEP"] = "," raises DXFValueError; every other $DIM* in the whitelist accepts its typed value; $DIMTXSTY/$DIMSTYLE/$TEXTSTYLE accept a name string.
  • doc.mleader_styles.new(name) creates an entry with only handle/name/owner; arrow_head_size, landing_gap_size, char_height, text_style_handle are settable, round-trip, and audit() reports 0 errors. The schema defaults are 4 / 2.0 / 4. has_entry is case-insensitive on every table.
  • ezdxf.fonts.fonts.font_manager.has_font("arial.ttf") is True and has_font("isocp.shx") is False on this machine (no AutoCAD support path is scanned), which is why font_resolved is preset-or-font-manager, never font-manager alone.
  • ezdxf.new() sets $INSUNITS = 6 (metres), so drawing_apply_standard's first call genuinely changes INSUNITS 6 → 4.

Task 9: engineering/standards/ — presets as data, whitelist, ranges, resolvers, provenance tests

Files:

  • Create: engineering/standards/__init__.py, engineering/standards/dimstyles.py, engineering/standards/textstyles.py, engineering/standards/mleaderstyles.py
  • Test: tests/test_standards_presets.py

Interfaces:

  • Produces: PRESETS = {"iso-25": {...17 vars...}, "ansi": {...}}, PRESET_VARIABLES (the 17, spec order), DIM_VARIABLE_WHITELIST: frozenset (17 + 16 = 33), DIM_VARIABLE_RANGES: dict[str, tuple | set], resolve_dimstyle(preset, overrides) -> dict, validate_overrides(overrides) -> dict, check_dim_value(name, value), describe_preset(name) -> dict; TEXT_PRESETS, resolve_font(font) -> (font_file, known), validate_textstyle(name, font, height, width_factor, oblique_deg) -> dict; MLEADER_PRESETS, MLEADER_KEYS, resolve_mleaderstyle(preset, overrides) -> dict.

  • Every refusal is ValueError (wrong type: TypeError) naming the key, and nothing in this package writes anything — the backends call these before they touch a table.

  • Groups C and P add sysvars.py, papers.py, pdfinfo.py, templates.py, plot.py to the same package and import them by path; this __init__.py re-exports only group S's names, so those groups never need to edit it.

  • Step 1: Write the failing provenance tests

# tests/test_standards_presets.py
"""Provenance for the authored style presets, pinned by name.

Every ISO-25 and ANSI value is asserted against the table in spec §4.1 — a
reader with ISO 129-1 / ISO 3098-1 / ASME Y14.2 open can check each line.
The refusals are pinned too: a value the standards data lets through is a
value a backend writes, so the data is where "nothing written on refusal"
starts.
"""

from __future__ import annotations

import pytest

from engineering.standards.dimstyles import (
    DIM_VARIABLE_RANGES,
    DIM_VARIABLE_WHITELIST,
    LINEWEIGHT_CODES,
    PRESET_VARIABLES,
    PRESETS,
    check_dim_value,
    describe_preset,
    resolve_dimstyle,
    validate_overrides,
)
from engineering.standards.mleaderstyles import (
    MLEADER_KEYS,
    MLEADER_PRESETS,
    resolve_mleaderstyle,
)
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle

# ── ISO-25: ISO 129-1:2018 at the 2.5 mm row of ISO 3098-1 ──────────────────

ISO_25_EXPECTED = {
    "DIMTXT": 2.5,
    "DIMASZ": 2.5,
    "DIMEXE": 1.25,
    "DIMEXO": 0.625,
    "DIMGAP": 0.625,
    "DIMTAD": 1,
    "DIMTIH": 0,
    "DIMTOH": 0,
    "DIMDEC": 2,
    "DIMDSEP": ",",
    "DIMLUNIT": 2,
    "DIMZIN": 8,
    "DIMBLK": "",
    "DIMTXSTY": "ISOCP",
    "DIMLWD": -2,
    "DIMLWE": -2,
    "DIMSCALE": 1.0,
}

# ── ANSI: ASME Y14.2-2014, metric values ────────────────────────────────────

ANSI_EXPECTED = {
    "DIMTXT": 3.0,
    "DIMASZ": 3.0,
    "DIMEXE": 1.5,
    "DIMEXO": 1.5,
    "DIMGAP": 1.0,
    "DIMTAD": 0,
    "DIMTIH": 1,
    "DIMTOH": 1,
    "DIMDEC": 2,
    "DIMDSEP": ".",
    "DIMLUNIT": 2,
    "DIMZIN": 8,
    "DIMBLK": "",
    "DIMTXSTY": "ROMANS",
    "DIMLWD": -2,
    "DIMLWE": -2,
    "DIMSCALE": 1.0,
}


@pytest.mark.parametrize(("variable", "expected"), sorted(ISO_25_EXPECTED.items()))
def test_iso25_value_by_name(variable, expected):
    assert PRESETS["iso-25"][variable] == expected
    assert type(PRESETS["iso-25"][variable]) is type(expected)


@pytest.mark.parametrize(("variable", "expected"), sorted(ANSI_EXPECTED.items()))
def test_ansi_value_by_name(variable, expected):
    assert PRESETS["ansi"][variable] == expected
    assert type(PRESETS["ansi"][variable]) is type(expected)


def test_presets_set_exactly_the_seventeen_spec_variables():
    assert len(PRESET_VARIABLES) == 17
    for name, preset in PRESETS.items():
        assert tuple(preset) == PRESET_VARIABLES, name


def test_the_whitelist_is_the_seventeen_plus_the_sixteen_spec_extras():
    extras = set(
        "DIMTOL DIMTP DIMTM DIMTOLJ DIMTFAC DIMLFAC DIMRND DIMATFIT "
        "DIMTMOVE DIMCLRD DIMCLRE DIMCLRT DIMSAH DIMBLK1 DIMBLK2 DIMCEN".split()
    )
    assert DIM_VARIABLE_WHITELIST == set(PRESET_VARIABLES) | extras
    assert set(DIM_VARIABLE_RANGES) == DIM_VARIABLE_WHITELIST


def test_iso_lineweights_are_all_legal_codes():
    for mm in (0.13, 0.18, 0.25, 0.35, 0.5, 0.7, 1.0, 1.4, 2.0):
        assert round(mm * 100) in LINEWEIGHT_CODES


def test_describe_preset_names_the_standard():
    iso = describe_preset("ISO-25")
    assert iso["name"] == "iso-25"
    assert "ISO 129-1" in iso["source"] and "ISO 3098-1" in iso["source"]
    assert iso["values"] == ISO_25_EXPECTED and iso["textstyle"] == "ISOCP"
    ansi = describe_preset("ansi")
    assert "ASME Y14.2" in ansi["source"] and ansi["textstyle"] == "ROMANS"
    with pytest.raises(ValueError, match="preset"):
        describe_preset("din")


# ── resolve_dimstyle ────────────────────────────────────────────────────────


def test_none_preset_is_the_iso25_base():
    assert resolve_dimstyle(None, None) == ISO_25_EXPECTED
    assert resolve_dimstyle("ISO-25", {}) == ISO_25_EXPECTED
    assert resolve_dimstyle("ansi", None) == ANSI_EXPECTED


def test_overrides_apply_on_top_and_are_upper_cased_and_typed():
    values = resolve_dimstyle("iso-25", {"dimtxt": 3, "DIMDEC": 3.0, "dimdsep": "."})
    assert values["DIMTXT"] == 3.0 and type(values["DIMTXT"]) is float
    assert values["DIMDEC"] == 3 and type(values["DIMDEC"]) is int
    assert values["DIMDSEP"] == "."
    assert values["DIMASZ"] == 2.5, "untouched values stay the preset's"


def test_resolve_leaves_the_preset_table_untouched():
    resolve_dimstyle("iso-25", {"DIMTXT": 9.0})
    assert PRESETS["iso-25"]["DIMTXT"] == 2.5


@pytest.mark.parametrize(
    ("overrides", "fragment"),
    [
        ({"DIMFOO": 1}, "DIMFOO"),
        ({"DIMDEC": 9}, "DIMDEC"),
        ({"DIMDEC": -1}, "DIMDEC"),
        ({"DIMDEC": 2.5}, "whole number"),
        ({"DIMTXT": 0}, "DIMTXT"),
        ({"DIMASZ": -1}, "DIMASZ"),
        ({"DIMTAD": 5}, "DIMTAD"),
        ({"DIMTIH": 2}, "DIMTIH"),
        ({"DIMDSEP": ",,"}, "DIMDSEP"),
        ({"DIMDSEP": ""}, "DIMDSEP"),
        ({"DIMLWD": 27}, "DIMLWD"),
        ({"DIMLUNIT": 0}, "DIMLUNIT"),
        ({"DIMZIN": 16}, "DIMZIN"),
        ({"DIMTXSTY": ""}, "DIMTXSTY"),
        ({"DIMTXSTY": "a/b"}, "DIMTXSTY"),
        ({"DIMLFAC": 0}, "DIMLFAC"),
        ({"DIMTFAC": 0}, "DIMTFAC"),
        ({"DIMCLRT": 257}, "DIMCLRT"),
        ({"DIMRND": -0.5}, "DIMRND"),
        ({"DIMSCALE": float("nan")}, "DIMSCALE"),
    ],
)
def test_out_of_range_or_unknown_overrides_are_refused_by_name(overrides, fragment):
    with pytest.raises(ValueError, match=fragment):
        resolve_dimstyle("iso-25", overrides)


@pytest.mark.parametrize(
    ("overrides", "fragment"),
    [
        ({"DIMTXT": "2.5"}, "DIMTXT"),
        ({"DIMTAD": True}, "DIMTAD"),
        ({"DIMDSEP": 44}, "DIMDSEP"),
        ({"DIMBLK": 0}, "DIMBLK"),
    ],
)
def test_wrong_types_are_a_type_error_naming_the_key(overrides, fragment):
    with pytest.raises(TypeError, match=fragment):
        resolve_dimstyle("iso-25", overrides)


def test_unknown_preset_is_refused():
    with pytest.raises(ValueError, match="preset"):
        resolve_dimstyle("din", None)
    with pytest.raises(TypeError, match="overrides"):
        resolve_dimstyle("iso-25", [("DIMTXT", 2.5)])


def test_validate_overrides_alone_is_what_modify_uses():
    assert validate_overrides(None) == {}
    assert validate_overrides({"dimgap": -1}) == {"DIMGAP": -1.0}, "negative gap boxes the text"
    assert check_dim_value("dimlwe", 25) == 25
    assert check_dim_value("DIMBLK", "_OBLIQUE") == "_OBLIQUE"


# ── text styles ─────────────────────────────────────────────────────────────


def test_text_presets_are_the_four_spec_fonts():
    assert TEXT_PRESETS == {
        "ISOCP": ("isocp.shx", 1.0, 0.0),
        "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
        "ARIAL": ("arial.ttf", 1.0, 0.0),
        "ROMANS": ("romans.shx", 1.0, 0.0),
    }


@pytest.mark.parametrize(
    ("font", "expected"),
    [
        ("ISOCP", "isocp.shx"),
        ("isocp", "isocp.shx"),
        ("isocp.shx", "isocp.shx"),
        ("ISOCP.SHX", "isocp.shx"),
        ("ISOCPEUR", "isocpeur.ttf"),
        ("arial", "arial.ttf"),
        ("Arial.ttf", "arial.ttf"),
        ("romans.shx", "romans.shx"),
    ],
)
def test_a_preset_font_resolves_known(font, expected):
    assert resolve_font(font) == (expected, True)


def test_an_unknown_font_passes_through_unknown_never_refused():
    assert resolve_font("simplex.shx") == ("simplex.shx", False)
    assert resolve_font("  c:/fonts/custom.ttf ") == ("c:/fonts/custom.ttf", False)


def test_empty_or_control_font_names_are_refused():
    with pytest.raises(ValueError, match="font"):
        resolve_font("")
    with pytest.raises(ValueError, match="font"):
        resolve_font("bad\nfont.shx")
    with pytest.raises(TypeError, match="font"):
        resolve_font(None)


def test_validate_textstyle_types_the_request():
    spec = validate_textstyle(" ISOCP ", "isocp", 0, 1, 0)
    assert spec == {
        "name": "ISOCP",
        "font_file": "isocp.shx",
        "known": True,
        "height": 0.0,
        "width_factor": 1.0,
        "oblique_deg": 0.0,
    }


@pytest.mark.parametrize(
    ("kwargs", "fragment"),
    [
        ({"name": ""}, "name"),
        ({"name": "A:B"}, "name"),
        ({"height": -1}, "height"),
        ({"width_factor": 0}, "width_factor"),
        ({"oblique_deg": 86}, "oblique_deg"),
        ({"oblique_deg": -90}, "oblique_deg"),
    ],
)
def test_validate_textstyle_refuses_by_field(kwargs, fragment):
    base = {"name": "T", "font": "arial.ttf", "height": 0.0, "width_factor": 1.0, "oblique_deg": 0.0}
    base.update(kwargs)
    with pytest.raises(ValueError, match=fragment):
        validate_textstyle(**base)


def test_validate_textstyle_refuses_wrong_types():
    with pytest.raises(TypeError, match="width_factor"):
        validate_textstyle("T", "arial.ttf", 0.0, "1", 0.0)
    with pytest.raises(TypeError, match="height"):
        validate_textstyle("T", "arial.ttf", True, 1.0, 0.0)


# ── multileader styles ──────────────────────────────────────────────────────


def test_mleader_presets_match_the_plan():
    assert MLEADER_PRESETS == {
        "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
        "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0},
    }
    assert MLEADER_KEYS == ("arrow_size", "landing_gap", "text_style", "text_height")


def test_resolve_mleaderstyle_applies_overrides():
    values = resolve_mleaderstyle("ISO", {"arrow_size": 3, "text_style": " ARIAL "})
    assert values == {
        "arrow_size": 3.0,
        "landing_gap": 1.0,
        "text_style": "ARIAL",
        "text_height": 2.5,
    }
    assert resolve_mleaderstyle("ansi") == MLEADER_PRESETS["ansi"]
    assert resolve_mleaderstyle("iso", {"landing_gap": 0})["landing_gap"] == 0.0


@pytest.mark.parametrize(
    ("preset", "overrides", "exc", "fragment"),
    [
        ("din", None, ValueError, "preset"),
        (None, None, ValueError, "preset"),
        ("iso", {"colour": 1}, ValueError, "colour"),
        ("iso", {"arrow_size": 0}, ValueError, "arrow_size"),
        ("iso", {"text_height": -2}, ValueError, "text_height"),
        ("iso", {"text_style": ""}, ValueError, "text_style"),
        ("iso", {"text_style": "a|b"}, ValueError, "text_style"),
        ("iso", {"arrow_size": "2.5"}, TypeError, "arrow_size"),
        ("iso", ["arrow_size"], TypeError, "overrides"),
    ],
)
def test_resolve_mleaderstyle_refuses_by_name(preset, overrides, exc, fragment):
    with pytest.raises(exc, match=fragment):
        resolve_mleaderstyle(preset, overrides)
  • Step 2: Run it to verify it fails

Run: uv run --frozen pytest tests/test_standards_presets.py -q Expected: collection error ModuleNotFoundError: No module named 'engineering.standards'.

  • Step 3: Write engineering/standards/dimstyles.py
"""ISO-25 and ANSI dimension style presets as data.

Every value in ``PRESETS`` is pinned by name in
``tests/test_standards_presets.py`` against the table in §4.1 of
``docs/superpowers/specs/2026-09-16-v1.6-settings-design.md``:

* ``iso-25`` — ISO 129-1:2018 dimensioning at the 2.5 mm lettering row of
  ISO 3098-1: 2.5 mm text and arrowheads, text above and aligned with the
  dimension line, decimal comma (ISO 80000-1 allows either marker; AutoCAD's
  own ISO-25 uses the comma).
* ``ansi`` — ASME Y14.2-2014 line conventions and lettering (3 mm / .12 in
  lettering, .125 in arrowheads) in metric values: 3 mm text and arrowheads,
  text centred in a gap and horizontal, decimal point.

``DIMDSEP`` is a one-character *string* here. DXF stores the character code and
ezdxf raises on a string in the header, so the translation belongs to the
backends, not to the data.

Pure data plus validation: no I/O, no ezdxf, no COM, so the backends, the
tools and the provenance tests all read one copy.
"""

from __future__ import annotations

import math
from typing import Any

__all__ = [
    "ANSI",
    "DIM_VARIABLE_RANGES",
    "DIM_VARIABLE_WHITELIST",
    "ISO_25",
    "LINEWEIGHT_CODES",
    "PRESETS",
    "PRESET_SOURCES",
    "PRESET_VARIABLES",
    "check_dim_value",
    "describe_preset",
    "resolve_dimstyle",
    "validate_overrides",
]

#: The seventeen variables every preset sets, in the order the spec table
#: lists them. ``dimstyle_list`` reports exactly these.
PRESET_VARIABLES: tuple[str, ...] = (
    "DIMTXT",
    "DIMASZ",
    "DIMEXE",
    "DIMEXO",
    "DIMGAP",
    "DIMTAD",
    "DIMTIH",
    "DIMTOH",
    "DIMDEC",
    "DIMDSEP",
    "DIMLUNIT",
    "DIMZIN",
    "DIMBLK",
    "DIMTXSTY",
    "DIMLWD",
    "DIMLWE",
    "DIMSCALE",
)

ISO_25: dict[str, Any] = {
    "DIMTXT": 2.5,  # text height, mm — ISO 3098-1 lettering row 2.5
    "DIMASZ": 2.5,  # arrowhead length, matched to the text
    "DIMEXE": 1.25,  # extension line beyond the dimension line
    "DIMEXO": 0.625,  # extension line offset from the feature
    "DIMGAP": 0.625,  # gap between dimension line and text
    "DIMTAD": 1,  # text above the dimension line
    "DIMTIH": 0,  # inside text aligned with the dimension line
    "DIMTOH": 0,  # outside text aligned with the dimension line
    "DIMDEC": 2,  # linear decimals
    "DIMDSEP": ",",  # decimal comma
    "DIMLUNIT": 2,  # decimal linear units
    "DIMZIN": 8,  # trailing zeros suppressed
    "DIMBLK": "",  # closed filled arrowhead
    "DIMTXSTY": "ISOCP",  # ISO 3098 type B lettering (created if missing)
    "DIMLWD": -2,  # dimension line lineweight ByBlock
    "DIMLWE": -2,  # extension line lineweight ByBlock
    "DIMSCALE": 1.0,
}

ANSI: dict[str, Any] = {
    "DIMTXT": 3.0,  # 3 mm / .12 in lettering, ASME Y14.2-2014
    "DIMASZ": 3.0,  # .125 in arrowheads, metric
    "DIMEXE": 1.5,
    "DIMEXO": 1.5,
    "DIMGAP": 1.0,
    "DIMTAD": 0,  # text centred in a gap in the dimension line
    "DIMTIH": 1,  # inside text horizontal
    "DIMTOH": 1,  # outside text horizontal
    "DIMDEC": 2,
    "DIMDSEP": ".",  # decimal point
    "DIMLUNIT": 2,
    "DIMZIN": 8,
    "DIMBLK": "",
    "DIMTXSTY": "ROMANS",  # AutoCAD's simplex roman, the ASME Gothic look
    "DIMLWD": -2,
    "DIMLWE": -2,
    "DIMSCALE": 1.0,
}

PRESETS: dict[str, dict[str, Any]] = {"iso-25": ISO_25, "ansi": ANSI}

PRESET_SOURCES: dict[str, str] = {
    "iso-25": (
        "ISO 129-1:2018 (dimensioning) with the 2.5 mm lettering row of "
        "ISO 3098-1 (text and arrow sizes); decimal comma per ISO 80000-1"
    ),
    "ansi": (
        "ASME Y14.2-2014 line conventions and lettering: 3 mm / .12 in lettering, "
        ".125 in arrowheads, metric values"
    ),
}

#: The lineweights AutoCAD accepts, in hundredths of a millimetre, plus the
#: ByLayer / ByBlock / Default sentinels. Every ISO 128 weight is in here.
_LINEWEIGHT_TEXT = (
    "-3 -2 -1 0 5 9 13 15 18 20 25 30 35 40 50 53 60 70 80 90 100 106 120 140 158 200 211"
)
LINEWEIGHT_CODES: frozenset[int] = frozenset(int(code) for code in _LINEWEIGHT_TEXT.split())

#: What ``dimstyle_create`` / ``dimstyle_modify`` will write, and the range each
#: value must sit in. The shape of each entry:
#:
#: * ``("float", low, high)`` — a number (bool refused) within the inclusive
#:   bounds; a ``low`` of ``1e-9`` is the repo's spelling of "strictly positive"
#:   (``backends/contracts/settings.py::_SETTING_RANGES``).
#: * ``("int", low, high)`` — an integer (a float with no fraction is accepted).
#: * ``("nonzero_float", low, high)`` — as ``float`` but ``0`` is refused.
#: * ``("char",)`` — exactly one character.
#: * ``("str",)`` — any string (arrowhead block names; ``""`` is the closed filled arrow).
#: * ``("name",)`` — a non-empty DXF symbol-table name.
#: * a ``set`` — one of its members.
DIM_VARIABLE_RANGES: dict[str, tuple | set] = {
    # the seventeen the presets set
    "DIMTXT": ("float", 1e-9, 1e6),
    "DIMASZ": ("float", 1e-9, 1e6),
    "DIMEXE": ("float", 0.0, 1e6),
    "DIMEXO": ("float", 0.0, 1e6),
    "DIMGAP": ("float", -1e6, 1e6),  # negative draws the basic-dimension box
    "DIMTAD": ("int", 0, 4),
    "DIMTIH": {0, 1},
    "DIMTOH": {0, 1},
    "DIMDEC": ("int", 0, 8),
    "DIMDSEP": ("char",),
    "DIMLUNIT": ("int", 1, 6),
    "DIMZIN": ("int", 0, 15),  # a bitmask
    "DIMBLK": ("str",),
    "DIMTXSTY": ("name",),
    "DIMLWD": LINEWEIGHT_CODES,
    "DIMLWE": LINEWEIGHT_CODES,
    "DIMSCALE": ("float", 0.0, 1e6),  # 0 means "scale to the viewport"
    # tolerances, rounding, fit, colours, arrowheads — the rest of the whitelist
    "DIMTOL": {0, 1},
    "DIMTP": ("float", 0.0, 1e6),
    "DIMTM": ("float", 0.0, 1e6),
    "DIMTOLJ": ("int", 0, 2),
    "DIMTFAC": ("float", 1e-9, 1e6),
    "DIMLFAC": ("nonzero_float", -1e6, 1e6),
    "DIMRND": ("float", 0.0, 1e6),  # 0 = no rounding; the backends discard it, never store it
    "DIMATFIT": ("int", 0, 3),
    "DIMTMOVE": ("int", 0, 2),
    "DIMCLRD": ("int", 0, 256),
    "DIMCLRE": ("int", 0, 256),
    "DIMCLRT": ("int", 0, 256),
    "DIMSAH": {0, 1},
    "DIMBLK1": ("str",),
    "DIMBLK2": ("str",),
    "DIMCEN": ("float", -1e6, 1e6),  # negative draws centre lines, 0 none
}

DIM_VARIABLE_WHITELIST: frozenset[str] = frozenset(DIM_VARIABLE_RANGES)

#: DXF forbids these in a symbol-table name; the same rule ``security.py``
#: applies to block and layer names, restated here so this module stays free
#: of fastmcp.
_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')


def _number(key: str, value: Any) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{key}: {value!r} is not a finite number")
    return number


def check_dim_value(name: str, value: Any) -> Any:
    """``value`` coerced to what ``name`` stores, or ``TypeError``/``ValueError`` naming it.

    Nothing here writes; the backends call this before touching a table.
    """
    key = str(name).strip().upper()
    spec = DIM_VARIABLE_RANGES.get(key)
    if spec is None:
        raise ValueError(
            f"{name}: not a dimension variable this tool writes; allowed: "
            f"{sorted(DIM_VARIABLE_WHITELIST)}"
        )
    if isinstance(spec, (set, frozenset)):
        number = _number(key, value)
        if number != int(number) or int(number) not in spec:
            raise ValueError(f"{key}: {value!r} is not one of {sorted(spec)}")
        return int(number)
    kind = spec[0]
    if kind in ("float", "nonzero_float"):
        number = _number(key, value)
        low, high = spec[1], spec[2]
        if not low <= number <= high:
            low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
            raise ValueError(
                f"{key}: {number:g} is out of range - must be {low_text} and at most {high:g}."
            )
        if kind == "nonzero_float" and number == 0.0:
            raise ValueError(f"{key}: 0 is refused (AutoCAD treats it as no factor at all)")
        return number
    if kind == "int":
        number = _number(key, value)
        if number != int(number):
            raise ValueError(f"{key}: {value!r} must be a whole number")
        low, high = spec[1], spec[2]
        if not low <= number <= high:
            raise ValueError(f"{key}: {int(number)} is out of range {low}..{high}")
        return int(number)
    if not isinstance(value, str):
        raise TypeError(f"{key}: expected a string, got {type(value).__name__}")
    if kind == "char":
        if len(value) != 1:
            raise ValueError(f"{key}: {value!r} must be exactly one character")
        return value
    if kind == "name":
        text = value.strip()
        if not text:
            raise ValueError(f"{key}: a style name cannot be empty")
        bad = sorted(set(text) & _ILLEGAL_NAME_CHARS)
        if bad:
            raise ValueError(f"{key}: {text!r} contains characters DXF forbids in a name: {bad}")
        return text
    return value  # "str"


def validate_overrides(overrides: dict | None) -> dict[str, Any]:
    """``overrides`` as a typed, upper-cased dict — every key checked, nothing written."""
    if overrides is None:
        return {}
    if not isinstance(overrides, dict):
        raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
    typed: dict[str, Any] = {}
    for raw_key, value in overrides.items():
        key = str(raw_key).strip().upper()
        typed[key] = check_dim_value(key, value)
    return typed


def resolve_dimstyle(preset: str | None, overrides: dict | None) -> dict[str, Any]:
    """The preset's seventeen values with ``overrides`` applied on top.

    ``preset`` ``None`` means the ISO-25 base. An unknown preset, an override
    key outside :data:`DIM_VARIABLE_WHITELIST` or a value outside its range
    raises ``ValueError`` (``TypeError`` for the wrong type) naming the key —
    before anything is written, because nothing here writes.
    """
    key = "iso-25" if preset is None else str(preset).strip().lower()
    if key not in PRESETS:
        raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(PRESETS)}")
    values = dict(PRESETS[key])
    values.update(validate_overrides(overrides))
    return values


def describe_preset(name: str) -> dict:
    """``{name, source, values, textstyle}`` for a preset, or ``ValueError``."""
    key = str(name).strip().lower()
    if key not in PRESETS:
        raise ValueError(f"preset: unknown value {name!r}; use one of {sorted(PRESETS)}")
    return {
        "name": key,
        "source": PRESET_SOURCES[key],
        "values": dict(PRESETS[key]),
        "textstyle": PRESETS[key]["DIMTXSTY"],
    }
  • Step 4: Write engineering/standards/textstyles.py
"""Text style presets: the four fonts the standards data refers to by name.

``TEXT_PRESETS`` maps a style name to ``(font file, width factor, oblique
degrees)``. ISOCP is AutoCAD's ISO 3098 type B lettering as an SHX shape
file; ISOCPEUR is the TrueType twin with accented characters; ARIAL is the
general-purpose TrueType face; ROMANS is AutoCAD's simplex roman, the face
ASME Y14.2 Gothic lettering is usually drawn with. A DXF stores only the font
*name*, so an unknown file is never refused here: ``resolve_font`` says
whether it is a bundled preset, the backend adds whether its font path has
it, and the caller sees ``font_resolved``.
"""

from __future__ import annotations

from pathlib import PurePath

__all__ = [
    "MAX_OBLIQUE_DEG",
    "TEXT_PRESETS",
    "resolve_font",
    "validate_textstyle",
]

TEXT_PRESETS: dict[str, tuple[str, float, float]] = {
    "ISOCP": ("isocp.shx", 1.0, 0.0),
    "ISOCPEUR": ("isocpeur.ttf", 1.0, 0.0),
    "ARIAL": ("arial.ttf", 1.0, 0.0),
    "ROMANS": ("romans.shx", 1.0, 0.0),
}

#: AutoCAD's own limit for the STYLE command's obliquing angle.
MAX_OBLIQUE_DEG = 85.0

_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')


def resolve_font(font: str) -> tuple[str, bool]:
    """``(font_file, known)``: a preset name or file resolves to the bundled
    file with ``known=True``; anything else is passed through with ``False``.

    Accepts the style name (``ISOCP``), the file (``isocp.shx``) or the stem
    (``isocp``), case-insensitively. Refuses an empty string or control
    characters — the only inputs a DXF cannot hold.
    """
    if not isinstance(font, str):
        raise TypeError(f"font: expected a string, got {type(font).__name__}")
    text = font.strip()
    if not text:
        raise ValueError("font: a font file name cannot be empty")
    if any(ord(ch) < 32 for ch in text):
        raise ValueError("font: control characters are refused")
    upper = text.upper()
    if upper in TEXT_PRESETS:
        return TEXT_PRESETS[upper][0], True
    for font_file, _width, _oblique in TEXT_PRESETS.values():
        if upper in (font_file.upper(), PurePath(font_file).stem.upper()):
            return font_file, True
    return text, False


def validate_textstyle(
    name: str,
    font: str,
    height: float = 0.0,
    width_factor: float = 1.0,
    oblique_deg: float = 0.0,
) -> dict:
    """The typed request both engines write, or a ``ValueError``/``TypeError``
    naming the field. ``height`` 0 is AutoCAD's "prompt per text" and is legal;
    the width factor must be positive; the oblique angle is AutoCAD's ±85°."""
    if not isinstance(name, str) or not name.strip():
        raise ValueError("name: a text style name cannot be empty")
    clean = name.strip()
    bad = sorted(set(clean) & _ILLEGAL_NAME_CHARS)
    if bad:
        raise ValueError(f"name: {clean!r} contains characters DXF forbids in a name: {bad}")
    font_file, known = resolve_font(font)
    for key, value in (
        ("height", height),
        ("width_factor", width_factor),
        ("oblique_deg", oblique_deg),
    ):
        if isinstance(value, bool) or not isinstance(value, (int, float)):
            raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
    if float(height) < 0.0:
        raise ValueError(f"height: {height!r} is negative; 0 means 'ask per text'")
    if float(width_factor) <= 0.0:
        raise ValueError(f"width_factor: {width_factor!r} must be greater than 0")
    if abs(float(oblique_deg)) > MAX_OBLIQUE_DEG:
        raise ValueError(
            f"oblique_deg: {oblique_deg!r} is outside AutoCAD's +/-{MAX_OBLIQUE_DEG:g} degrees"
        )
    return {
        "name": clean,
        "font_file": font_file,
        "known": known,
        "height": float(height),
        "width_factor": float(width_factor),
        "oblique_deg": float(oblique_deg),
    }
  • Step 5: Write engineering/standards/mleaderstyles.py
"""Multileader style presets: arrow, landing gap, text style and height.

Sized to match the dimension presets they sit beside (ISO-25: 2.5 mm arrows
and lettering; ANSI: 3 mm), so a leader note and a dimension on the same
sheet read at one size. Only ezdxf can author an MLEADERSTYLE object;
ActiveX exposes no collection for it (see ``backends/contracts/styles.py``).
"""

from __future__ import annotations

import math
from typing import Any

__all__ = ["MLEADER_KEYS", "MLEADER_PRESETS", "resolve_mleaderstyle"]

MLEADER_PRESETS: dict[str, dict[str, Any]] = {
    "iso": {"arrow_size": 2.5, "landing_gap": 1.0, "text_style": "ISOCP", "text_height": 2.5},
    "ansi": {"arrow_size": 3.0, "landing_gap": 1.5, "text_style": "ROMANS", "text_height": 3.0},
}

MLEADER_KEYS: tuple[str, ...] = ("arrow_size", "landing_gap", "text_style", "text_height")

_ILLEGAL_NAME_CHARS = frozenset('<>/\\":;?*|,=`')


def _positive(key: str, value: Any, *, allow_zero: bool = False) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise TypeError(f"{key}: expected a number, got {type(value).__name__}")
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{key}: {value!r} is not a finite number")
    if number < 0.0 or (number == 0.0 and not allow_zero):
        raise ValueError(f"{key}: {value!r} must be greater than 0")
    return number


def resolve_mleaderstyle(preset: str, overrides: dict | None = None) -> dict[str, Any]:
    """The preset's four values with ``overrides`` applied; refusals name the key."""
    key = str(preset).strip().lower() if preset is not None else ""
    if key not in MLEADER_PRESETS:
        raise ValueError(f"preset: unknown value {preset!r}; use one of {sorted(MLEADER_PRESETS)}")
    values = dict(MLEADER_PRESETS[key])
    if overrides is None:
        return values
    if not isinstance(overrides, dict):
        raise TypeError(f"overrides: expected a mapping, got {type(overrides).__name__}")
    for raw_key, value in overrides.items():
        name = str(raw_key).strip().lower()
        if name not in MLEADER_KEYS:
            raise ValueError(
                f"overrides: {raw_key!r} is not a leader style key; allowed: {list(MLEADER_KEYS)}"
            )
        if name == "text_style":
            if not isinstance(value, str) or not value.strip():
                raise ValueError("text_style: a text style name cannot be empty")
            bad = sorted(set(value.strip()) & _ILLEGAL_NAME_CHARS)
            if bad:
                raise ValueError(
                    f"text_style: {value!r} contains characters DXF forbids in a name: {bad}"
                )
            values[name] = value.strip()
        elif name == "landing_gap":
            values[name] = _positive(name, value, allow_zero=True)
        else:
            values[name] = _positive(name, value)
    return values
  • Step 6: Write engineering/standards/__init__.py
"""Authored drafting standards data.

Dimension, text and leader style presets live here (track E, group S); the
system-variable catalogue, paper sizes, plot styles and templates are sibling
modules added by their own groups and imported by path. Everything in this
package is data plus validation — no I/O, no ezdxf, no COM — so the backends,
the tools and the provenance tests read one copy.
"""

from __future__ import annotations

from engineering.standards.dimstyles import (
    DIM_VARIABLE_RANGES,
    DIM_VARIABLE_WHITELIST,
    PRESET_VARIABLES,
    PRESETS,
    check_dim_value,
    describe_preset,
    resolve_dimstyle,
    validate_overrides,
)
from engineering.standards.mleaderstyles import MLEADER_PRESETS, resolve_mleaderstyle
from engineering.standards.textstyles import TEXT_PRESETS, resolve_font, validate_textstyle

__all__ = [
    "DIM_VARIABLE_RANGES",
    "DIM_VARIABLE_WHITELIST",
    "MLEADER_PRESETS",
    "PRESETS",
    "PRESET_VARIABLES",
    "TEXT_PRESETS",
    "check_dim_value",
    "describe_preset",
    "resolve_dimstyle",
    "resolve_font",
    "resolve_mleaderstyle",
    "validate_overrides",
    "validate_textstyle",
]
  • Step 7: Run the tests

Run: uv run --frozen pytest tests/test_standards_presets.py -q Expected: 97 passed (measured against these exact files on 2026-09-16).

  • Step 8: Lint and commit

Run: uv run --frozen ruff check engineering/standards tests/test_standards_presets.py && uv run --frozen ruff format --check engineering/standards tests/test_standards_presets.py Expected: clean (run ruff format on the four modules first if the check reports the LINEWEIGHT_CODES set literal reflowed).

git add engineering/standards tests/test_standards_presets.py
git commit -m "feat(standards): ISO-25 and ANSI dimension, text and leader style presets as pinned data"

(End every commit message in this part with the two attribution lines from the plan's Global Constraints.)


Task 10: StylesContract + both engines + capability key mleaderstyle

Files:

  • Create: backends/contracts/styles.py
  • Modify: backends/contracts/__init__.py (import + __all__), backends/base.py (compose StylesContract; idempotent — skip if another group merged it first)
  • Modify: backends/ezdxf_backend.py — a module-level helper block after _with_header_dimvars, a method block immediately before # ── corner ops (group S's reserved anchor — C's block goes at the very end of the class, V's after the layouts block, P's before # ── 3D solids; skeleton, Merge notes), the capability map (mleaderstyle), and the seven call-site literals described in the header of this part
  • Modify: backends/com_backend.py — a module-level helper block after _regen, a method block immediately before # ── corner ops (same reserved anchor), the capability map (mleaderstyle)
  • Test: tests/test_styles.py

Interfaces:

  • Consumes: resolve_dimstyle, validate_overrides, PRESET_VARIABLES, DIM_VARIABLE_WHITELIST, TEXT_PRESETS, validate_textstyle, MLEADER_KEYS (Task 9); sanitize_symbol_name (security.py) — note it raises ToolError, not ValueError, on an empty or illegal style name, the same refusal the existing block and layer tools give (the skeleton's Global Constraints record this as the one standing exception to the ValueError/TypeError rule); _async/_require_doc/_mark_dirty (ezdxf), _run/_acad_doc/_acad_app/_regen/deg2rad/rad2deg (COM).

  • Produces: the nine StylesContract methods on both backends, exactly the plan's signatures. Extra keys beyond the plan's row shapes, on both engines: dimstyle_list rows carry values_available: bool; dimstyle_create carries written: [vars]; dimstyle_set_current / textstyle_set_current carry changed: bool; mleaderstyle_create carries textstyle_created. Names are matched case-insensitively (AutoCAD's rule) and reported in the table's own spelling.

  • The COM rule, stated once in the code and enforced by the fake: AcadDimStyle has Name and CopyFrom only. Values are written by setting the DIM* document variables and CopyFrom(document); making a style current (ActiveDimStyle) restores its saved settings and drops unsaved overrides; reading a non-current style's values is impossible, so dimstyle_list reports values for the current style only. mleaderstyle_create stays the @capability("mleaderstyle") default on COM (never defined there); mleaderstyle_list reads names from the ACAD_MLEADERSTYLE dictionary with values_available: false.

  • Step 1: Write the failing tests

# tests/test_styles.py
"""Dimension, text and multileader styles on both engines.

Headless: real ezdxf tables, and the *rendered* dimension block as the witness
that a current style reaches the next dimension (the header alone was never
the thing that could lie — see tests/test_dimension_header_vars.py). Live:
a fake ActiveX surface that records the call shapes the COM block relies on
and models the one AutoCAD rule it depends on — making a style current
restores that style's saved DIM* settings.
"""

from __future__ import annotations

import types

import pytest

from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import _current_dimstyle_name, _current_textstyle_name
from engineering.standards.dimstyles import PRESET_VARIABLES, resolve_dimstyle
from engineering.standards.mleaderstyles import resolve_mleaderstyle

pytestmark = pytest.mark.asyncio


def _rendered(backend, handle):
    """(text, text height, arrow xscales) out of the dimension's geometry block."""
    dim = backend._doc.entitydb.get(handle)
    block = backend._doc.blocks.get(dim.dxf.geometry)
    text = height = None
    arrows = []
    for entity in block:
        if entity.dxftype() == "MTEXT" and text is None:
            text, height = entity.text, round(float(entity.dxf.char_height), 4)
        elif entity.dxftype() == "TEXT" and text is None:
            text, height = entity.dxf.text, round(float(entity.dxf.height), 4)
        elif entity.dxftype() == "INSERT":
            arrows.append(round(float(entity.dxf.get("xscale", 1.0)), 4))
    if text is None:
        raise AssertionError("the dimension block carries no text")
    return text, height, sorted(set(arrows))


# ── headless engine ─────────────────────────────────────────────────────────


async def test_dimstyle_list_reports_standard_current_with_the_seventeen_values(backend):
    rows = await backend.dimstyle_list()
    assert [r["name"] for r in rows] == ["Standard"]
    assert rows[0]["current"] is True and rows[0]["values_available"] is True
    assert tuple(rows[0]["values"]) == PRESET_VARIABLES
    assert rows[0]["values"]["DIMTXT"] == 2.5
    assert rows[0]["values"]["DIMDSEP"] == ".", "drawing_new's ISO point"
    assert rows[0]["values"]["DIMTXSTY"] == "Standard", "unstored code reports the schema default"


async def test_dimstyle_create_iso25_writes_every_value_and_creates_isocp(backend):
    result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    assert result["ok"] is True and result["name"] == "ISO-25"
    assert result["textstyle_created"] is True and result["current"] is False
    assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
    assert result["written"] == sorted(PRESET_VARIABLES)
    style = backend._doc.dimstyles.get("ISO-25")
    assert style.dxf.dimtxt == 2.5 and style.dxf.dimdsep == 44 and style.dxf.dimtad == 1
    assert style.dxf.dimtxsty == "ISOCP" and style.dxf.dimlwd == -2 and style.dxf.dimblk == ""
    assert backend._doc.styles.get("ISOCP").dxf.font == "isocp.shx"
    # ezdxf.new() writes $DIMSTYLE "ISO-25" with no such entry; creating the
    # entry must not make it current by accident.
    assert backend._doc.header["$DIMSTYLE"] == "Standard"
    assert _current_dimstyle_name(backend._doc) == "Standard"


async def test_set_current_syncs_the_header_and_the_next_dimension_renders_with_it(backend):
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
    header = backend._doc.header
    assert header["$DIMSTYLE"] == "ISO-25"
    assert header["$DIMDSEP"] == 44 and header["$DIMTXT"] == 2.5 and header["$DIMTXSTY"] == "ISOCP"

    dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
    assert backend._doc.entitydb.get(dim.handle).dxf.dimstyle == "ISO-25"
    assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])

    await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
    dim2 = await backend.dimension_linear(0, 0, 33.333, 0, 16, -60)
    assert backend._doc.entitydb.get(dim2.handle).dxf.dimstyle == "ANSI"
    assert _rendered(backend, dim2.handle) == ("33.33", 3.0, [3.0])
    for creator, args in (
        (backend.dimension_aligned, (0, 0, 10, 10, 5, 15)),
        (backend.dimension_radius, (50, 50, 60, 50)),
        (backend.dimension_diameter, (70, 0, 90, 0)),
        (backend.dimension_angular, (0, 0, 10, 0, 0, 10, 5, 5)),
    ):
        created = await creator(*args)
        assert backend._doc.entitydb.get(created.handle).dxf.dimstyle == "ANSI", creator.__name__


async def test_dimstyle_modify_reports_only_what_moved_and_the_dimensions_using_it(backend):
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
    dim = await backend.dimension_linear(0, 0, 10, 0, 5, -10)
    result = await backend.dimstyle_modify("iso-25", {"DIMTXT": 2.5, "DIMDEC": 3, "dimrnd": 0})
    assert result["name"] == "ISO-25"
    assert result["changed"] == {"DIMDEC": [2, 3]}
    assert result["dimensions_using_style"] == [dim.handle]
    assert result["rerender_required"] is True
    assert backend._doc.header["$DIMDEC"] == 3, "the current style's header twin follows"
    assert not backend._doc.dimstyles.get("ISO-25").dxf.hasattr("dimrnd"), (
        "DIMRND 0 is 'no rounding' and must be discarded, not stored (xround(x, 0.0) rounds)"
    )
    again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
    assert again["changed"] == {} and again["rerender_required"] is False
    on_standard = await backend.dimstyle_modify("Standard", {"DIMDEC": 4})
    assert on_standard["dimensions_using_style"] == [] and on_standard["rerender_required"] is False
    assert backend._doc.header["$DIMDEC"] == 3, "a non-current style does not touch the header"


async def test_refusals_happen_before_anything_is_written(backend):
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    with pytest.raises(ValueError, match="already exists"):
        await backend.dimstyle_create("iso-25", resolve_dimstyle("iso-25", None))
    with pytest.raises(ValueError, match="DIMTXSTY"):
        await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
    assert not backend._doc.dimstyles.has_entry("X")
    with pytest.raises(ValueError, match="DIMFOO"):
        await backend.dimstyle_create("Y", {"DIMFOO": 1})
    assert not backend._doc.dimstyles.has_entry("Y")
    with pytest.raises(ValueError, match="DIMDEC"):
        await backend.dimstyle_modify("ISO-25", {"DIMDEC": 9})
    with pytest.raises(ValueError, match="does not exist"):
        await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})
    with pytest.raises(ValueError, match="empty"):
        await backend.dimstyle_modify("ISO-25", {})
    with pytest.raises(ValueError, match="does not exist"):
        await backend.dimstyle_set_current("NOPE")
    assert backend._doc.dimstyles.get("ISO-25").dxf.dimdec == 2


async def test_dimstyle_set_current_reports_previous_and_survives_save_reopen(backend, tmp_path):
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    result = await backend.dimstyle_set_current("iso-25")
    assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
    same = await backend.dimstyle_set_current("ISO-25")
    assert same["changed"] is False and same["previous"] == "ISO-25"
    path = str(tmp_path / "styles.dxf")
    await backend.drawing_save_as(path)
    await backend.drawing_open(path)
    rows = await backend.dimstyle_list()
    assert [r["name"] for r in rows if r["current"]] == ["ISO-25"]
    assert rows[0]["name"] == "ISO-25" and rows[0]["values"]["DIMDSEP"] == ","


async def test_textstyle_create_list_set_current_and_new_text_uses_it(backend):
    result = await backend.textstyle_create("ISOCP", "isocp", set_current=True)
    assert result == {
        "ok": True,
        "name": "ISOCP",
        "font": "isocp.shx",
        "font_resolved": True,
        "current": True,
    }
    rows = await backend.textstyle_list()
    assert [r["name"] for r in rows] == ["ISOCP", "Standard"]
    assert rows[0] == {
        "name": "ISOCP",
        "font": "isocp.shx",
        "height": 0.0,
        "width_factor": 1.0,
        "oblique_deg": 0.0,
        "current": True,
    }
    assert backend._doc.header["$TEXTSTYLE"] == "ISOCP"
    assert _current_textstyle_name(backend._doc) == "ISOCP"
    text = await backend.entity_create_text("note", 0, 0)
    mtext = await backend.entity_create_mtext("note", 0, 10)
    assert backend._doc.entitydb.get(text.handle).dxf.style == "ISOCP"
    assert backend._doc.entitydb.get(mtext.handle).dxf.style == "ISOCP"
    back = await backend.textstyle_set_current("standard")
    assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}
    assert backend._doc.entitydb.get((await backend.entity_create_text("x", 0, 0)).handle).dxf.style == "Standard"


async def test_textstyle_unknown_font_is_written_but_reported_unresolved(backend):
    result = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15)
    assert result["font_resolved"] is False and result["current"] is False
    style = backend._doc.styles.get("ODD")
    assert style.dxf.font == "acadmcp-missing-font.ttf"
    assert style.dxf.height == 3.5 and style.dxf.width == 0.8 and style.dxf.oblique == 15.0


async def test_textstyle_refusals_write_nothing(backend):
    await backend.textstyle_create("ISOCP", "isocp.shx")
    with pytest.raises(ValueError, match="already exists"):
        await backend.textstyle_create("isocp", "isocp.shx")
    with pytest.raises(ValueError, match="width_factor"):
        await backend.textstyle_create("Z", "arial.ttf", width_factor=0)
    with pytest.raises(ValueError, match="oblique_deg"):
        await backend.textstyle_create("Z", "arial.ttf", oblique_deg=90)
    with pytest.raises(ValueError, match="height"):
        await backend.textstyle_create("Z", "arial.ttf", height=-1)
    with pytest.raises(ValueError, match="font"):
        await backend.textstyle_create("Z", "")
    assert not backend._doc.styles.has_entry("Z")
    with pytest.raises(ValueError, match="does not exist"):
        await backend.textstyle_set_current("NOPE")


async def test_mleaderstyle_create_iso_and_list(backend):
    result = await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
    assert result["ok"] is True and result["textstyle_created"] is True
    assert result["values"] == {
        "arrow_size": 2.5,
        "landing_gap": 1.0,
        "text_style": "ISOCP",
        "text_height": 2.5,
    }
    style = backend._doc.mleader_styles.get("ISO")
    assert style.dxf.arrow_head_size == 2.5 and style.dxf.landing_gap_size == 1.0
    assert backend._doc.entitydb[style.dxf.text_style_handle].dxf.name == "ISOCP"
    rows = await backend.mleaderstyle_list()
    assert rows == [
        {
            "name": "ISO",
            "arrow_size": 2.5,
            "landing_gap": 1.0,
            "text_style": "ISOCP",
            "text_height": 2.5,
            "values_available": True,
        },
        {
            "name": "Standard",
            "arrow_size": 4.0,
            "landing_gap": 2.0,
            "text_style": "Standard",
            "text_height": 4.0,
            "values_available": True,
        },
    ]


async def test_mleaderstyle_refusals_write_nothing(backend):
    await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
    with pytest.raises(ValueError, match="already exists"):
        await backend.mleaderstyle_create("iso", resolve_mleaderstyle("iso", None))
    with pytest.raises(ValueError, match="text_style"):
        await backend.mleaderstyle_create("X", resolve_mleaderstyle("iso", {"text_style": "NOPE"}))
    with pytest.raises(ValueError, match="missing"):
        await backend.mleaderstyle_create("X", {"arrow_size": 2.5})
    assert not backend._doc.mleader_styles.has_entry("X")


async def test_both_capability_maps_declare_mleaderstyle(backend):
    from backends.com_backend import ComBackend

    assert backend.capabilities().to_dict()["features"]["mleaderstyle"]["supported"] is True
    com = ComBackend().capabilities().to_dict()["features"]["mleaderstyle"]
    assert com["supported"] is False and "leader_create_mleader" in com["reason"]


# ── live engine, against a fake ActiveX surface ──────────────────────────────


class _FakeStyle:
    def __init__(self, name, calls):
        self.Name = name
        self._calls = calls
        self.fontFile = "txt.shx"
        self.Width = 1.0
        self.ObliqueAngle = 0.0
        self.Height = 0.0
        self.saved: dict | None = None  # the DIM* values this style holds, once saved

    def CopyFrom(self, source):
        # AcadDimStyle.CopyFrom(document) saves the document's current
        # dimension settings into this style.
        self._calls.append(("CopyFrom", self.Name, source))
        self.saved = dict(source.variables)


class _FakeCollection:
    def __init__(self, kind, calls, *names):
        self._kind = kind
        self._calls = calls
        self.entries = [_FakeStyle(name, calls) for name in names]

    @property
    def Count(self):
        return len(self.entries)

    def Item(self, index):
        return self.entries[index]

    def Add(self, name):
        self._calls.append((f"{self._kind}.Add", name))
        style = _FakeStyle(name, self._calls)
        self.entries.append(style)
        return style


class _FakeBlock:
    def __init__(self, *entities):
        self.entities = list(entities)

    @property
    def Count(self):
        return len(self.entities)

    def Item(self, index):
        return self.entities[index]


class _FakeLayouts:
    def __init__(self, *blocks):
        self.layouts = [types.SimpleNamespace(Block=block) for block in blocks]

    @property
    def Count(self):
        return len(self.layouts)

    def Item(self, index):
        return self.layouts[index]


class _FakeDictionary:
    def __init__(self, *names):
        self.objects = [types.SimpleNamespace(mleader_name=name) for name in names]

    @property
    def Count(self):
        return len(self.objects)

    def Item(self, index):
        return self.objects[index]

    def GetName(self, obj):
        return obj.mleader_name


class _FakeDictionaries:
    def __init__(self, entries):
        self.entries = entries

    def Item(self, name):
        if name not in self.entries:
            raise RuntimeError(f"no dictionary {name}")
        return self.entries[name]


CURRENT_DIMVARS = {
    "DIMTXT": 2.5, "DIMASZ": 2.5, "DIMEXE": 1.25, "DIMEXO": 0.625, "DIMGAP": 0.625,
    "DIMTAD": 1, "DIMTIH": 0, "DIMTOH": 0, "DIMDEC": 2, "DIMDSEP": ".", "DIMLUNIT": 2,
    "DIMZIN": 8, "DIMBLK": "", "DIMTXSTY": "Standard", "DIMLWD": -2, "DIMLWE": -2,
    "DIMSCALE": 1.0,
}


class _FakeDocument:
    def __init__(self):
        self.calls: list[tuple] = []
        self.DimStyles = _FakeCollection("DimStyles", self.calls, "Standard")
        self.TextStyles = _FakeCollection("TextStyles", self.calls, "Standard")
        self.variables = dict(CURRENT_DIMVARS)
        self._active_dim = self.DimStyles.entries[0]
        self._active_dim.saved = dict(CURRENT_DIMVARS)
        self._active_text = self.TextStyles.entries[0]
        dim = types.SimpleNamespace(
            ObjectName="AcDbRotatedDimension", StyleName="ISO-25", Handle="2A"
        )
        line = types.SimpleNamespace(ObjectName="AcDbLine", Handle="2B")
        sheet_dim = types.SimpleNamespace(
            ObjectName="AcDbAlignedDimension", StyleName="Standard", Handle="2C"
        )
        self.Layouts = _FakeLayouts(_FakeBlock(dim, line), _FakeBlock(sheet_dim))
        self.Dictionaries = _FakeDictionaries(
            {"ACAD_MLEADERSTYLE": _FakeDictionary("Standard", "Annotative")}
        )

    def GetVariable(self, name):
        return self.variables[name]

    def SetVariable(self, name, value):
        self.calls.append(("SetVariable", name, value))
        self.variables[name] = value

    @property
    def ActiveDimStyle(self):
        return self._active_dim

    @ActiveDimStyle.setter
    def ActiveDimStyle(self, style):
        # Making a style current restores its saved settings as the current
        # DIM* variables and discards unsaved overrides (AutoCAD's -DIMSTYLE
        # Restore rule; scripts/smoke_settings_com.py confirms it live). A
        # style never saved (fresh DimStyles.Add) keeps the current variables.
        self.calls.append(("ActiveDimStyle", style.Name))
        self._active_dim = style
        if style.saved is not None:
            self.variables.update(style.saved)

    @property
    def ActiveTextStyle(self):
        return self._active_text

    @ActiveTextStyle.setter
    def ActiveTextStyle(self, style):
        self.calls.append(("ActiveTextStyle", style.Name))
        self._active_text = style


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    document = _FakeDocument()
    app = types.SimpleNamespace(
        Preferences=types.SimpleNamespace(Files=types.SimpleNamespace(SupportPath=""))
    )
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    monkeypatch.setattr(module, "_regen", lambda: None)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document


async def test_com_create_sets_variables_then_copies_from_the_document(com_backend):
    backend, document = com_backend
    result = await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    assert result["ok"] is True and result["current"] is False
    assert result["textstyle_created"] is True
    names = [call[0] for call in document.calls]
    assert names[:2] == ["TextStyles.Add", "DimStyles.Add"], "the text style exists before DIMTXSTY names it"
    assert document.calls[2] == ("ActiveDimStyle", "ISO-25")
    sets = [call for call in document.calls if call[0] == "SetVariable"]
    assert len(sets) == 17
    assert ("SetVariable", "DIMDSEP", ",") in sets, "DIMDSEP is a string on ActiveX"
    assert ("SetVariable", "DIMTXSTY", "ISOCP") in sets
    assert ("SetVariable", "DIMTXT", 2.5) in sets and ("SetVariable", "DIMTAD", 1) in sets
    copy_index = names.index("CopyFrom")
    assert copy_index > names.index("SetVariable")
    assert document.calls[copy_index] == ("CopyFrom", "ISO-25", document)
    assert document.calls[-1] == ("ActiveDimStyle", "Standard"), "restored: set_current was False"
    assert result["values"]["DIMDSEP"] == "," and result["values"]["DIMTXSTY"] == "ISOCP"
    isocp = document.TextStyles.entries[-1]
    assert isocp.Name == "ISOCP" and isocp.fontFile == "isocp.shx"


async def test_com_create_set_current_keeps_the_new_style_active(com_backend):
    backend, document = com_backend
    result = await backend.dimstyle_create("ANSI", resolve_dimstyle("ansi", None), set_current=True)
    assert result["current"] is True
    activations = [c for c in document.calls if c[0] == "ActiveDimStyle"]
    assert activations == [("ActiveDimStyle", "ANSI")]
    assert document.ActiveDimStyle.Name == "ANSI"


async def test_com_create_refuses_before_touching_activex(com_backend):
    backend, document = com_backend
    with pytest.raises(ValueError, match="already exists"):
        await backend.dimstyle_create("standard", resolve_dimstyle("iso-25", None))
    with pytest.raises(ValueError, match="DIMTXSTY"):
        await backend.dimstyle_create("X", resolve_dimstyle("iso-25", {"DIMTXSTY": "NOPE"}))
    with pytest.raises(ValueError, match="DIMDEC"):
        await backend.dimstyle_create("X", {"DIMDEC": 9})
    assert document.calls == []


async def test_com_list_reports_values_for_the_current_style_only(com_backend):
    backend, document = com_backend
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    rows = await backend.dimstyle_list()
    assert [(r["name"], r["current"], r["values_available"]) for r in rows] == [
        ("ISO-25", False, False),
        ("Standard", True, True),
    ]
    assert rows[0]["values"] is None
    assert rows[1]["values"]["DIMDSEP"] == "." and tuple(rows[1]["values"]) == PRESET_VARIABLES


async def test_com_modify_switches_saves_and_switches_back(com_backend):
    backend, document = com_backend
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    document.calls.clear()
    result = await backend.dimstyle_modify("iso-25", {"DIMDEC": 3, "DIMTXT": 2.5})
    assert result["name"] == "ISO-25"
    assert result["changed"] == {"DIMDEC": [2, 3]}
    assert result["dimensions_using_style"] == ["2A"], "model and paper space, dimensions only"
    assert result["rerender_required"] is False, "AutoCAD re-renders on regen"
    assert document.calls == [
        ("ActiveDimStyle", "ISO-25"),
        ("SetVariable", "DIMDEC", 3),
        ("CopyFrom", "ISO-25", document),
        ("ActiveDimStyle", "Standard"),
    ]
    document.calls.clear()
    again = await backend.dimstyle_modify("ISO-25", {"DIMDEC": 3})
    assert again["changed"] == {}
    assert [c[0] for c in document.calls] == ["ActiveDimStyle", "ActiveDimStyle"], (
        "no CopyFrom when nothing moved"
    )
    with pytest.raises(ValueError, match="does not exist"):
        await backend.dimstyle_modify("NOPE", {"DIMDEC": 1})


async def test_com_set_current(com_backend):
    backend, document = com_backend
    await backend.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None))
    result = await backend.dimstyle_set_current("ISO-25")
    assert result == {"ok": True, "current": "ISO-25", "previous": "Standard", "changed": True}
    assert document.ActiveDimStyle.Name == "ISO-25"
    with pytest.raises(ValueError, match="does not exist"):
        await backend.dimstyle_set_current("NOPE")


async def test_com_textstyle_create_sets_font_width_oblique_height(com_backend):
    backend, document = com_backend
    result = await backend.textstyle_create("ISOCP", "isocp", 0.0, 1.0, 0.0, set_current=True)
    assert result == {
        "ok": True,
        "name": "ISOCP",
        "font": "isocp.shx",
        "font_resolved": True,
        "current": True,
    }
    style = document.TextStyles.entries[-1]
    assert (style.fontFile, style.Width, style.ObliqueAngle, style.Height) == (
        "isocp.shx", 1.0, 0.0, 0.0,
    )
    assert document.ActiveTextStyle.Name == "ISOCP"
    odd = await backend.textstyle_create("ODD", "acadmcp-missing-font.ttf", 3.5, 0.8, 15.0)
    assert odd["font_resolved"] is False
    assert document.TextStyles.entries[-1].ObliqueAngle == pytest.approx(0.2617993878), "radians"
    rows = await backend.textstyle_list()
    assert [(r["name"], r["current"]) for r in rows] == [
        ("ISOCP", True), ("ODD", False), ("Standard", False),
    ]
    assert rows[1]["oblique_deg"] == pytest.approx(15.0)
    with pytest.raises(ValueError, match="already exists"):
        await backend.textstyle_create("isocp", "isocp.shx")
    back = await backend.textstyle_set_current("standard")
    assert back == {"ok": True, "current": "Standard", "previous": "ISOCP", "changed": True}


async def test_com_mleaderstyle_list_reads_the_dictionary_names_only(com_backend):
    backend, document = com_backend
    rows = await backend.mleaderstyle_list()
    assert rows == [
        {"name": "Annotative", "arrow_size": None, "landing_gap": None, "text_style": None,
         "text_height": None, "values_available": False},
        {"name": "Standard", "arrow_size": None, "landing_gap": None, "text_style": None,
         "text_height": None, "values_available": False},
    ]


async def test_com_mleaderstyle_create_refuses_with_the_capability_key(com_backend):
    from backends.com_backend import ComBackend

    backend, document = com_backend
    assert is_capability_default(ComBackend, "mleaderstyle_create")
    with pytest.raises(UnsupportedCapabilityError) as excinfo:
        await backend.mleaderstyle_create("ISO", resolve_mleaderstyle("iso", None))
    assert excinfo.value.capability == "mleaderstyle"
    assert "leader_create_mleader" in str(excinfo.value)
    assert document.calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_styles.py -q Expected: collection error ImportError: cannot import name '_current_dimstyle_name' from 'backends.ezdxf_backend'.

  • Step 3: Write the contract
# backends/contracts/styles.py
"""Dimension, text and multileader styles (track E, group S).

``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.

Names are matched case-insensitively — AutoCAD's rule for every symbol
table — and reported in the table's own spelling. Every refusal is raised
before a table is touched: ``ValueError`` naming the key (a missing style, a
clash, a variable outside ``engineering.standards.dimstyles.DIM_VARIABLE_RANGES``),
``TypeError`` for a wrong type.
"""

from __future__ import annotations

from abc import ABC, abstractmethod

from backends.capability import capability


class StylesContract(ABC):
    @abstractmethod
    async def dimstyle_list(self) -> list[dict]:
        """Every DIMSTYLE entry: ``[{name, current, values, values_available}]``.

        ``values`` holds the seventeen preset variables
        (``engineering.standards.dimstyles.PRESET_VARIABLES``) with ``DIMDSEP``
        as the character. Headless every row carries them (the DXF's stored
        value, else the schema default AutoCAD applies to an omitted code).
        On the live engine ActiveX has no per-style getters — ``AcadDimStyle``
        exposes ``Name`` and ``CopyFrom`` only — so ``values`` is read for the
        *current* style and the other rows carry ``values: None`` with
        ``values_available: False`` rather than cycling the active style
        behind the operator's back.
        """
        ...

    @abstractmethod
    async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
        """Create a dimension style from a fully resolved ``values`` dict
        (``engineering.standards.dimstyles.resolve_dimstyle(preset, overrides)``).

        Returns ``{ok, name, values, written, current, textstyle_created}``.
        ``DIMTXSTY`` must name a text style that exists or a bundled preset
        (ISOCP / ISOCPEUR / ARIAL / ROMANS), which is created first and
        reported in ``textstyle_created``; anything else is refused by name.
        A name clash is refused (``dimstyle_modify`` changes an existing
        style). With ``set_current`` the style becomes ``$DIMSTYLE`` *and* its
        values become the current ``DIM*`` settings — what a live seat does —
        so the next dimension carries them on both engines.
        """
        ...

    @abstractmethod
    async def dimstyle_modify(self, name: str, values: dict) -> dict:
        """Change variables on an existing style.

        Returns ``{ok, name, changed: {VAR: [old, new]}, dimensions_using_style,
        rerender_required}``. ``changed`` names only the variables that moved
        (the ``hatch_edit`` rule: re-setting a value to itself is not a
        change). Dimensions already drawn with the style are listed by handle;
        ``rerender_required`` is ``True`` headlessly when any exist and
        something changed, because a headless DIMENSION carries its rendered
        block and shows the old style until it is redrawn — AutoCAD re-renders
        on the next regen, so the live engine reports ``False``. Modifying the
        current style also updates the ``DIM*`` current settings.
        """
        ...

    @abstractmethod
    async def dimstyle_set_current(self, name: str) -> dict:
        """Make ``name`` the current dimension style.

        Returns ``{ok, current, previous, changed}``. Sets ``$DIMSTYLE`` and
        loads the style's stored variables into the current ``DIM*`` settings
        (AutoCAD's ``-DIMSTYLE Restore``; on the live engine unsaved overrides
        of the previous style are discarded, which is AutoCAD's own rule).
        The dimension tools then create dimensions with it; the per-dimension
        override path (tolerances, fits, text override) is unchanged.
        """
        ...

    @abstractmethod
    async def textstyle_list(self) -> list[dict]:
        """``[{name, font, height, width_factor, oblique_deg, current}]``."""
        ...

    @abstractmethod
    async def textstyle_create(
        self,
        name: str,
        font: str,
        height: float = 0.0,
        width_factor: float = 1.0,
        oblique_deg: float = 0.0,
        set_current: bool = False,
    ) -> dict:
        """Create a text style. Returns ``{ok, name, font, font_resolved, current}``.

        ``font`` is a preset (``ISOCP`` / ``isocp.shx`` / ``isocp``, any case)
        or any file name. A file that is neither a preset nor found on the
        engine's font path is *written* and reported ``font_resolved: False``
        — a DXF stores only the name and AutoCAD substitutes at open — never
        refused. Refuses a clash, ``width_factor <= 0``, ``height < 0``, an
        oblique angle outside ±85°, an empty font.
        """
        ...

    @abstractmethod
    async def textstyle_set_current(self, name: str) -> dict:
        """``$TEXTSTYLE``. Returns ``{ok, current, previous, changed}``; new
        TEXT/MTEXT then carry the style on both engines."""
        ...

    @abstractmethod
    async def mleaderstyle_list(self) -> list[dict]:
        """``[{name, arrow_size, landing_gap, text_style, text_height, values_available}]``.

        Headless the values are read from the MLEADERSTYLE object (schema
        defaults for an unset field). ActiveX exposes no MLeaderStyle object,
        so the live engine reads the *names* from the ``ACAD_MLEADERSTYLE``
        dictionary and reports the four values ``None`` with
        ``values_available: False``.
        """
        ...

    # B027 (empty method, no @abstractmethod) is what this pattern looks like
    # from the outside: the decorator replaces the `...` body with the refusing
    # default, so the source really is empty on purpose.
    @capability(  # noqa: B027
        "mleaderstyle",
        reason=(
            "ActiveX exposes no MLeaderStyle collection; use leader_create_mleader's "
            "per-leader parameters (arrow size, landing, text height) or the headless "
            "backend (AUTOCAD_MCP_BACKEND=ezdxf)"
        ),
    )
    async def mleaderstyle_create(self, name: str, values: dict) -> dict:
        """Create a multileader style from a resolved ``values`` dict
        (``engineering.standards.mleaderstyles.resolve_mleaderstyle``).
        ezdxf only. Returns ``{ok, name, values, textstyle_created}``; the
        text style follows the ``dimstyle_create`` rule (exists or preset,
        else refused by name); a clash is refused."""
        ...

Register it in backends/contracts/__init__.py: add from backends.contracts.styles import StylesContract to the import block (alphabetical, after solids) and "StylesContract" to __all__ (after "SolidContract"). In backends/base.py, add StylesContract, to the from backends.contracts import (...) list (after SolidContract,) and to the AutoCADBackend bases after SettingsContract,. If another group's merge already did both, leave them.

  • Step 4: Run the contract gate to see both backends fail at instantiation

Run: uv run --frozen pytest tests/test_capability_contract.py -q Expected: FAIL with TypeError: Can't instantiate abstract class EzdxfBackend with abstract methods dimstyle_create, ... (nine names on ezdxf, eight on COM — mleaderstyle_create is optional).

  • Step 5: ezdxf — module helpers

Insert immediately after the end of _with_header_dimvars (before def _round_measure) in backends/ezdxf_backend.py:

# ── styles (track E): module helpers ─────────────────────────────────────────
#
# `$DIMSTYLE` names the style new dimensions are created with, exactly as on
# a live seat. Two measured ezdxf facts shape everything below:
#
# * `ezdxf.new()` writes `$DIMSTYLE "ISO-25"` into a fresh header with no such
#   table entry (only `Standard` exists). `_current_dimstyle_name` therefore
#   falls back to `Standard` when the header names nothing in the table, and
#   `dimstyle_create` pins the header so creating a style *called* ISO-25 does
#   not make it current by accident.
# * `_with_header_dimvars` folds every `$DIM*` that differs from the current
#   style's stored value onto the next dimension. Making a style current
#   without loading its values into the header leaves the previous style's
#   header winning: measured, ISO-25 (comma) after `drawing_new` (point)
#   rendered "33.33". `_sync_header_to_dimstyle` is what a live seat's
#   `-DIMSTYLE Restore` does, and it makes the fold empty.


def _current_dimstyle_name(doc) -> str:
    """The dimension style new dimensions are created with.

    `$DIMSTYLE` when the table holds it (in the table's spelling); otherwise
    `Standard` — ezdxf's own substitution for a missing style, made explicit.
    """
    try:
        name = doc.header.get("$DIMSTYLE", _DIMSTYLE_NAME)
    except Exception:
        return _DIMSTYLE_NAME
    if isinstance(name, str) and name and doc.dimstyles.has_entry(name):
        return doc.dimstyles.get(name).dxf.name
    return _DIMSTYLE_NAME


def _current_textstyle_name(doc) -> str:
    """The text style new TEXT/MTEXT carry: `$TEXTSTYLE`, else `Standard`."""
    try:
        name = doc.header.get("$TEXTSTYLE", "Standard")
    except Exception:
        return "Standard"
    if isinstance(name, str) and name and doc.styles.has_entry(name):
        return doc.styles.get(name).dxf.name
    return "Standard"


def _dimstyle_schema_default(var: str):
    """What AutoCAD reads for a DIMSTYLE entry that omits the code."""
    from ezdxf.entities.dimstyle import acdb_dimstyle

    attrib = acdb_dimstyle.attribs.get(var.lower())
    return None if attrib is None else attrib.default


def _dimstyle_value(style, var: str):
    """A style's value for ``var`` in the tool vocabulary (DIMDSEP as the character)."""
    attr = var.lower()
    raw = style.dxf.get(attr) if style.dxf.hasattr(attr) else None
    if raw is None:
        raw = _dimstyle_schema_default(var)
    if var == "DIMDSEP":
        code = 44 if raw is None else int(raw)
        return "," if code == 0 else chr(code)
    if var == "DIMTXSTY":
        return str(raw) if raw else "Standard"
    return raw


def _read_dimstyle_values(style, variables=None) -> dict[str, Any]:
    from engineering.standards.dimstyles import PRESET_VARIABLES

    return {var: _dimstyle_value(style, var) for var in (variables or PRESET_VARIABLES)}


def _write_dimstyle_values(style, values: dict[str, Any]) -> None:
    """Store typed values (from ``validate_overrides``) on a DIMSTYLE entry.

    DIMRND 0 is AutoCAD's "no rounding"; ezdxf applies ``xround(x, 0.0)`` to a
    *stored* 0.0 and rounds every dimension to a whole unit, so it is
    discarded rather than stored (the `_normalise_dimstyle_rounding` lesson).
    """
    for var, value in values.items():
        attr = var.lower()
        if var == "DIMRND" and float(value) == 0.0:
            style.dxf.discard("dimrnd")
            continue
        style.dxf.set(attr, ord(value) if var == "DIMDSEP" else value)


def _sync_header_to_dimstyle(doc, style) -> None:
    """Load the style's stored variables into ``$DIM*`` — a live seat's Restore."""
    from engineering.standards.dimstyles import DIM_VARIABLE_WHITELIST

    for var in sorted(DIM_VARIABLE_WHITELIST):
        attr = var.lower()
        if not style.dxf.hasattr(attr):
            continue
        raw = style.dxf.get(attr)
        if raw is None:
            continue
        doc.header[f"${var}"] = raw


def _font_available(font_file: str) -> bool:
    """Whether ezdxf's font manager can find ``font_file`` on this machine."""
    try:
        from ezdxf.fonts import fonts

        return bool(fonts.font_manager.has_font(font_file))
    except Exception:
        return False


def _ensure_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
    """``(table spelling, created)`` for a text style that must exist.

    A bundled preset (ISOCP / ISOCPEUR / ARIAL / ROMANS) is created on demand;
    any other missing name is refused by ``refusal_key`` before a write.
    """
    from engineering.standards.textstyles import TEXT_PRESETS

    if doc.styles.has_entry(name):
        return doc.styles.get(name).dxf.name, False
    preset = TEXT_PRESETS.get(name.upper())
    if preset is None:
        raise ValueError(
            f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a "
            f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create"
        )
    font_file, width, oblique = preset
    doc.styles.new(
        name.upper(),
        dxfattribs={"font": font_file, "width": width, "oblique": oblique, "height": 0.0},
    )
    return name.upper(), True


def _dimensions_using(doc, style_name: str) -> list[str]:
    """Handles of every DIMENSION, in any layout, drawn with ``style_name``."""
    wanted = style_name.lower()
    handles: list[str] = []
    for layout in doc.layouts:
        for entity in layout.query("DIMENSION"):
            if str(entity.dxf.get("dimstyle", "")).lower() == wanted:
                handles.append(entity.dxf.handle)
    return handles
  • Step 6: ezdxf — the seven call-site edits

In _style_dimvar (module level, ~line 594) change

        style = doc.dimstyles.get(_DIMSTYLE_NAME)

to

        style = doc.dimstyles.get(_current_dimstyle_name(doc))

In the five dimension creators (dimension_linear, dimension_aligned, dimension_angular, dimension_radius, dimension_diameter, ~lines 3334–3491) replace every

                dimstyle=_DIMSTYLE_NAME,

with

                dimstyle=_current_dimstyle_name(self._require_doc()),

(five occurrences; _DIMSTYLE_NAME itself stays — it is the fallback). In entity_create_text add "style": _current_textstyle_name(self._require_doc()), as the first key of the dxfattribs dict, and in entity_create_mtext change dxfattribs={"char_height": float(height), "width": float(width)} to dxfattribs={"style": _current_textstyle_name(self._require_doc()), "char_height": float(height), "width": float(width)}.

  • Step 7: ezdxf — the method block and the capability key

Insert immediately before # ── corner ops in EzdxfBackend (directly after system_run_lisp; this position is reserved for group S — C, V and P anchor elsewhere, see the skeleton's Merge notes):

    # ── styles (track E) ─────────────────────────────────────────────────────

    async def dimstyle_list(self) -> list[dict]:
        def _sync():
            doc = self._require_doc()
            current = _current_dimstyle_name(doc)
            rows = [
                {
                    "name": style.dxf.name,
                    "current": style.dxf.name == current,
                    "values": _read_dimstyle_values(style),
                    "values_available": True,
                }
                for style in doc.dimstyles
            ]
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._async(_sync)

    async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
        from engineering.standards.dimstyles import validate_overrides
        from security import sanitize_symbol_name

        clean = sanitize_symbol_name(name, kind="dimstyle")
        typed = validate_overrides(values)
        if not typed:
            raise ValueError("dimstyle_create: values is empty; resolve a preset first")

        def _sync():
            doc = self._require_doc()
            if doc.dimstyles.has_entry(clean):
                raise ValueError(
                    f"dimstyle_create: dimension style {clean!r} already exists; "
                    "use dimstyle_modify to change it"
                )
            previous = _current_dimstyle_name(doc)
            txsty, textstyle_created = _ensure_textstyle(
                doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
            )
            style = doc.dimstyles.new(clean)
            _write_dimstyle_values(style, {**typed, "DIMTXSTY": txsty})
            if set_current:
                doc.header["$DIMSTYLE"] = clean
                _sync_header_to_dimstyle(doc, style)
            else:
                # Measured: ezdxf.new() writes $DIMSTYLE "ISO-25" into a fresh
                # header with no such table entry, so creating a style of that
                # name would silently make it current. Pin the header to the
                # style that was effectively current before this call.
                doc.header["$DIMSTYLE"] = previous
            self._mark_dirty()
            return {
                "ok": True,
                "name": clean,
                "values": _read_dimstyle_values(style),
                "written": sorted(typed),
                "current": bool(set_current),
                "textstyle_created": textstyle_created,
            }

        return await self._async(_sync)

    async def dimstyle_modify(self, name: str, values: dict) -> dict:
        from engineering.standards.dimstyles import validate_overrides

        typed = validate_overrides(values)
        if not typed:
            raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable")

        def _sync():
            doc = self._require_doc()
            if not doc.dimstyles.has_entry(name):
                raise ValueError(
                    f"dimstyle_modify: dimension style {name!r} does not exist; "
                    "dimstyle_list names the styles this drawing holds"
                )
            style = doc.dimstyles.get(name)
            if "DIMTXSTY" in typed and not doc.styles.has_entry(typed["DIMTXSTY"]):
                raise ValueError(
                    f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
                    "create it first with textstyle_create"
                )
            before = _read_dimstyle_values(style, tuple(typed))
            _write_dimstyle_values(style, typed)
            after = _read_dimstyle_values(style, tuple(typed))
            changed = {var: [before[var], after[var]] for var in typed if before[var] != after[var]}
            if changed and style.dxf.name == _current_dimstyle_name(doc):
                _sync_header_to_dimstyle(doc, style)
            using = _dimensions_using(doc, style.dxf.name)
            if changed:
                self._mark_dirty()
            return {
                "ok": True,
                "name": style.dxf.name,
                "changed": changed,
                "dimensions_using_style": using,
                # A headless DIMENSION carries its rendered block; it shows the
                # old style until redrawn. Stated, not silent.
                "rerender_required": bool(changed and using),
            }

        return await self._async(_sync)

    async def dimstyle_set_current(self, name: str) -> dict:
        def _sync():
            doc = self._require_doc()
            if not doc.dimstyles.has_entry(name):
                raise ValueError(
                    f"dimstyle_set_current: dimension style {name!r} does not exist; "
                    "dimstyle_list names the styles this drawing holds"
                )
            style = doc.dimstyles.get(name)
            previous = _current_dimstyle_name(doc)
            doc.header["$DIMSTYLE"] = style.dxf.name
            _sync_header_to_dimstyle(doc, style)
            changed = style.dxf.name != previous
            if changed:
                self._mark_dirty()
            return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}

        return await self._async(_sync)

    async def textstyle_list(self) -> list[dict]:
        def _sync():
            doc = self._require_doc()
            current = _current_textstyle_name(doc)
            rows = [
                {
                    "name": style.dxf.name,
                    "font": str(style.dxf.get("font", "") or ""),
                    "height": float(style.dxf.get("height", 0.0) or 0.0),
                    "width_factor": float(style.dxf.get("width", 1.0) or 1.0),
                    "oblique_deg": float(style.dxf.get("oblique", 0.0) or 0.0),
                    "current": style.dxf.name == current,
                }
                for style in doc.styles
            ]
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._async(_sync)

    async def textstyle_create(
        self,
        name: str,
        font: str,
        height: float = 0.0,
        width_factor: float = 1.0,
        oblique_deg: float = 0.0,
        set_current: bool = False,
    ) -> dict:
        from engineering.standards.textstyles import validate_textstyle

        spec = validate_textstyle(name, font, height, width_factor, oblique_deg)

        def _sync():
            doc = self._require_doc()
            if doc.styles.has_entry(spec["name"]):
                raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
            doc.styles.new(
                spec["name"],
                dxfattribs={
                    "font": spec["font_file"],
                    "width": spec["width_factor"],
                    "oblique": spec["oblique_deg"],
                    "height": spec["height"],
                },
            )
            if set_current:
                doc.header["$TEXTSTYLE"] = spec["name"]
            self._mark_dirty()
            return {
                "ok": True,
                "name": spec["name"],
                "font": spec["font_file"],
                # A DXF stores the name only; a font nobody can find is written
                # and *said* to be unresolved, never refused.
                "font_resolved": spec["known"] or _font_available(spec["font_file"]),
                "current": bool(set_current),
            }

        return await self._async(_sync)

    async def textstyle_set_current(self, name: str) -> dict:
        def _sync():
            doc = self._require_doc()
            if not doc.styles.has_entry(name):
                raise ValueError(
                    f"textstyle_set_current: text style {name!r} does not exist; "
                    "textstyle_list names the styles this drawing holds"
                )
            style = doc.styles.get(name)
            previous = _current_textstyle_name(doc)
            doc.header["$TEXTSTYLE"] = style.dxf.name
            changed = style.dxf.name != previous
            if changed:
                self._mark_dirty()
            return {"ok": True, "current": style.dxf.name, "previous": previous, "changed": changed}

        return await self._async(_sync)

    async def mleaderstyle_list(self) -> list[dict]:
        def _sync():
            doc = self._require_doc()
            rows = []
            for name, style in doc.mleader_styles:
                handle = style.dxf.get("text_style_handle")
                text_style = doc.entitydb.get(handle) if handle else None
                rows.append(
                    {
                        "name": name,
                        # schema defaults (measured: 4 / 2.0 / 4) for an unset field
                        "arrow_size": float(style.dxf.get("arrow_head_size", 4.0)),
                        "landing_gap": float(style.dxf.get("landing_gap_size", 2.0)),
                        "text_style": text_style.dxf.name if text_style is not None else "Standard",
                        "text_height": float(style.dxf.get("char_height", 4.0)),
                        "values_available": True,
                    }
                )
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._async(_sync)

    async def mleaderstyle_create(self, name: str, values: dict) -> dict:
        from engineering.standards.mleaderstyles import MLEADER_KEYS
        from security import sanitize_symbol_name

        clean = sanitize_symbol_name(name, kind="mleaderstyle")
        missing = [key for key in MLEADER_KEYS if key not in values]
        if missing:
            raise ValueError(
                f"mleaderstyle_create: values is missing {missing}; resolve a preset first"
            )

        def _sync():
            doc = self._require_doc()
            if doc.mleader_styles.has_entry(clean):
                raise ValueError(f"mleaderstyle_create: leader style {clean!r} already exists")
            text_style_name, textstyle_created = _ensure_textstyle(
                doc, str(values["text_style"]), refusal_key="text_style"
            )
            style = doc.mleader_styles.new(clean)
            style.dxf.arrow_head_size = float(values["arrow_size"])
            style.dxf.landing_gap_size = float(values["landing_gap"])
            style.dxf.char_height = float(values["text_height"])
            style.dxf.text_style_handle = doc.styles.get(text_style_name).dxf.handle
            self._mark_dirty()
            return {
                "ok": True,
                "name": clean,
                "values": {
                    "arrow_size": float(values["arrow_size"]),
                    "landing_gap": float(values["landing_gap"]),
                    "text_style": text_style_name,
                    "text_height": float(values["text_height"]),
                },
                "textstyle_created": textstyle_created,
            }

        return await self._async(_sync)

In EzdxfBackend.capabilities() add, after the "lisp" entry (S's entry is the first of the chain the merge agent assembles at that spot: "lisp" → this "mleaderstyle" → C's "registry_sysvar", "dwgprops" → V's "documents" … "interactive_prompt"; skeleton, Merge notes):

                "mleaderstyle": FeatureCapability(True, "native"),
  • Step 8: COM — module helpers

Insert immediately after _regen (before _apply_dim_tolerance) in backends/com_backend.py:

# ── styles (track E): module helpers ─────────────────────────────────────────
#
# The ActiveX object model has no per-style getters or setters for dimension
# variables: `AcadDimStyle` exposes `Name` and `CopyFrom` and nothing else. A
# style's values are therefore written the way AutoCAD's own DIMSTYLE command
# writes them — set the DIM* system variables on the document (which makes
# them overrides of the *current* style) and `CopyFrom(document)` to save
# those settings into the target style. Assigning `ActiveDimStyle` restores
# that style's saved settings and discards unsaved overrides (AutoCAD's
# `-DIMSTYLE Restore` rule; the fake in tests/test_styles.py models exactly
# this, and scripts/smoke_settings_com.py confirms it live). Reading is
# one-sided: `GetVariable` reads the *current* style only, so `dimstyle_list`
# reports values for the current style and `values_available: False` for the
# rest rather than cycling the active style behind the operator's back.


def _com_named(collection, name: str):
    """A collection member by name, case-insensitive (AutoCAD's rule), or None."""
    wanted = name.strip().lower()
    for index in range(collection.Count):
        item = collection.Item(index)
        if str(item.Name).lower() == wanted:
            return item
    return None


def _com_dimvar_for_write(var: str, value: Any) -> Any:
    """ActiveX takes DIMDSEP and the arrowhead/text-style names as strings."""
    if var in ("DIMDSEP", "DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"):
        return str(value)
    if isinstance(value, float):
        return float(value)
    return int(value)


def _com_dimvar_for_report(var: str, raw: Any) -> Any:
    if var == "DIMDSEP":
        if isinstance(raw, int):
            return "," if raw == 0 else chr(raw)
        text = str(raw)
        return text[:1] if text else ","
    if var in ("DIMBLK", "DIMBLK1", "DIMBLK2", "DIMTXSTY"):
        return str(raw)
    return raw


def _same_dimvar(old: Any, new: Any) -> bool:
    if isinstance(old, (int, float)) and isinstance(new, (int, float)):
        return abs(float(old) - float(new)) < 1e-9
    return str(old) == str(new)


def _com_dimensions_using(doc, style_name: str) -> list[str]:
    """Handles of every dimension, in every layout, whose StyleName is ``style_name``."""
    wanted = style_name.lower()
    handles: list[str] = []
    for index in range(doc.Layouts.Count):
        block = doc.Layouts.Item(index).Block
        for position in range(block.Count):
            obj = block.Item(position)
            try:
                if "Dimension" in str(obj.ObjectName) and str(obj.StyleName).lower() == wanted:
                    handles.append(str(obj.Handle))
            except Exception:
                continue
    return handles


def _com_font_on_support_path(font_file: str) -> bool:
    """Whether AutoCAD's support path (Preferences.Files.SupportPath) holds the file."""
    path = Path(font_file)
    if path.is_absolute():
        return path.is_file()
    try:
        support = str(_acad_app().Preferences.Files.SupportPath)
    except Exception:
        return False
    return any((Path(folder) / font_file).is_file() for folder in support.split(";") if folder)


def _ensure_com_textstyle(doc, name: str, *, refusal_key: str) -> tuple[str, bool]:
    """``(name as AutoCAD spells it, created)``; a preset is created, anything else missing is refused."""
    from engineering.standards.textstyles import TEXT_PRESETS

    existing = _com_named(doc.TextStyles, name)
    if existing is not None:
        return str(existing.Name), False
    preset = TEXT_PRESETS.get(name.upper())
    if preset is None:
        raise ValueError(
            f"{refusal_key}: text style {name!r} does not exist in this drawing and is not a "
            f"bundled preset ({sorted(TEXT_PRESETS)}); create it first with textstyle_create"
        )
    font_file, width, oblique = preset
    style = doc.TextStyles.Add(name.upper())
    style.fontFile = font_file
    style.Width = float(width)
    style.ObliqueAngle = deg2rad(oblique)
    style.Height = 0.0
    return name.upper(), True
  • Step 9: COM — the method block and the capability key

Insert immediately before # ── corner ops in ComBackend — S's reserved anchor on the COM side too (no mleaderstyle_create here — the contract's refusing default is the implementation):

    # ── styles (track E) ─────────────────────────────────────────────────────
    #
    # Fake-tested in tests/test_styles.py; executed live once by
    # scripts/smoke_settings_com.py. See the module helper block for the
    # ActiveX rules every method here relies on.

    async def dimstyle_list(self) -> list[dict]:
        from engineering.standards.dimstyles import PRESET_VARIABLES

        def _sync():
            doc = _acad_doc()
            current = str(doc.ActiveDimStyle.Name)
            rows = []
            for index in range(doc.DimStyles.Count):
                name = str(doc.DimStyles.Item(index).Name)
                is_current = name.lower() == current.lower()
                values = (
                    {
                        var: _com_dimvar_for_report(var, doc.GetVariable(var))
                        for var in PRESET_VARIABLES
                    }
                    if is_current
                    else None
                )
                rows.append(
                    {
                        "name": name,
                        "current": is_current,
                        "values": values,
                        "values_available": is_current,
                    }
                )
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._run(_sync)

    async def dimstyle_create(self, name: str, values: dict, set_current: bool = False) -> dict:
        from engineering.standards.dimstyles import PRESET_VARIABLES, validate_overrides

        clean = sanitize_symbol_name(name, kind="dimstyle")
        typed = validate_overrides(values)
        if not typed:
            raise ValueError("dimstyle_create: values is empty; resolve a preset first")

        def _sync():
            doc = _acad_doc()
            if _com_named(doc.DimStyles, clean) is not None:
                raise ValueError(
                    f"dimstyle_create: dimension style {clean!r} already exists; "
                    "use dimstyle_modify to change it"
                )
            # The text style must exist before DIMTXSTY can name it.
            txsty, textstyle_created = _ensure_com_textstyle(
                doc, str(typed.get("DIMTXSTY", "Standard")), refusal_key="DIMTXSTY"
            )
            previous = doc.ActiveDimStyle
            style = doc.DimStyles.Add(clean)
            doc.ActiveDimStyle = style
            for var, value in {**typed, "DIMTXSTY": txsty}.items():
                doc.SetVariable(var, _com_dimvar_for_write(var, value))
            style.CopyFrom(doc)
            written = {
                var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in PRESET_VARIABLES
            }
            if not set_current:
                doc.ActiveDimStyle = previous
            _regen()
            return {
                "ok": True,
                "name": clean,
                "values": written,
                "written": sorted(typed),
                "current": bool(set_current),
                "textstyle_created": textstyle_created,
            }

        return await self._run(_sync)

    async def dimstyle_modify(self, name: str, values: dict) -> dict:
        from engineering.standards.dimstyles import validate_overrides

        typed = validate_overrides(values)
        if not typed:
            raise ValueError("dimstyle_modify: overrides is empty; name at least one DIM* variable")

        def _sync():
            doc = _acad_doc()
            style = _com_named(doc.DimStyles, name)
            if style is None:
                raise ValueError(
                    f"dimstyle_modify: dimension style {name!r} does not exist; "
                    "dimstyle_list names the styles this drawing holds"
                )
            if "DIMTXSTY" in typed and _com_named(doc.TextStyles, typed["DIMTXSTY"]) is None:
                raise ValueError(
                    f"DIMTXSTY: text style {typed['DIMTXSTY']!r} does not exist in this drawing; "
                    "create it first with textstyle_create"
                )
            previous = doc.ActiveDimStyle
            switched = str(previous.Name).lower() != str(style.Name).lower()
            if switched:
                doc.ActiveDimStyle = style
            before = {var: _com_dimvar_for_report(var, doc.GetVariable(var)) for var in typed}
            changed: dict[str, list] = {}
            for var, value in typed.items():
                if _same_dimvar(before[var], value):
                    continue
                doc.SetVariable(var, _com_dimvar_for_write(var, value))
                changed[var] = [before[var], value]
            if changed:
                style.CopyFrom(doc)
            if switched:
                doc.ActiveDimStyle = previous
            using = _com_dimensions_using(doc, str(style.Name))
            if changed:
                _regen()
            return {
                "ok": True,
                "name": str(style.Name),
                "changed": changed,
                "dimensions_using_style": using,
                "rerender_required": False,  # AutoCAD re-renders on regen
            }

        return await self._run(_sync)

    async def dimstyle_set_current(self, name: str) -> dict:
        def _sync():
            doc = _acad_doc()
            style = _com_named(doc.DimStyles, name)
            if style is None:
                raise ValueError(
                    f"dimstyle_set_current: dimension style {name!r} does not exist; "
                    "dimstyle_list names the styles this drawing holds"
                )
            previous = str(doc.ActiveDimStyle.Name)
            changed = previous.lower() != str(style.Name).lower()
            if changed:
                doc.ActiveDimStyle = style
            return {
                "ok": True,
                "current": str(style.Name),
                "previous": previous,
                "changed": changed,
            }

        return await self._run(_sync)

    async def textstyle_list(self) -> list[dict]:
        def _sync():
            doc = _acad_doc()
            current = str(doc.ActiveTextStyle.Name)
            rows = []
            for index in range(doc.TextStyles.Count):
                style = doc.TextStyles.Item(index)
                rows.append(
                    {
                        "name": str(style.Name),
                        "font": str(style.fontFile),
                        "height": float(style.Height),
                        "width_factor": float(style.Width),
                        "oblique_deg": rad2deg(float(style.ObliqueAngle)),
                        "current": str(style.Name).lower() == current.lower(),
                    }
                )
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._run(_sync)

    async def textstyle_create(
        self,
        name: str,
        font: str,
        height: float = 0.0,
        width_factor: float = 1.0,
        oblique_deg: float = 0.0,
        set_current: bool = False,
    ) -> dict:
        from engineering.standards.textstyles import validate_textstyle

        spec = validate_textstyle(name, font, height, width_factor, oblique_deg)

        def _sync():
            doc = _acad_doc()
            if _com_named(doc.TextStyles, spec["name"]) is not None:
                raise ValueError(f"textstyle_create: text style {spec['name']!r} already exists")
            style = doc.TextStyles.Add(spec["name"])
            style.fontFile = spec["font_file"]
            style.Width = spec["width_factor"]
            style.ObliqueAngle = deg2rad(spec["oblique_deg"])
            style.Height = spec["height"]
            if set_current:
                doc.ActiveTextStyle = style
            return {
                "ok": True,
                "name": spec["name"],
                "font": spec["font_file"],
                "font_resolved": spec["known"] or _com_font_on_support_path(spec["font_file"]),
                "current": bool(set_current),
            }

        return await self._run(_sync)

    async def textstyle_set_current(self, name: str) -> dict:
        def _sync():
            doc = _acad_doc()
            style = _com_named(doc.TextStyles, name)
            if style is None:
                raise ValueError(
                    f"textstyle_set_current: text style {name!r} does not exist; "
                    "textstyle_list names the styles this drawing holds"
                )
            previous = str(doc.ActiveTextStyle.Name)
            changed = previous.lower() != str(style.Name).lower()
            if changed:
                doc.ActiveTextStyle = style
            return {
                "ok": True,
                "current": str(style.Name),
                "previous": previous,
                "changed": changed,
            }

        return await self._run(_sync)

    async def mleaderstyle_list(self) -> list[dict]:
        def _sync():
            doc = _acad_doc()
            try:
                dictionary = doc.Dictionaries.Item("ACAD_MLEADERSTYLE")
            except Exception:
                return []
            rows = []
            for index in range(dictionary.Count):
                rows.append(
                    {
                        "name": str(dictionary.GetName(dictionary.Item(index))),
                        # ActiveX exposes no MLeaderStyle object: names only.
                        "arrow_size": None,
                        "landing_gap": None,
                        "text_style": None,
                        "text_height": None,
                        "values_available": False,
                    }
                )
            rows.sort(key=lambda row: row["name"].lower())
            return rows

        return await self._run(_sync)

In ComBackend.capabilities() add, after the "lisp" entry (first of the S → C → V chain at that spot, as on ezdxf):

                "mleaderstyle": FeatureCapability(
                    False,
                    reason=(
                        "no_activex_mleaderstyle_collection;"
                        "use_leader_create_mleader_per_leader_parameters"
                    ),
                ),
  • Step 10: Run the tests and the dimension regression suites

Run: uv run --frozen pytest tests/test_styles.py tests/test_capability_contract.py tests/test_dimension_header_vars.py tests/test_dimension_style.py tests/test_dimensions.py tests/test_dimension_tolerances.py tests/test_fits.py tests/test_meta_tools.py tests/test_entity_creation.py -q Expected: all PASS (the header-vars and dimension-style suites prove the call-site change is invisible while Standard is current).

  • Step 11: Lint and commit

Run: uv run --frozen ruff check backends tests/test_styles.py && uv run --frozen ruff format --check backends tests/test_styles.py

git add backends/contracts/styles.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py tests/test_styles.py
git commit -m "feat(styles): dimension, text and multileader styles on both engines; \$DIMSTYLE/\$TEXTSTYLE reach new entities headlessly"

Task 11: SECTION 18 — nine style tools, aliases, group, counts, gates

Files:

  • Modify: server.py (_GROUP_TAG_PRIORITY + _GROUP_TAG_LABELS; new # ── SECTION 18: Styles (9 tools) after pid_tag_parse, before the # ── RESOURCES banner)
  • Modify: discovery/aliases.py (SHARED_ACAD_COMMANDS + a # ── Styles (track E, group S) block at the end of TOOL_ALIASES)
  • Modify: README.md snapshot line 166 tools → 175 tools; regenerate docs/tool-inventory.json; tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged gains "styles": 9, total 175
  • Test: tests/test_styles.py (append a tool section)

Interfaces:

  • Produces MCP tools dimstyle_list, dimstyle_create(name, preset=None, overrides=None, set_current=False), dimstyle_modify(name, overrides), dimstyle_set_current(name), textstyle_list, textstyle_create(name, font, height=0.0, width_factor=1.0, oblique_deg=0.0, set_current=False), textstyle_set_current(name), mleaderstyle_list, mleaderstyle_create(name, preset, overrides=None). All tagged style (+ query/create/modify), which files them under a new styles group; pack core (no PACK_TOOL_NAMES change — core is everything unclaimed); lean decision deferred to Task 13 (dimstyle_set_current and textstyle_set_current join, the rest do not).

  • DIMSTYLE, STYLE, MLEADERSTYLE are umbrella commands (one dialog for list/create/modify/set-current) and join SHARED_ACAD_COMMANDS; DDIM is the one command unique to a single tool (dimstyle_create).

  • Step 1: Write the failing tool tests

Append to tests/test_styles.py:

# ── the MCP tools (SECTION 18) ──────────────────────────────────────────────

STYLE_TOOLS = {
    "dimstyle_list",
    "dimstyle_create",
    "dimstyle_modify",
    "dimstyle_set_current",
    "textstyle_list",
    "textstyle_create",
    "textstyle_set_current",
    "mleaderstyle_list",
    "mleaderstyle_create",
}


class _Ctx:
    """Enough of a fastmcp Context for a direct tool call (tests/test_capabilities.py)."""

    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, *_args, **_kwargs):
        return None


async def test_style_tools_are_registered_and_grouped_under_styles():
    import server

    names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)}
    assert STYLE_TOOLS <= names
    groups = await server._tool_groups()
    assert STYLE_TOOLS <= set(groups["styles"]), "the style tag must win over query/create/modify"
    for other in ("layers", "drawing", "entity_query", "entity_creation", "entity_modification"):
        assert not STYLE_TOOLS & set(groups.get(other, [])), other


async def test_dimstyle_tools_resolve_the_preset_then_write(backend):
    import server

    ctx = _Ctx(backend)
    created = await server.dimstyle_create(
        name="ISO-25", preset="iso-25", overrides={"dimdec": 3}, set_current=True, ctx=ctx
    )
    assert created["current"] is True and created["values"]["DIMDEC"] == 3
    listed = await server.dimstyle_list(ctx=ctx)
    assert listed["count"] == 2 and listed["current"] == "ISO-25"
    modified = await server.dimstyle_modify(name="ISO-25", overrides={"DIMTXT": 3.5}, ctx=ctx)
    assert modified["changed"] == {"DIMTXT": [2.5, 3.5]}
    switched = await server.dimstyle_set_current(name="Standard", ctx=ctx)
    assert switched["previous"] == "ISO-25" and switched["current"] == "Standard"


async def test_dimstyle_create_tool_refuses_an_unknown_override_before_writing(backend):
    import server

    ctx = _Ctx(backend)
    with pytest.raises(ValueError, match="DIMFOO"):
        await server.dimstyle_create(name="X", overrides={"DIMFOO": 1}, ctx=ctx)
    with pytest.raises(ValueError, match="preset"):
        await server.dimstyle_create(name="X", preset="din", ctx=ctx)
    assert (await server.dimstyle_list(ctx=ctx))["count"] == 1


async def test_textstyle_and_mleaderstyle_tools_round_trip(backend):
    import server

    ctx = _Ctx(backend)
    made = await server.textstyle_create(name="ISOCP", font="isocp", set_current=True, ctx=ctx)
    assert made["font_resolved"] is True
    assert (await server.textstyle_list(ctx=ctx))["current"] == "ISOCP"
    assert (await server.textstyle_set_current(name="Standard", ctx=ctx))["previous"] == "ISOCP"
    leader = await server.mleaderstyle_create(
        name="ISO", preset="iso", overrides={"arrow_size": 3}, ctx=ctx
    )
    assert leader["values"]["arrow_size"] == 3.0 and leader["textstyle_created"] is False
    assert (await server.mleaderstyle_list(ctx=ctx))["count"] == 2
    with pytest.raises(ValueError, match="preset"):
        await server.mleaderstyle_create(name="X", preset="din", ctx=ctx)
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_styles.py -q -k "tools or tool" Expected: four FAILs — KeyError: 'styles' in the registration test and AttributeError: module 'server' has no attribute 'dimstyle_create' (or textstyle_create) in the three call tests.

  • Step 3: The styles group

In server.py::_GROUP_TAG_PRIORITY insert "style", immediately before "layer",; in _GROUP_TAG_LABELS insert "style": "styles", immediately before "layer": "layers",.

  • Step 4: Register the nine tools

Insert after the pid_tag_parse tool and before the # ── RESOURCES banner in server.py:

# ---------------------------------------------------------------------------
# ── SECTION 18: Styles (9 tools) ────────────────────────────────────────────
# ---------------------------------------------------------------------------
#
# Pack: core (drafting essentials). Lean profile: dimstyle_set_current and
# textstyle_set_current only (Task 13) — a lean client needs to *use* a
# style a template already holds far more often than to author one.


@cad_tool(
    summary="List dimension styles with their ISO-25/ANSI variables and which one is current.",
    cost="read",
)
@mcp.tool(
    annotations={"title": "Styles: List Dimension Styles", "readOnlyHint": True},
    tags={"style", "query"},
)
async def dimstyle_list(ctx: Context = None) -> dict:
    """Every DIMSTYLE table entry with the seventeen preset variables
    (DIMTXT, DIMASZ, DIMEXE, DIMEXO, DIMGAP, DIMTAD, DIMTIH, DIMTOH, DIMDEC,
    DIMDSEP, DIMLUNIT, DIMZIN, DIMBLK, DIMTXSTY, DIMLWD, DIMLWE, DIMSCALE).

    Headless, `values` is what the DXF stores (the schema default where an
    entry omits a code). On the live engine ActiveX has no per-style getters,
    so `values` is read for the *current* style only and the other rows carry
    `values: null`, `values_available: false` — `dimstyle_set_current` a
    style to read it. Never refuses on an open drawing.
    """
    rows = await _backend(ctx).dimstyle_list()
    return {
        "ok": True,
        "styles": rows,
        "count": len(rows),
        "current": next((row["name"] for row in rows if row["current"]), None),
    }


@cad_tool(
    summary="Create a dimension style from the ISO-25 or ANSI preset, with per-variable overrides.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Styles: Create Dimension Style", "readOnlyHint": False},
    tags={"style", "create"},
)
async def dimstyle_create(
    name: Annotated[str, "New dimension style name, e.g. ISO-25, ANSI, MECH-A3"],
    preset: Annotated[
        str | None, "iso-25 (ISO 129-1, 2.5 mm, comma) | ansi (ASME Y14.2, 3 mm, point); omit for the ISO-25 base"
    ] = None,
    overrides: Annotated[
        dict | None,
        "DIM* variables applied on top of the preset, e.g. {DIMTXT: 3.5, DIMDSEP: '.', DIMTAD: 0}",
    ] = None,
    set_current: Annotated[bool, "Also make it the current dimension style"] = False,
    ctx: Context = None,
) -> dict:
    """Create a dimension style: preset values first, then `overrides`.

    Refuses, before anything is written: an unknown preset; an override key
    outside the 33-variable whitelist (the 17 preset variables plus DIMTOL,
    DIMTP, DIMTM, DIMTOLJ, DIMTFAC, DIMLFAC, DIMRND, DIMATFIT, DIMTMOVE,
    DIMCLRD/E/T, DIMSAH, DIMBLK1/2, DIMCEN); a value outside its range
    (DIMDEC 0-8, sizes > 0, DIMTAD 0-4, DIMDSEP one character, lineweights an
    AutoCAD code); a name that already exists (use `dimstyle_modify`); a
    DIMTXSTY that is neither an existing text style nor a bundled preset
    (ISOCP, ISOCPEUR, ARIAL, ROMANS — those are created, `textstyle_created`).
    With `set_current` the next dimension carries the style on both engines.
    """
    from engineering.standards.dimstyles import resolve_dimstyle

    values = resolve_dimstyle(preset, overrides)
    await ctx.info(f"Dimension style {name!r} from preset {preset or 'iso-25'}")
    return await _backend(ctx).dimstyle_create(name, values, set_current)


@cad_tool(
    summary="Change DIM* variables on an existing dimension style; reports only what moved.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Styles: Modify Dimension Style", "readOnlyHint": False},
    tags={"style", "modify"},
)
async def dimstyle_modify(
    name: Annotated[str, "Existing dimension style name"],
    overrides: Annotated[dict, "DIM* variables to change, e.g. {DIMDEC: 3, DIMTXT: 3.5}"],
    ctx: Context = None,
) -> dict:
    """Change variables on an existing dimension style.

    `changed` names only the variables that actually moved — re-setting a
    value to itself is not a change. `dimensions_using_style` lists the
    dimensions already drawn with it; headlessly `rerender_required: true`
    says they still show the old style until redrawn (AutoCAD re-renders on
    the next regen). Refuses a missing style, an empty `overrides`, a key
    outside the whitelist, a value outside its range, a DIMTXSTY that does
    not exist — all before any write. On the live engine, editing a
    non-current style makes it current for the duration of the call and the
    previous style is restored (its unsaved overrides are discarded, which is
    AutoCAD's own DIMSTYLE rule).
    """
    await ctx.info(f"Modifying dimension style {name!r}")
    return await _backend(ctx).dimstyle_modify(name, overrides)


@cad_tool(summary="Make a dimension style current so new dimensions use it.", cost="safe")
@mcp.tool(
    annotations={"title": "Styles: Set Current Dimension Style", "readOnlyHint": False},
    tags={"style", "modify"},
)
async def dimstyle_set_current(
    name: Annotated[str, "Dimension style to make current (case-insensitive)"],
    ctx: Context = None,
) -> dict:
    """Set `$DIMSTYLE` and load the style's variables as the current DIM*
    settings — what a live seat's DIMSTYLE Restore does — so `dimension_*`
    and `dimension_auto` draw with it on both engines. Per-dimension
    tolerances, fits and text overrides still apply on top. Refuses a style
    the drawing does not hold. Returns `previous` and `changed`.
    """
    return await _backend(ctx).dimstyle_set_current(name)


@cad_tool(summary="List text styles: font file, height, width factor, oblique angle.", cost="read")
@mcp.tool(
    annotations={"title": "Styles: List Text Styles", "readOnlyHint": True},
    tags={"style", "query"},
)
async def textstyle_list(ctx: Context = None) -> dict:
    """Every STYLE table entry and which one new TEXT/MTEXT will use."""
    rows = await _backend(ctx).textstyle_list()
    return {
        "ok": True,
        "styles": rows,
        "count": len(rows),
        "current": next((row["name"] for row in rows if row["current"]), None),
    }


@cad_tool(
    summary="Create a text style from a bundled font (isocp, isocpeur, arial, romans) or any file.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Styles: Create Text Style", "readOnlyHint": False},
    tags={"style", "create"},
)
async def textstyle_create(
    name: Annotated[str, "New text style name, e.g. ISOCP"],
    font: Annotated[
        str, "isocp.shx | isocpeur.ttf | arial.ttf | romans.shx (name or file, any case) or any font file"
    ],
    height: Annotated[float, Field(default=0.0, ge=0, description="Fixed height; 0 = ask per text")] = 0.0,
    width_factor: Annotated[float, Field(default=1.0, gt=0, description="Width factor")] = 1.0,
    oblique_deg: Annotated[float, "Obliquing angle in degrees, -85..85"] = 0.0,
    set_current: Annotated[bool, "Also make it the current text style"] = False,
    ctx: Context = None,
) -> dict:
    """Create a text style.

    A font that is neither a bundled preset nor on the engine's font path is
    *written* and reported `font_resolved: false` (AutoCAD substitutes at
    open; the headless renderer falls back) — never refused, because a DXF
    stores only the name. Refuses, before any write: a name that exists, an
    empty font, `width_factor <= 0`, `height < 0`, an oblique angle outside
    ±85°.
    """
    await ctx.info(f"Text style {name!r} with font {font!r}")
    return await _backend(ctx).textstyle_create(
        name, font, height, width_factor, oblique_deg, set_current
    )


@cad_tool(summary="Make a text style current so new text uses it.", cost="safe")
@mcp.tool(
    annotations={"title": "Styles: Set Current Text Style", "readOnlyHint": False},
    tags={"style", "modify"},
)
async def textstyle_set_current(
    name: Annotated[str, "Text style to make current (case-insensitive)"],
    ctx: Context = None,
) -> dict:
    """Set `$TEXTSTYLE`; `entity_create_text` / `entity_create_mtext` then
    carry the style on both engines. Refuses a style the drawing does not
    hold. Returns `previous` and `changed`."""
    return await _backend(ctx).textstyle_set_current(name)


@cad_tool(summary="List multileader styles: arrow size, landing gap, text style and height.", cost="read")
@mcp.tool(
    annotations={"title": "Styles: List Multileader Styles", "readOnlyHint": True},
    tags={"style", "query"},
)
async def mleaderstyle_list(ctx: Context = None) -> dict:
    """Every MLEADERSTYLE. Headless the four values are read from the object.
    ActiveX exposes no MLeaderStyle object, so the live engine lists the
    *names* from the ACAD_MLEADERSTYLE dictionary with `values_available:
    false`; the values themselves are set per leader through
    `leader_create_mleader`."""
    rows = await _backend(ctx).mleaderstyle_list()
    return {"ok": True, "styles": rows, "count": len(rows)}


@cad_tool(
    summary="Create a multileader style from the ISO or ANSI preset (headless engine only).",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Styles: Create Multileader Style", "readOnlyHint": False},
    tags={"style", "create"},
)
async def mleaderstyle_create(
    name: Annotated[str, "New multileader style name"],
    preset: Annotated[str, "iso (2.5 mm arrow, 1.0 landing, ISOCP 2.5) | ansi (3.0, 1.5, ROMANS 3.0)"],
    overrides: Annotated[
        dict | None, "arrow_size | landing_gap | text_style | text_height on top of the preset"
    ] = None,
    ctx: Context = None,
) -> dict:
    """Create a multileader style on `doc.mleader_styles`.

    Refuses, before any write: an unknown preset; an override key outside the
    four; `arrow_size`/`text_height <= 0`, `landing_gap < 0`; a name that
    exists; a `text_style` that is neither present nor a bundled preset.
    On the live engine this is refused with `capability: "mleaderstyle"`
    (ActiveX exposes no MLeaderStyle collection) — use
    `leader_create_mleader`'s per-leader parameters there.
    """
    from engineering.standards.mleaderstyles import resolve_mleaderstyle

    values = resolve_mleaderstyle(preset, overrides)
    await ctx.info(f"Multileader style {name!r} from preset {preset}")
    return await _backend(ctx).mleaderstyle_create(name, values)
  • Step 5: Alias records and the shared-command allowlist

In discovery/aliases.py::SHARED_ACAD_COMMANDS add, with these comment lines above the set alongside the existing rationale:

#   DIMSTYLE, STYLE, MLEADERSTYLE -- one dialog each in AutoCAD for
#       list / create / modify / set-current; this server splits each into
#       discrete style tools (track E), so the command is a correct
#       destination for every one of them.

and the three members "DIMSTYLE",, "MLEADERSTYLE",, "STYLE", in alphabetical position. Then append to TOOL_ALIASES after pid_tag_parse:

    # ── Styles (track E, group S) ───────────────────────────────────────────
    "dimstyle_create": ToolAliases(
        acad=("DIMSTYLE", "DDIM"),
        synonyms=(
            "new dimension style",
            "create a dimension style",
            "iso-25 dimension style",
            "ansi dimension style",
            "dimension style from a preset",
            "set up dimension text height and arrows",
        ),
    ),
    "dimstyle_list": ToolAliases(
        acad=("DIMSTYLE",),
        synonyms=(
            "list dimension styles",
            "which dimension styles are defined",
            "dimension style table",
            "current dimstyle",
            "dim styles",
        ),
    ),
    "dimstyle_modify": ToolAliases(
        acad=("DIMSTYLE",),
        synonyms=(
            "change a dimension style",
            "edit the dimension style",
            "change dimension text height for the whole drawing",
            "set dimdec on a style",
            "modify dimstyle variables",
        ),
    ),
    "dimstyle_set_current": ToolAliases(
        acad=("DIMSTYLE",),
        synonyms=(
            "current dimension style",
            "make this dimension style current",
            "switch dimension style",
            "use iso-25 for new dimensions",
            "activate a dimstyle",
        ),
    ),
    "mleaderstyle_create": ToolAliases(
        acad=("MLEADERSTYLE",),
        synonyms=(
            "new leader style",
            "multileader style",
            "leader arrow size and landing",
            "iso leader style",
        ),
    ),
    "mleaderstyle_list": ToolAliases(
        acad=("MLEADERSTYLE",),
        synonyms=("list leader styles", "which multileader styles exist", "mleader styles"),
    ),
    "textstyle_create": ToolAliases(
        acad=("STYLE",),
        synonyms=(
            "new text style",
            "create a text style",
            "isocp font",
            "set the font",
            "text style with width factor and oblique angle",
        ),
    ),
    "textstyle_list": ToolAliases(
        acad=("STYLE",),
        synonyms=(
            "list text styles",
            "which fonts are defined",
            "text style table",
            "current text style",
        ),
    ),
    "textstyle_set_current": ToolAliases(
        acad=("STYLE", "TEXTSTYLE"),
        synonyms=(
            "make this text style current",
            "switch the text style",
            "use isocp for new text",
            "active text style",
        ),
    ),
  • Step 6: Counts — inventory, README, registry snapshot

Run: uv run --frozen python scripts/generate_tool_inventory.py Then change the README line to > **v1.5 release snapshot:** 175 tools · 8 resources · 5 prompt templates · 2423 collected tests. (leave the collected-tests figure; the final task re-derives it). In tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged add "styles": 9, between "solids": 5, and "system": 7,, change the total to 175, and append to the docstring: styles appeared (0 -> 9) when track E's SECTION 18 opened with the dimension, text and multileader style tools; the style tag sits ahead of layer in _GROUP_TAG_PRIORITY so they never file under their secondary query / create / modify tags.

  • Step 7: Run the gates

Run: uv run --frozen pytest tests/test_styles.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_discovery_transform.py -q Expected: all PASS except, possibly, test_document_numbers_match_the_tree on the README collected tests figure alone — that drift predates this track and belongs to the final task; any other failure is this task's. (test_shared_command_allowlist_has_no_stale_entries passes because each of the three new shared commands is claimed by at least two tools.)

  • Step 8: Commit
git add server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_styles.py
git commit -m "feat(server): SECTION 18 styles — dimstyle/textstyle/mleaderstyle tools with DIMSTYLE/STYLE/MLEADERSTYLE aliases"

Task 12: apply_standard + drawing_apply_standard (SECTION 18 → 10 tools)

Files:

  • Create: engineering/standards/apply.py
  • Modify: server.py (SECTION 18 header (9 tools) → (10 tools); tool after mleaderstyle_create), discovery/aliases.py (record after dimstyle_set_current), README.md (175 → 176), docs/tool-inventory.json (regenerate), tests/test_tool_registry.py ("styles": 10, total 176)
  • Test: tests/test_styles.py (append)

Interfaces:

  • Produces: async def apply_standard(backend, standard: str, layers: bool = True, units: bool = True) -> dict → {"standard", "dimstyle": {"name", "created", "current": True}, "textstyle": {"name", "created", "current": True}, "settings": {"changed": {VAR: [old, new]}, "applied": bool}, "layers": {"layer_set", "layers": {name: "created"|"exists"|"failed: ..."}} | None}; STANDARDS, STANDARD_SYSVARS, resolve_standard(standard) -> (key, row). MCP tool drawing_apply_standard(standard, layers=True, units=True).

  • Composes only existing backend methods (textstyle_*, dimstyle_*, system_get/set_variable, drawing_apply_iso_layers), so it needs nothing from group C: units are written as the five system variables directly (INSUNITS 4, LUNITS 2, AUNITS 0, LTSCALE 1.0, DIMSCALE 1.0); the decimal separator comes from the preset through the dimstyle. An existing ISO-25 / ANSI style is used as it is (created: false) and made current, never reset — dimstyle_modify is the deliberate way to change one.

  • Step 1: Write the failing tests

Append to tests/test_styles.py:

# ── drawing_apply_standard ──────────────────────────────────────────────────


async def test_apply_iso_then_dimension_linear_carries_iso25(backend):
    from engineering.standards.apply import apply_standard

    result = await apply_standard(backend, "iso")
    assert result["standard"] == "iso"
    assert result["dimstyle"] == {"name": "ISO-25", "created": True, "current": True}
    assert result["textstyle"] == {"name": "ISOCP", "created": True, "current": True}
    assert result["settings"]["changed"] == {"INSUNITS": [6, 4]}, "ezdxf.new() starts in metres"
    assert result["layers"]["layer_set"] == "mech"
    assert result["layers"]["layers"]["DIM"] in ("created", "exists")
    dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
    assert _rendered(backend, dim.handle) == ("33,33", 2.5, [2.5])
    assert backend._doc.header["$TEXTSTYLE"] == "ISOCP"
    assert await backend.system_get_variable("LUNITS") == 2
    assert await backend.system_get_variable("INSUNITS") == 4


async def test_apply_ansi_then_dimension_linear_carries_ansi(backend):
    from engineering.standards.apply import apply_standard

    result = await apply_standard(backend, "ANSI", layers=False)
    assert result["dimstyle"]["name"] == "ANSI" and result["textstyle"]["name"] == "ROMANS"
    assert result["layers"] is None
    dim = await backend.dimension_linear(0, 0, 33.333, 0, 16, -30)
    assert _rendered(backend, dim.handle) == ("33.33", 3.0, [3.0])


async def test_second_apply_is_idempotent_and_reports_nothing_created(backend):
    from engineering.standards.apply import apply_standard

    await apply_standard(backend, "iso")
    again = await apply_standard(backend, "iso")
    assert again["dimstyle"]["created"] is False and again["textstyle"]["created"] is False
    assert again["settings"]["changed"] == {}
    assert set(again["layers"]["layers"].values()) == {"exists"}
    swapped = await apply_standard(backend, "ansi", layers=False)
    assert swapped["dimstyle"]["created"] is True
    assert (await backend.dimstyle_list())[0]["name"] == "ANSI"
    back = await apply_standard(backend, "iso", layers=False, units=False)
    assert back["dimstyle"]["created"] is False and back["settings"] == {"changed": {}, "applied": False}
    assert backend._doc.header["$DIMSTYLE"] == "ISO-25"


async def test_apply_refuses_an_unknown_standard_before_writing(backend):
    from engineering.standards.apply import apply_standard

    with pytest.raises(ValueError, match="standard"):
        await apply_standard(backend, "din")
    with pytest.raises(TypeError, match="layers"):
        await apply_standard(backend, "iso", layers="yes")
    assert [r["name"] for r in await backend.dimstyle_list()] == ["Standard"]
    assert await backend.system_get_variable("INSUNITS") == 6


async def test_apply_standard_tool_is_registered_in_the_styles_group(backend):
    import server

    names = {t.name for t in await server._registered_tools() if getattr(t, "name", None)}
    assert "drawing_apply_standard" in names
    styles_group = set((await server._tool_groups())["styles"])
    assert styles_group == STYLE_TOOLS | {"drawing_apply_standard"}, "SECTION 18 is exactly ten"
    result = await server.drawing_apply_standard(standard="iso", layers=False, ctx=_Ctx(backend))
    assert result["dimstyle"]["created"] is True


async def test_com_apply_standard_creates_both_styles_and_sets_units(com_backend):
    from engineering.standards.apply import apply_standard

    backend, document = com_backend
    document.variables.update({"INSUNITS": 1, "LUNITS": 4, "AUNITS": 0, "LTSCALE": 1.0, "DIMSCALE": 1.0})
    result = await apply_standard(backend, "iso", layers=False)
    assert result["dimstyle"]["created"] is True and result["textstyle"]["created"] is True
    assert result["settings"]["changed"] == {"INSUNITS": [1, 4], "LUNITS": [4, 2]}
    names = [call[0] for call in document.calls]
    assert names[0] == "TextStyles.Add" and "DimStyles.Add" in names
    assert ("ActiveTextStyle", "ISOCP") in document.calls
    assert document.ActiveDimStyle.Name == "ISO-25"
    assert document.variables["DIMDSEP"] == "," and document.variables["INSUNITS"] == 4

The COM case needs system_get_variable / system_set_variable to reach the fake document: those methods call _acad_app().GetVariable/SetVariable, so extend the fixture's app namespace with GetVariable=document.GetVariable, SetVariable=document.SetVariable (edit the app = types.SimpleNamespace(...) line in the com_backend fixture to app = types.SimpleNamespace(Preferences=..., GetVariable=document.GetVariable, SetVariable=document.SetVariable)).

  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_styles.py -q -k apply Expected: FAIL with ModuleNotFoundError: No module named 'engineering.standards.apply'.

  • Step 3: Write engineering/standards/apply.py
"""One call to put a drawing on a drafting standard: styles, units, layers.

Composes only backend methods that already exist (``textstyle_*``,
``dimstyle_*``, ``system_get/set_variable``, ``drawing_apply_iso_layers``),
so it runs on both engines and needs nothing from the settings facade. Units
are the five system variables both standards share; the decimal separator is
the preset's and arrives through the dimension style. ANSI layer naming is
company-specific, so both standards use the ``mech`` layer set — only styles
and the separator differ (spec §4.2).
"""

from __future__ import annotations

from typing import TYPE_CHECKING, Any

from engineering.standards.dimstyles import resolve_dimstyle
from engineering.standards.textstyles import TEXT_PRESETS

if TYPE_CHECKING:
    from backends.base import AutoCADBackend

__all__ = ["STANDARDS", "STANDARD_SYSVARS", "apply_standard", "resolve_standard"]

STANDARDS: dict[str, dict[str, str]] = {
    "iso": {"preset": "iso-25", "dimstyle": "ISO-25", "textstyle": "ISOCP", "layer_set": "mech"},
    "ansi": {"preset": "ansi", "dimstyle": "ANSI", "textstyle": "ROMANS", "layer_set": "mech"},
}

#: Millimetres, decimal linear and angular units, 1:1 linetype and dimension
#: scale. Written by name so the report can say what moved.
STANDARD_SYSVARS: tuple[tuple[str, Any], ...] = (
    ("INSUNITS", 4),
    ("LUNITS", 2),
    ("AUNITS", 0),
    ("LTSCALE", 1.0),
    ("DIMSCALE", 1.0),
)


def resolve_standard(standard: str) -> tuple[str, dict[str, str]]:
    """``(key, row)`` for ``iso`` / ``ansi`` (any case), else ``ValueError``."""
    key = str(standard).strip().lower() if standard is not None else ""
    if key not in STANDARDS:
        raise ValueError(f"standard: unknown value {standard!r}; use one of {sorted(STANDARDS)}")
    return key, STANDARDS[key]


def _same(old: Any, new: Any) -> bool:
    if old is None:
        return False
    try:
        return abs(float(old) - float(new)) < 1e-9
    except (TypeError, ValueError):
        return str(old) == str(new)


async def apply_standard(
    backend: AutoCADBackend, standard: str, layers: bool = True, units: bool = True
) -> dict:
    """Styles created (or reused) and made current, units written, layers bootstrapped.

    Every item reports whether it was created or already present; ``settings``
    reports only the variables that moved. An existing style of the standard's
    name is used as it is — ``dimstyle_modify`` changes one on purpose.
    """
    key, row = resolve_standard(standard)
    if not isinstance(layers, bool) or not isinstance(units, bool):
        raise TypeError("layers and units must be booleans")

    text_names = {entry["name"].lower() for entry in await backend.textstyle_list()}
    textstyle_created = row["textstyle"].lower() not in text_names
    if textstyle_created:
        font_file, width, oblique = TEXT_PRESETS[row["textstyle"]]
        await backend.textstyle_create(row["textstyle"], font_file, 0.0, width, oblique, False)
    await backend.textstyle_set_current(row["textstyle"])

    dim_names = {entry["name"].lower() for entry in await backend.dimstyle_list()}
    dimstyle_created = row["dimstyle"].lower() not in dim_names
    if dimstyle_created:
        await backend.dimstyle_create(
            row["dimstyle"], resolve_dimstyle(row["preset"], None), set_current=True
        )
    else:
        await backend.dimstyle_set_current(row["dimstyle"])

    changed: dict[str, list] = {}
    if units:
        for var, value in STANDARD_SYSVARS:
            old = await backend.system_get_variable(var)
            if _same(old, value):
                continue
            await backend.system_set_variable(var, value)
            changed[var] = [old, value]

    layer_result = None
    if layers:
        applied = await backend.drawing_apply_iso_layers(row["layer_set"])
        layer_result = {"layer_set": row["layer_set"], "layers": applied["layers"]}

    return {
        "standard": key,
        "dimstyle": {"name": row["dimstyle"], "created": dimstyle_created, "current": True},
        "textstyle": {"name": row["textstyle"], "created": textstyle_created, "current": True},
        "settings": {"changed": changed, "applied": units},
        "layers": layer_result,
    }
  • Step 4: Register the tool, alias, counts

Change the SECTION 18 header to # ── SECTION 18: Styles (10 tools) ──… (keep the rule width) and add after mleaderstyle_create:

@cad_tool(
    summary="Put the drawing on ISO or ANSI in one call: dimstyle, text style, units, mech layers.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Drawing: Apply Drafting Standard", "readOnlyHint": False},
    tags={"style", "drawing"},
)
async def drawing_apply_standard(
    standard: Annotated[str, "iso (ISO-25 / ISOCP, decimal comma) | ansi (ANSI / ROMANS, decimal point)"],
    layers: Annotated[bool, "Also bootstrap the mech layer set with ISO 128 lineweights"] = True,
    units: Annotated[bool, "Also write INSUNITS mm, LUNITS/AUNITS decimal, LTSCALE 1, DIMSCALE 1"] = True,
    ctx: Context = None,
) -> dict:
    """One call for the common case: the standard's dimension style (created
    if missing, then current), its text style (created if missing, then
    current), the shared units, and with `layers` the `mech` layer set (ANSI
    layer naming is company-specific, so both standards share it).

    Every item reports `created` or already present; `settings.changed`
    names only the variables that moved, so a second call reports nothing.
    An existing ISO-25 / ANSI style is reused as it is, not reset — use
    `dimstyle_modify` to change one. Refuses an unknown standard before any
    write; the underlying style refusals (`dimstyle_create`,
    `textstyle_create`) apply unchanged. In the `lean` profile (Task 13).
    """
    from engineering.standards.apply import apply_standard

    await ctx.info(f"Applying the {standard} drafting standard")
    return await apply_standard(_backend(ctx), standard, layers, units)

Alias record, inserted after dimstyle_set_current in the styles block:

    "drawing_apply_standard": ToolAliases(
        acad=(),
        synonyms=(
            "set the drawing up to iso",
            "iso drafting standard",
            "ansi drawing setup",
            "apply the drafting standard",
            "iso-25 and isocp in one go",
            "standard styles units and layers",
        ),
    ),

Then: uv run --frozen python scripts/generate_tool_inventory.py; README 175 tools → 176 tools; registry snapshot "styles": 10, total 176.

  • Step 5: Run the tests and gates

Run: uv run --frozen pytest tests/test_styles.py tests/test_standards_presets.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_meta_tools.py -q Expected: all PASS (same caveat on the README collected-tests figure as Task 11).

  • Step 6: Lint and commit

Run: uv run --frozen ruff check engineering/standards server.py discovery tests/test_styles.py && uv run --frozen ruff format --check engineering/standards tests/test_styles.py

git add engineering/standards/apply.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_tool_registry.py tests/test_styles.py
git commit -m "feat(styles): drawing_apply_standard — ISO or ANSI styles, units and layers in one idempotent call"

Task 13: Discovery golden queries for styles + the lean decision

Files:

  • Modify: tests/data/discovery_golden.py (five tuning cases; docstring count), server.py (LEAN_TOOL_NAMES + 3)
  • Test: tests/test_styles.py (append the lean assertion)

Interfaces:

  • Lean decision (spec §8.2): dimstyle_set_current, textstyle_set_current, drawing_apply_standard join LEAN_TOOL_NAMES (50 → 53; the final task adds P's two for 55). The seven authoring/list tools stay full-only: a lean client uses a style a template holds and, when it must author, drawing_apply_standard does the whole job in one call. Pack: all ten stay core (no PACK_TOOL_NAMES entry).

  • The holdout set is frozen (its docstring forbids edits); the five new cases go into TUNING_CASES. If one misses, the honest fix is vocabulary in the tool's alias record — never a relabelled case.

  • Step 1: Write the failing lean test

Append to tests/test_styles.py:

# ── profile decision ────────────────────────────────────────────────────────


def test_lean_carries_the_two_set_current_tools_and_apply_standard_only():
    import server

    lean = server.LEAN_TOOL_NAMES
    assert {"dimstyle_set_current", "textstyle_set_current", "drawing_apply_standard"} <= lean
    assert not (STYLE_TOOLS - {"dimstyle_set_current", "textstyle_set_current"}) & lean, (
        "authoring and listing styles is full-profile work"
    )
    claimed = set().union(*server.PACK_TOOL_NAMES.values())
    assert not claimed & (STYLE_TOOLS | {"drawing_apply_standard"}), (
        "styles are core: no pack may claim them"
    )
  • Step 2: Run it to verify it fails

Run: uv run --frozen pytest tests/test_styles.py -q -k lean Expected: FAIL on the first assertion (none of the three is in LEAN_TOOL_NAMES).

  • Step 3: The lean profile

In server.py::LEAN_TOOL_NAMES, after the three pid_* entries add:

        # Styles (track E) — a lean client *uses* a style far more often than
        # it authors one; drawing_apply_standard authors the whole set in one
        # call when it must.
        "dimstyle_set_current",
        "textstyle_set_current",
        "drawing_apply_standard",
  • Step 4: The golden queries

In tests/data/discovery_golden.py, change the docstring's first sentence to Seventy-one cases (56 tuning + 15 holdout) split into a **tuning** set and a **holdout** set. and append to TUNING_CASES (before its closing parenthesis):

    # Styles (track E). DIMSTYLE / STYLE / MLEADERSTYLE are umbrella commands
    # shared by their list/create/modify/set-current tools, so the one command
    # case uses the command unique to a single tool and the rest are phrased.
    GoldenCase("DDIM", "dimstyle_create", "command"),
    GoldenCase("make ISO-25 the current dimension style", "dimstyle_set_current", "paraphrase"),
    GoldenCase(
        "set the drawing up to the ISO drafting standard in one go",
        "drawing_apply_standard",
        "paraphrase",
    ),
    GoldenCase("create a text style that uses the isocp font", "textstyle_create", "paraphrase"),
    GoldenCase("which dimension styles are defined", "dimstyle_list", "counting", risk="read"),
  • Step 5: Run the discovery suite

Run: uv run --frozen pytest tests/test_discovery_transform.py tests/test_discovery_aliases.py tests/test_styles.py tests/test_tool_profiles.py tests/test_tool_packs.py tests/test_token_suite.py -q Expected: all PASS — each of the five new cases in the top 3 (test_tuning_case_finds_its_tool), the tuning bars (top-1 ≥ 0.85, top-3 ≥ 0.94 over 56) and the stock A/B (mine1 >= 2 * stock1) still cleared. If a new case misses, add the missing phrase to that tool's record in discovery/aliases.py (e.g. "drafting standard in one go" to drawing_apply_standard) and re-run; do not edit the case or any constant in the transform. test_tool_profiles.py re-derives the lean count from LEAN_TOOL_NAMES, and tests/test_token_suite.py compares advertised_tools against it, so neither needs a hand-typed number.

  • Step 6: Commit
git add server.py tests/data/discovery_golden.py tests/test_styles.py
git commit -m "feat(discovery): style golden queries; dimstyle/textstyle set_current and drawing_apply_standard join the lean profile"

The README configuration row (lean (50 curated tools)), the surface table, and CLAUDE.md's SECTION 18 / profile prose are the final task's, which also carries group P's two lean additions and re-derives the collected-tests figure — leaving one edit site for all of it.

Group C — settings (Tasks 14–16, worktree track-e/settings)

Group C owns backends/contracts/settings.py, engineering/standards/sysvars.py, the two drawing_properties_* implementations on both backends (inside a # ── settings (track E, group C) ── block each), the system_get_variable / system_set_variable methods of both backends (the sysvar path is this group's domain; no other group's task touches them), server.py's system_variable_describe / drawing_properties_* under the SECTION 20 stub, the system_set_variable tool's catalogue check and the drawing_settings tool docstring, its own alias records, and tests/test_settings_facade.py, tests/test_sysvar_catalog.py, tests/test_drawing_properties.py.

Measured before these tasks were written (ezdxf 1.4.4, uv run --frozen python, 2026-09-16) — every decision below follows from one of these:

  1. doc.header["$GRIDMODE"] = 1 raises DXFKeyError: Invalid header variable $GRIDMODE. — the same for $GRIDUNIT, $SNAPMODE, $SNAPUNIT, $POLARMODE, $POLARANG, $CANNOSCALE, $CANNOSCALEVALUE, $CTAB, $VIEWCTR, $VIEWSIZE, $HPNAME, $FILEDIA, $XREFCTL, $ISOLINES, $FACETRES. None of them is a DXF HEADER variable. Today system_set_variable("GRIDMODE", 1) therefore raises that cryptic error headlessly and system_get_variable("GRIDMODE") silently returns None.
  2. Grid and snap live on the active VPORT table entry (doc.viewports.get("*Active")[0].dxf.grid_on / grid_spacing / snap_on / snap_spacing, DXF codes 76 / 15 / 75 / 14) and round-trip through save + reopen on an R2010 document.
  3. CANNOSCALE is a DICTIONARYVAR in the root dictionary's AcDbVariableDictionary (ezdxf's own sections/header.py says so); a fresh ezdxf document has neither the dictionary nor any ACAD_SCALELIST entries, and ezdxf has no SCALE entity class — but a raw-tag SCALE object loaded through ezdxf.entities.factory.load round-trips, ezdxf registers its CLASS, and doc.audit() is clean after reopen. CANNOSCALEVALUE is derived (read-only in AutoCAD too).
  4. A $LIMMAX written to the header is overwritten at save: ezdxf.document.Drawing.update_limits() copies the model-space LAYOUT's limmin / limmax into the header on every write(). system_set_variable("LIMMAX", (500, 350)) reported ok: True today and the file reopened at (420, 297).
  5. $OSMODE, $ATTREQ, $ATTDIA are capped at maxdxf="AC1009" in HEADER_VAR_MAP: ezdxf holds them in memory but never exports them for R2000+ files.
  6. POLARANG, POLARMODE, AUTOSNAP, ATTREQ, ATTDIA, SAVETIME, FILEDIA, XREFCTL, PROXYSHOW are registry-saved and CMDECHO, HIGHLIGHT, HPNAME, HPSCALE, HPANG, DWGNAME, DWGPREFIX are never saved (AutoCAD System Variables reference, "Saved in" column): a headless file has nowhere to keep them. Polar tracking's on/off switch is AUTOSNAP bit 8 (the F10 toggle); POLARMODE bit 0 means "measure polar angles relative to the last segment". The spec's $POLARMODE bit 0 wording is corrected here.
  7. engineering/standards/ does not exist in this worktree (group S creates __init__.py); import engineering.standards.sysvars works as a namespace package without it — verified with a probe module — so Task 15 does not create __init__.py.
  8. ruff format --check . formats Python fenced blocks inside Markdown (ruff 0.16.1), so this file itself is run through uv run --frozen ruff format docs/superpowers/plans/_settings_part_C.md before it is committed.

Task 14: drawing_settings grows 14 keys, on both engines, with honest headless storage

Files:

  • Modify: backends/contracts/settings.py (rewritten in full below — the facade gains _read_setting / _write_setting, parse_scale / scale_value, the new kinds and ranges)
  • Modify: backends/ezdxf_backend.py (system_get_variable / system_set_variable replaced in place; _VPORT_SYSVARS / _UNSAVED_SYSVARS constants; a # ── settings (track E, group C) ── helper block at the very end of the class, after _create_xline — group C's reserved anchor, see the skeleton's Merge notes; registry_sysvar in capabilities())
  • Modify: backends/com_backend.py (system_set_variable learns to send point variables as VARIANT double arrays; registry_sysvar in capabilities())
  • Modify: server.py (drawing_settings tool: parameter description + docstring name the new keys and the refusals)
  • Modify: discovery/aliases.py (drawing_settings record grows: LIMITS, GRID, SNAP, ORTHO, PSLTSCALE, CANNOSCALE + synonyms)
  • Test: tests/test_settings_facade.py; tests/test_drawing_settings.py and tests/test_dimension_header_vars.py must stay green

Interfaces:

  • Consumes: system_get_variable(name) / system_set_variable(name, value) on both backends; dimstyle_set_current(name) / textstyle_set_current(name) from group S when present (callable(getattr(self, ...))), otherwise the key is refused by name.

  • Produces:

    • _SETTING_MAP gains limits ("limits" → LIMMIN + LIMMAX), grid ("bool" → GRIDMODE), grid_spacing ("spacing" → GRIDUNIT, both axes), snap ("bool" → SNAPMODE), snap_spacing ("spacing" → SNAPUNIT), ortho ("bool" → ORTHOMODE), polar ("bit" → AUTOSNAP bit 8), polar_angle ("degrees" → POLARANG in radians), psltscale ("bool" → PSLTSCALE), annotation_scale ("scale" → CANNOSCALE "1:50"; the value 0.02 is derived), linear_units ("lunits" enum → LUNITS 1–5), angular_units ("aunits" enum → AUNITS 0–4), dimstyle / textstyle ("name" → dimstyle_set_current / textstyle_set_current; read through DIMSTYLE / TEXTSTYLE).
    • drawing_settings(dict) → {"ok", "applied", "changed": {key: [old, new]}, "errors"?}; drawing_settings() → {"ok": True, "settings": {every key}} where a key the engine cannot hold reads None.
    • parse_scale(text) -> (name, paper, drawing) and scale_value(text) -> float (module-level, imported by the ezdxf backend).
    • EzdxfBackend.system_set_variable: GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT → active VPORT; LIMMIN / LIMMAX → header and model-space layout; CANNOSCALE → AcDbVariableDictionary + an ACAD_SCALELIST SCALE entry; registry / never-saved names → UnsupportedCapabilityError("registry_sysvar", …) before any write; an unknown header name → ValueError carrying ezdxf's message (keeps the pinned $$DIMTXT behaviour). system_get_variable mirrors the reads (CANNOSCALE defaults to "1:1", CANNOSCALEVALUE is computed).
    • Capability key registry_sysvar in both maps (ezdxf False, COM True).
  • Pack / lean: drawing_settings is an existing core tool; unchanged.

  • Step 1: Write the failing tests

# tests/test_settings_facade.py
"""drawing_settings, track E: limits, grid/snap, ortho/polar, PSLTSCALE, annotation
scale, unit formats and the current styles — on both engines.

Measured before writing (ezdxf 1.4.4, 2026-09-16):

* `doc.header["$GRIDMODE"]` (and GRIDUNIT / SNAPMODE / SNAPUNIT / POLARMODE /
  POLARANG / CANNOSCALE / CANNOSCALEVALUE) raises `DXFKeyError: Invalid header
  variable` — none of them is a DXF header variable. Grid and snap live on the
  active VPORT; CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary; the
  polar pair is registry-saved and has no home in a file at all.
* A `$LIMMAX` written to the header is overwritten at save by
  `Drawing.update_limits()`, which copies the model-space LAYOUT's limits into
  the header — so a header-only write reported success and vanished on reload.
"""

from __future__ import annotations

import math

import pytest

from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import _SETTING_MAP, parse_scale, scale_value

pytestmark = pytest.mark.asyncio

NEW_KEYS = (
    "limits",
    "grid",
    "grid_spacing",
    "snap",
    "snap_spacing",
    "ortho",
    "polar",
    "polar_angle",
    "psltscale",
    "annotation_scale",
    "linear_units",
    "angular_units",
    "dimstyle",
    "textstyle",
)


def _active_vport(backend):
    return backend._doc.viewports.get("*Active")[0]


# ── pure helpers ────────────────────────────────────────────────────────────


def test_parse_scale_canonicalises_and_refuses():
    assert parse_scale("1:50") == ("1:50", 1.0, 50.0)
    assert parse_scale(" 2 : 1 ") == ("2:1", 2.0, 1.0)
    assert parse_scale("1:2.5") == ("1:2.5", 1.0, 2.5)
    assert scale_value("1:50") == pytest.approx(0.02)
    for bad in ("fifty", "1:0", "0:1", "1-50", "", True):
        with pytest.raises(ValueError, match="annotation_scale"):
            parse_scale(bad)


def test_every_new_key_is_in_the_map():
    for key in NEW_KEYS:
        assert key in _SETTING_MAP, key


# ── headless engine ─────────────────────────────────────────────────────────


async def test_snapshot_reports_every_key_including_the_unavailable_ones(backend):
    snap = (await backend.drawing_settings())["settings"]
    for key in NEW_KEYS:
        assert key in snap, key
    assert snap["limits"] == [[0.0, 0.0], [420.0, 297.0]]
    assert snap["grid"] is False and snap["snap"] is False and snap["ortho"] is False
    assert snap["psltscale"] is True
    assert snap["annotation_scale"] == {"name": "1:1", "value": 1.0}
    assert snap["linear_units"] == {"code": 2, "name": "decimal"}
    assert snap["angular_units"] == {"code": 0, "name": "degrees"}
    assert snap["dimstyle"] == "ISO-25"
    assert snap["textstyle"] == "Standard"
    # registry-saved: a file holds no value, and None says so rather than a guess
    assert snap["polar"] is None
    assert snap["polar_angle"] is None


async def test_limits_survive_a_save_and_reopen(backend, tmp_path):
    res = await backend.drawing_settings({"limits": [[0, 0], [500, 350]]})
    assert res["ok"] is True, res.get("errors")
    assert res["changed"]["limits"] == [
        [[0.0, 0.0], [420.0, 297.0]],
        [[0.0, 0.0], [500.0, 350.0]],
    ]
    layout = backend._doc.modelspace().dxf_layout.dxf
    assert tuple(layout.limmax)[:2] == (500.0, 350.0), "the layout is what survives write()"

    path = str(tmp_path / "limits.dxf")
    await backend.drawing_save_as(path)
    await backend.drawing_open(path)
    assert (await backend.drawing_settings())["settings"]["limits"] == [
        [0.0, 0.0],
        [500.0, 350.0],
    ]


async def test_grid_and_snap_live_on_the_active_vport(backend, tmp_path):
    res = await backend.drawing_settings(
        {"grid": True, "grid_spacing": 5, "snap": "on", "snap_spacing": [2.5, 5]}
    )
    assert res["ok"] is True, res.get("errors")
    vport = _active_vport(backend).dxf
    assert vport.grid_on == 1 and vport.snap_on == 1
    assert tuple(vport.grid_spacing)[:2] == (5.0, 5.0)
    assert tuple(vport.snap_spacing)[:2] == (2.5, 5.0)

    snap = (await backend.drawing_settings())["settings"]
    assert snap["grid"] is True and snap["grid_spacing"] == 5.0
    assert snap["snap"] is True and snap["snap_spacing"] == [2.5, 5.0]

    path = str(tmp_path / "grid.dxf")
    await backend.drawing_save_as(path)
    await backend.drawing_open(path)
    again = (await backend.drawing_settings())["settings"]
    assert again["grid"] is True and again["grid_spacing"] == 5.0
    assert again["snap_spacing"] == [2.5, 5.0]


async def test_ortho_and_psltscale_are_header_variables(backend):
    res = await backend.drawing_settings({"ortho": True, "psltscale": False})
    assert res["ok"] is True, res.get("errors")
    assert backend._doc.header["$ORTHOMODE"] == 1
    assert backend._doc.header["$PSLTSCALE"] == 0
    snap = (await backend.drawing_settings())["settings"]
    assert snap["ortho"] is True and snap["psltscale"] is False


async def test_polar_is_refused_headlessly_by_capability(backend):
    res = await backend.drawing_settings({"polar": True, "polar_angle": 45, "ortho": True})
    assert res["ok"] is False
    assert res["applied"] == {"ortho": True}, "the key that has a home is still applied"
    assert "registry" in res["errors"]["polar"]
    assert "registry" in res["errors"]["polar_angle"]
    with pytest.raises(UnsupportedCapabilityError) as excinfo:
        await backend.system_set_variable("POLARANG", 0.5)
    assert excinfo.value.capability == "registry_sysvar"


async def test_annotation_scale_writes_the_variable_dictionary_and_scale_list(backend, tmp_path):
    res = await backend.drawing_settings({"annotation_scale": "1:50"})
    assert res["ok"] is True, res.get("errors")
    assert res["changed"]["annotation_scale"] == [
        {"name": "1:1", "value": 1.0},
        {"name": "1:50", "value": 0.02},
    ]
    vardict = backend._doc.rootdict.get("AcDbVariableDictionary")
    assert vardict["CANNOSCALE"].dxf.value == "1:50"
    scales = backend._doc.rootdict.get("ACAD_SCALELIST")
    assert scales.get("1:50") is not None, "AutoCAD only honours a scale that is in the list"
    assert await backend.system_get_variable("CANNOSCALEVALUE") == pytest.approx(0.02)

    # re-setting the same scale is not a change and adds no second list entry
    again = await backend.drawing_settings({"annotation_scale": "1:50"})
    assert again["changed"] == {}
    assert len(list(scales.keys())) == 1

    path = str(tmp_path / "scale.dxf")
    await backend.drawing_save_as(path)
    await backend.drawing_open(path)
    assert (await backend.drawing_settings())["settings"]["annotation_scale"] == {
        "name": "1:50",
        "value": 0.02,
    }
    assert backend._doc.audit().has_errors is False


async def test_unit_formats_by_name_and_by_code(backend):
    res = await backend.drawing_settings({"linear_units": "architectural", "angular_units": "dms"})
    assert res["ok"] is True, res.get("errors")
    assert backend._doc.header["$LUNITS"] == 4
    assert backend._doc.header["$AUNITS"] == 1
    snap = (await backend.drawing_settings())["settings"]
    assert snap["linear_units"] == {"code": 4, "name": "architectural"}
    assert snap["angular_units"] == {"code": 1, "name": "dms"}

    by_code = await backend.drawing_settings({"linear_units": 1, "angular_units": 3})
    assert by_code["ok"] is True
    assert backend._doc.header["$LUNITS"] == 1 and backend._doc.header["$AUNITS"] == 3

    bad = await backend.drawing_settings({"linear_units": "furlongs", "angular_units": 9})
    assert "architectural" in bad["errors"]["linear_units"]
    assert "angular_units" in bad["errors"]["angular_units"]
    assert backend._doc.header["$LUNITS"] == 1, "a refused value writes nothing"


async def test_current_styles_delegate_to_the_styles_contract(backend, monkeypatch):
    calls: list[tuple[str, str]] = []

    async def _dimstyle(name):
        calls.append(("dimstyle_set_current", name))
        backend._doc.header["$DIMSTYLE"] = name
        return {"ok": True, "current": name, "previous": "ISO-25"}

    async def _textstyle(name):
        calls.append(("textstyle_set_current", name))
        backend._doc.header["$TEXTSTYLE"] = name
        return {"ok": True, "current": name, "previous": "Standard"}

    monkeypatch.setattr(backend, "dimstyle_set_current", _dimstyle, raising=False)
    monkeypatch.setattr(backend, "textstyle_set_current", _textstyle, raising=False)
    res = await backend.drawing_settings({"dimstyle": " ANSI ", "textstyle": "ISOCP"})
    assert res["ok"] is True, res.get("errors")
    assert calls == [("dimstyle_set_current", "ANSI"), ("textstyle_set_current", "ISOCP")]
    assert res["changed"]["dimstyle"] == ["ISO-25", "ANSI"]
    snap = (await backend.drawing_settings())["settings"]
    assert snap["dimstyle"] == "ANSI" and snap["textstyle"] == "ISOCP"


async def test_current_styles_are_refused_when_no_styles_contract_exists(backend, monkeypatch):
    # Group S may or may not have merged: force the "absent" branch either way.
    monkeypatch.setattr(backend, "dimstyle_set_current", None, raising=False)
    res = await backend.drawing_settings({"dimstyle": "ANSI"})
    assert res["ok"] is False
    assert "dimstyle_set_current" in res["errors"]["dimstyle"]
    assert (await backend.drawing_settings())["settings"]["dimstyle"] == "ISO-25"


async def test_changed_reports_only_values_that_moved(backend):
    same = await backend.drawing_settings({"psltscale": True, "ortho": False})
    assert same["ok"] is True and same["changed"] == {}
    moved = await backend.drawing_settings({"psltscale": False, "ortho": False})
    assert moved["changed"] == {"psltscale": [True, False]}


async def test_malformed_values_are_refused_before_any_write(backend):
    before_limmax = tuple(backend._doc.header["$LIMMAX"])
    res = await backend.drawing_settings(
        {
            "limits": [[0, 0], [0, 5]],
            "annotation_scale": "fifty",
            "grid_spacing": 0,
            "snap_spacing": [1, 2, 3],
            "polar": "maybe",
            "dimstyle": "",
        }
    )
    assert res["ok"] is False and res["applied"] == {}
    assert set(res["errors"]) == {
        "limits",
        "annotation_scale",
        "grid_spacing",
        "snap_spacing",
        "polar",
        "dimstyle",
    }
    assert "upper-right" in res["errors"]["limits"]
    assert "paper:drawing" in res["errors"]["annotation_scale"]
    assert "greater than 0" in res["errors"]["grid_spacing"]
    assert tuple(backend._doc.header["$LIMMAX"]) == before_limmax
    assert backend._doc.rootdict.get("AcDbVariableDictionary") is None
    assert tuple(_active_vport(backend).dxf.grid_spacing)[:2] != (0.0, 0.0)


async def test_raw_gridmode_routes_to_the_vport_and_the_old_error_is_gone(backend):
    assert await backend.system_get_variable("GRIDMODE") == 0
    out = await backend.system_set_variable("GRIDMODE", 1)
    assert out["ok"] is True
    assert _active_vport(backend).dxf.grid_on == 1
    assert await backend.system_get_variable("GRIDMODE") == 1


async def test_raw_unknown_header_name_is_a_named_value_error(backend):
    with pytest.raises(ValueError, match=r"\$HPNAME2"):
        await backend.system_set_variable("HPNAME2", "x")


async def test_cannoscalevalue_is_read_only(backend):
    with pytest.raises(ValueError, match="CANNOSCALEVALUE is read-only"):
        await backend.system_set_variable("CANNOSCALEVALUE", 0.5)


def test_both_capability_maps_declare_registry_sysvar():
    from backends.com_backend import ComBackend
    from backends.ezdxf_backend import EzdxfBackend

    ezdxf_features = EzdxfBackend().capabilities().to_dict()["features"]
    com_features = ComBackend().capabilities().to_dict()["features"]
    assert ezdxf_features["registry_sysvar"]["supported"] is False
    assert com_features["registry_sysvar"]["supported"] is True


# ── server tool wiring ──────────────────────────────────────────────────────


class _FakeCtx:
    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, *a, **k):
        pass


async def test_server_tool_carries_the_new_keys(backend):
    import server

    ctx = _FakeCtx(backend)
    write = await server.drawing_settings(
        settings={"limits": [[0, 0], [297, 210]], "grid": True, "annotation_scale": "1:2"},
        ctx=ctx,
    )
    assert write["ok"] is True, write.get("errors")
    read = await server.drawing_settings(ctx=ctx)
    assert read["settings"]["limits"] == [[0.0, 0.0], [297.0, 210.0]]
    assert read["settings"]["annotation_scale"] == {"name": "1:2", "value": 0.5}


# ── live engine, against a fake ActiveX application ─────────────────────────


class _FakeApp:
    """Records GetVariable / SetVariable; stores what AutoCAD would store."""

    def __init__(self):
        self.store = {
            "INSUNITS": 4,
            "LIMMIN": (0.0, 0.0),
            "LIMMAX": (420.0, 297.0),
            "GRIDMODE": 0,
            "GRIDUNIT": (10.0, 10.0),
            "SNAPMODE": 0,
            "SNAPUNIT": (10.0, 10.0),
            "ORTHOMODE": 0,
            "AUTOSNAP": 63,
            "POLARANG": math.pi / 2,
            "PSLTSCALE": 1,
            "CANNOSCALE": "1:1",
            "LUNITS": 2,
            "AUNITS": 0,
            "DIMSTYLE": "ISO-25",
            "TEXTSTYLE": "Standard",
            "DIMSCALE": 1.0,
        }
        self.calls: list[tuple] = []

    def GetVariable(self, name):
        self.calls.append(("GetVariable", name))
        return self.store[name]

    def SetVariable(self, name, value):
        stored = getattr(value, "value", value)  # a VARIANT array → its Python sequence
        if isinstance(stored, list):
            stored = tuple(stored)
        self.calls.append(("SetVariable", name, stored))
        self.store[name] = stored


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    app = _FakeApp()
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, app


async def test_com_facade_sends_autocad_shaped_values(com_backend):
    backend, app = com_backend
    res = await backend.drawing_settings(
        {
            "limits": [[0, 0], [500, 350]],
            "grid": True,
            "grid_spacing": 5,
            "polar": False,
            "polar_angle": 45,
            "annotation_scale": "1:50",
            "linear_units": "architectural",
            "psltscale": 0,
        }
    )
    assert res["ok"] is True, res.get("errors")
    sets = [call[1:] for call in app.calls if call[0] == "SetVariable"]
    assert sets == [
        ("LIMMIN", (0.0, 0.0)),
        ("LIMMAX", (500.0, 350.0)),
        ("GRIDMODE", 1),
        ("GRIDUNIT", (5.0, 5.0)),
        ("AUTOSNAP", 55),  # 63 with bit 8 cleared: polar off, everything else kept
        ("POLARANG", pytest.approx(math.radians(45))),
        ("CANNOSCALE", "1:50"),
        ("LUNITS", 4),
        ("PSLTSCALE", 0),
    ]
    assert res["changed"]["polar"] == [True, False]
    assert res["changed"]["polar_angle"] == [90.0, 45.0]


async def test_com_point_variables_travel_as_variant_double_arrays(com_backend):
    import pythoncom

    backend, app = com_backend
    seen: dict = {}
    real_set = app.SetVariable

    def _capture(name, value):
        seen[name] = value
        real_set(name, value)

    app.SetVariable = _capture
    await backend.system_set_variable("LIMMAX", (300, 200))
    variant = seen["LIMMAX"]
    assert variant.varianttype == pythoncom.VT_ARRAY | pythoncom.VT_R8
    assert tuple(variant.value) == (300.0, 200.0)


async def test_com_refuses_before_touching_activex(com_backend):
    backend, app = com_backend
    res = await backend.drawing_settings({"limits": [[0, 0], [0, 0]], "annotation_scale": "x"})
    assert res["ok"] is False
    assert not [call for call in app.calls if call[0] == "SetVariable"]
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_settings_facade.py -v Expected: collection ERROR — ImportError: cannot import name 'parse_scale' from 'backends.contracts.settings'.

  • Step 3: Rewrite backends/contracts/settings.py

Replace the whole module with:

"""Friendly facade over system variables.

Split out of the single 1854-line ``AutoCADBackend`` ABC in v1.5.0 (M7).
``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.

v1.6 (track E) grows the facade from 15 keys to 29: drawing limits, grid and
snap, ortho and polar tracking, PSLTSCALE, the annotation scale, linear and
angular unit formats, and the current dimension / text style. Every key still
goes through ``system_get_variable`` / ``system_set_variable`` so the facade
stays backend-agnostic; the *engines* are where a variable's real home is
known (header, VPORT, the variable dictionary, or — headlessly — nowhere).
"""

from __future__ import annotations

import math
import re
from typing import Any


class SettingsContract:
    # A user-facing wrapper over system_get_variable / system_set_variable that
    # maps memorable names ("units", "dimscale", …) to AutoCAD system variables.
    # Concrete + backend-agnostic: both engines accept the bare sysvar name, so
    # the same call reads/writes on live COM and headless ezdxf alike.

    async def drawing_settings(self, settings: dict | None = None) -> dict:
        """Read (no args) or apply (with args) common drawing settings.

        With ``settings=None`` returns a snapshot of every known setting. With a
        dict, applies each provided key and returns ``{applied, changed,
        errors}`` — ``changed`` holds ``[old, new]`` only for keys whose value
        actually moved (re-setting a value to itself is not a change).

        Friendly keys: units, linear_precision, angular_precision, ltscale,
        dimscale, dim_text_height, dim_arrow_size, dim_decimals,
        decimal_separator, zero_suppression, text_size, point_mode, point_size,
        osmode, fillet_radius, limits, grid, grid_spacing, snap, snap_spacing,
        ortho, polar, polar_angle, psltscale, annotation_scale, linear_units,
        angular_units, dimstyle, textstyle.

        Refusals (per key, in ``errors``; the other keys still apply): an
        unknown key; a value outside ``_SETTING_RANGES``; a malformed
        ``limits`` / ``annotation_scale``; ``polar`` / ``polar_angle`` on the
        headless engine (registry-saved — ``capability: registry_sysvar``);
        ``dimstyle`` / ``textstyle`` when the backend has no styles contract.

        Note that a no-argument snapshot is one ``system_get_variable`` per key
        (two for ``limits``). Free headless; a COM round trip each on a live
        seat, and a write costs a read before and after it for ``changed``."""
        if not settings:
            snapshot: dict = {}
            for key, spec in _SETTING_MAP.items():
                try:
                    snapshot[key] = await self._read_setting(key, spec)
                except Exception as exc:
                    snapshot[key] = {"error": str(exc)}
            return {"ok": True, "settings": snapshot}

        applied: dict = {}
        changed: dict = {}
        errors: dict = {}
        for key, value in settings.items():
            spec = _SETTING_MAP.get(key)
            if spec is None:
                errors[key] = f"unknown setting (valid: {sorted(_SETTING_MAP)})"
                continue
            try:
                before = await self._read_setting(key, spec)
                await self._write_setting(key, spec, value)
                after = await self._read_setting(key, spec)
                applied[key] = value
                if after != before:
                    changed[key] = [before, after]
            except Exception as exc:
                errors[key] = str(exc)

        result = {"ok": not errors, "applied": applied, "changed": changed}
        if errors:
            result["errors"] = errors
        return result

    async def _read_setting(self, key: str, spec: tuple[str, str]) -> Any:
        var, kind = spec
        if kind == "limits":
            low = await self.system_get_variable("LIMMIN")
            high = await self.system_get_variable("LIMMAX")
            if low is None or high is None:
                return None
            return [_point2(low), _point2(high)]
        return _decode_setting(key, kind, await self.system_get_variable(var))

    async def _write_setting(self, key: str, spec: tuple[str, str], value: Any) -> None:
        var, kind = spec
        if kind == "limits":
            low, high = _encode_limits(key, value)  # validated before either write
            await self.system_set_variable("LIMMIN", low)
            await self.system_set_variable("LIMMAX", high)
            return
        if kind == "name":
            name = _encode_name(key, value)
            setter = getattr(self, _STYLE_SETTERS[key], None)
            if not callable(setter):
                raise ValueError(
                    f"{key}: this backend has no {_STYLE_SETTERS[key]} (styles contract "
                    f"absent); the current {key} can be read but not changed here."
                )
            await setter(name)
            return
        if kind == "bit":
            flag = _encode_bool(key, value)
            raw = await self.system_get_variable(var)
            current = int(raw) if raw is not None else 0
            mask = _BIT_MASKS[key]
            await self.system_set_variable(var, (current | mask) if flag else (current & ~mask))
            return
        await self.system_set_variable(var, _encode_setting(key, kind, value))


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


# Friendly setting name -> (AutoCAD system variable, value kind).
_SETTING_MAP: dict[str, tuple[str, str]] = {
    "units": ("INSUNITS", "units"),
    "linear_precision": ("LUPREC", "int"),
    "angular_precision": ("AUPREC", "int"),
    "ltscale": ("LTSCALE", "float"),
    "dimscale": ("DIMSCALE", "float"),
    # The dimension variables had no friendly name until v1.5.2, so the only
    # way to reach dimension lettering was a raw `system_set_variable` — which
    # is precisely the path that silently did nothing on the headless engine.
    # `text_size` is TEXTSIZE, the height of a standalone TEXT entity; it has
    # never had anything to do with the numbers on a dimension.
    "dim_text_height": ("DIMTXT", "float"),
    "dim_arrow_size": ("DIMASZ", "float"),
    "dim_decimals": ("DIMDEC", "int"),
    "decimal_separator": ("DIMDSEP", "char"),
    "zero_suppression": ("DIMZIN", "int"),
    "text_size": ("TEXTSIZE", "float"),
    "point_mode": ("PDMODE", "int"),
    "point_size": ("PDSIZE", "float"),
    "osmode": ("OSMODE", "int"),
    "fillet_radius": ("FILLETRAD", "float"),
    # ── track E: the environment a drawing lives in ──────────────────────
    # `limits` is one friendly key over two variables; `_read_setting` and
    # `_write_setting` special-case the kind, the variable named here is the
    # first of the pair.
    "limits": ("LIMMIN", "limits"),
    # GRIDMODE / GRIDUNIT / SNAPMODE / SNAPUNIT are not header variables in a
    # DXF file — AutoCAD keeps them on the active VPORT — so the headless engine
    # routes them there (see `EzdxfBackend.system_set_variable`).
    "grid": ("GRIDMODE", "bool"),
    "grid_spacing": ("GRIDUNIT", "spacing"),
    "snap": ("SNAPMODE", "bool"),
    "snap_spacing": ("SNAPUNIT", "spacing"),
    "ortho": ("ORTHOMODE", "bool"),
    # Polar tracking on/off is AUTOSNAP bit 8 (the F10 toggle), not POLARMODE
    # — POLARMODE's bits choose *how* polar angles are measured. Both AUTOSNAP
    # and POLARANG are registry-saved, so a headless file cannot hold them.
    "polar": ("AUTOSNAP", "bit"),
    "polar_angle": ("POLARANG", "degrees"),
    "psltscale": ("PSLTSCALE", "bool"),
    # CANNOSCALE is a DICTIONARYVAR in AcDbVariableDictionary, not a header
    # variable; CANNOSCALEVALUE is derived from it and read-only.
    "annotation_scale": ("CANNOSCALE", "scale"),
    "linear_units": ("LUNITS", "lunits"),
    "angular_units": ("AUNITS", "aunits"),
    # The current style names read through the header / GetVariable; a write
    # delegates to the styles contract (`dimstyle_set_current` /
    # `textstyle_set_current`) because DIMSTYLE is a read-only variable.
    "dimstyle": ("DIMSTYLE", "name"),
    "textstyle": ("TEXTSTYLE", "name"),
}

_STYLE_SETTERS = {"dimstyle": "dimstyle_set_current", "textstyle": "textstyle_set_current"}

#: AUTOSNAP bit 8 = polar tracking on. (1 marker, 2 magnet, 4 tooltip, 16
#: object snap tracking, 32 tracking tooltips, 64 dynamic input aperture.)
_BIT_MASKS = {"polar": 8}

# INSUNITS code table (AutoCAD $INSUNITS): friendly name <-> integer code.
_UNIT_TO_CODE: dict[str, int] = {
    "unitless": 0,
    "inch": 1,
    "inches": 1,
    "in": 1,
    "feet": 2,
    "ft": 2,
    "foot": 2,
    "mm": 4,
    "millimeter": 4,
    "millimeters": 4,
    "cm": 5,
    "centimeter": 5,
    "centimeters": 5,
    "m": 6,
    "meter": 6,
    "meters": 6,
}
_CODE_TO_UNIT: dict[int, str] = {0: "unitless", 1: "inch", 2: "feet", 4: "mm", 5: "cm", 6: "m"}

# LUNITS (linear unit format) and AUNITS (angular unit format) code tables.
_LUNITS_TO_CODE: dict[str, int] = {
    "scientific": 1,
    "decimal": 2,
    "engineering": 3,
    "architectural": 4,
    "fractional": 5,
}
_CODE_TO_LUNITS: dict[int, str] = {code: name for name, code in _LUNITS_TO_CODE.items()}
_AUNITS_TO_CODE: dict[str, int] = {
    "degrees": 0,
    "dms": 1,
    "grads": 2,
    "radians": 3,
    "surveyor": 4,
}
_CODE_TO_AUNITS: dict[int, str] = {code: name for name, code in _AUNITS_TO_CODE.items()}

#: DIMDSEP is a character *code*, not a character — ezdxf raises DXFValueError
#: on a string in the header — so the friendly key is where the translation
#: belongs. Only two markers are in real use: ISO 129's point and the comma most
#: of continental Europe drafts with. Anything else lands an arbitrary glyph in
#: the middle of every number on the sheet, so it is refused rather than stored.
_DECIMAL_MARKERS = (".", ",")

#: "1:50", "2:1", "1:2.5" — paper units : drawing units, both strictly positive.
SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")


def parse_scale(text: Any) -> tuple[str, float, float]:
    """``"1:50"`` → ``("1:50", 1.0, 50.0)``; raises ``ValueError`` naming the input.

    Returns the canonical name (whitespace stripped, integers without a
    trailing ``.0``), the paper units and the drawing units. The annotation
    scale value AutoCAD reports as CANNOSCALEVALUE is ``paper / drawing``.
    """
    match = SCALE_RE.match(str(text)) if not isinstance(text, bool) else None
    if match is None:
        raise ValueError(
            f"annotation_scale: {text!r} is not a scale — use 'paper:drawing' such as "
            "'1:1', '1:50' or '2:1'."
        )
    paper, drawing = float(match.group(1)), float(match.group(2))
    if paper <= 0 or drawing <= 0:
        raise ValueError(f"annotation_scale: both sides of {text!r} must be greater than 0.")
    return f"{_trim(paper)}:{_trim(drawing)}", paper, drawing


def scale_value(text: Any) -> float:
    """CANNOSCALEVALUE for a scale name: ``"1:50"`` → ``0.02``."""
    _, paper, drawing = parse_scale(text)
    return paper / drawing


def _trim(number: float) -> str:
    return str(int(number)) if number == int(number) else f"{number:g}"


def _point2(raw: Any) -> list[float]:
    """A 2D point from whatever the engine returned (tuple, list, Vec2/Vec3)."""
    try:
        return [float(raw[0]), float(raw[1])]
    except (TypeError, IndexError, ValueError) as exc:
        raise ValueError(f"expected a point, got {raw!r}") from exc


def _finite(key: str, value: Any, what: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{key}: {what} must be a number, got {value!r}")
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{key}: {what} must be finite")
    return number


def _encode_limits(key: str, value: Any) -> tuple[tuple[float, float], tuple[float, float]]:
    """``[[xmin, ymin], [xmax, ymax]]`` → two 2D tuples; refused before any write."""
    if (
        not isinstance(value, (list, tuple))
        or len(value) != 2
        or any(not isinstance(corner, (list, tuple)) or len(corner) != 2 for corner in value)
    ):
        raise ValueError(f"{key}: expected [[xmin, ymin], [xmax, ymax]], got {value!r}")
    xmin = _finite(key, value[0][0], "xmin")
    ymin = _finite(key, value[0][1], "ymin")
    xmax = _finite(key, value[1][0], "xmax")
    ymax = _finite(key, value[1][1], "ymax")
    if xmax <= xmin or ymax <= ymin:
        raise ValueError(
            f"{key}: the upper-right corner must be above and right of the lower-left "
            f"(got {[[xmin, ymin], [xmax, ymax]]})"
        )
    return (xmin, ymin), (xmax, ymax)


_TRUE_WORDS = {"1", "on", "true", "yes"}
_FALSE_WORDS = {"0", "off", "false", "no"}


def _encode_bool(key: str, value: Any) -> bool:
    if isinstance(value, bool):
        return value
    if isinstance(value, (int, float)) and value in (0, 1):
        return bool(value)
    if isinstance(value, str):
        word = value.strip().lower()
        if word in _TRUE_WORDS:
            return True
        if word in _FALSE_WORDS:
            return False
    raise ValueError(f"{key}: expected true/false (or 1/0, on/off), got {value!r}")


def _encode_name(key: str, value: Any) -> str:
    if not isinstance(value, str) or not value.strip():
        raise ValueError(f"{key}: expected a non-empty style name, got {value!r}")
    return value.strip()


def _encode_enum(key: str, value: Any, table: dict[str, int]) -> int:
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        code = int(value)
        if code in table.values():
            return code
        raise ValueError(f"{key}: code {code} is not one of {sorted(table.values())}")
    name = str(value).strip().lower()
    if name in table:
        return table[name]
    raise ValueError(f"{key}: unknown value {value!r}. Use one of {sorted(table)}.")


def _encode_setting(key: str, kind: str, value: Any) -> Any:
    """Coerce a friendly value into the raw system-variable value."""
    if kind == "units":
        if isinstance(value, (int, float)) and not isinstance(value, bool):
            return int(value)
        code = _UNIT_TO_CODE.get(str(value).strip().lower())
        if code is None:
            raise ValueError(
                f"units: unknown value {value!r}. Use one of "
                f"{sorted(set(_UNIT_TO_CODE))} or an INSUNITS integer."
            )
        return code
    if kind == "lunits":
        return _encode_enum(key, value, _LUNITS_TO_CODE)
    if kind == "aunits":
        return _encode_enum(key, value, _AUNITS_TO_CODE)
    if kind == "bool":
        return 1 if _encode_bool(key, value) else 0
    if kind == "scale":
        name, _paper, _drawing = parse_scale(value)
        return name
    if kind == "spacing":
        if isinstance(value, (list, tuple)):
            if len(value) != 2:
                raise ValueError(f"{key}: expected a number or [x, y], got {value!r}")
            x = _in_range(key, _finite(key, value[0], "x"))
            y = _in_range(key, _finite(key, value[1], "y"))
            return (x, y)
        step = _in_range(key, _finite(key, value, "spacing"))
        return (step, step)
    if kind == "degrees":
        degrees = _in_range(key, _finite(key, value, "angle"))
        return math.radians(degrees)
    if kind == "char":
        if isinstance(value, bool):
            marker = ""
        elif isinstance(value, (int, float)):
            code = int(value)
            marker = chr(code) if 0 < code < 0x110000 else ""
        else:
            marker = str(value).strip()
        if marker not in _DECIMAL_MARKERS:
            raise ValueError(
                f"{key}: unknown value {value!r}. Use one of {list(_DECIMAL_MARKERS)}."
            )
        return ord(marker)
    if kind == "int":
        return _in_range(key, int(float(value)))
    if kind == "float":
        return _in_range(key, float(value))
    return value


#: AutoCAD's own ranges for the settings that now reach the renderer.
#:
#: Before the DIM* header fold, an out-of-range value died harmlessly in the
#: header. Folding it into the per-dimension override is what gave it teeth:
#: `dim_decimals=-1` reported `ok: True` and then killed the *next* dimension
#: with `ValueError: Format specifier missing precision` raised from inside
#: ezdxf's formatter, nowhere near the call responsible; `dim_decimals=40`
#: printed forty digits of float noise onto the sheet; and a non-positive
#: length was reported applied and silently discarded. `applied` has to mean
#: applied.
_SETTING_RANGES: dict[str, tuple[float, float]] = {
    "dim_decimals": (0, 8),  # DIMDEC
    "zero_suppression": (0, 15),  # DIMZIN is a bitmask
    "linear_precision": (0, 8),  # LUPREC
    "angular_precision": (0, 8),  # AUPREC
    "dim_text_height": (1e-9, 1e6),  # DIMTXT, strictly positive
    "dim_arrow_size": (1e-9, 1e6),  # DIMASZ, strictly positive
    "text_size": (1e-9, 1e6),  # TEXTSIZE
    "dimscale": (0, 1e6),  # 0 means "scale to the viewport" and is legal
    "ltscale": (1e-9, 1e6),
    "point_size": (-100, 1e6),  # negative is a percentage of the screen
    "fillet_radius": (0, 1e6),
    # track E
    "grid_spacing": (1e-9, 1e9),  # GRIDUNIT, strictly positive per axis
    "snap_spacing": (1e-9, 1e9),  # SNAPUNIT, strictly positive per axis
    "polar_angle": (1e-9, 360),  # POLARANG increment, degrees at the facade
}


def _in_range(key: str, value: float) -> float:
    """Refuse a value the renderer cannot use, naming the range."""
    bounds = _SETTING_RANGES.get(key)
    if bounds is None:
        return value
    low, high = bounds
    if not low <= value <= high:
        low_text = "greater than 0" if 0 < low < 1e-6 else f"at least {low:g}"
        raise ValueError(
            f"{key}: {value:g} is out of range - must be {low_text} and at most {high:g}."
        )
    return value


def _decode_setting(key: str, kind: str, raw: Any) -> Any:
    """Present a raw system-variable value in friendly form."""
    if raw is None:
        return None
    if kind == "units":
        try:
            code = int(raw)
        except (TypeError, ValueError):
            return raw
        return {"code": code, "name": _CODE_TO_UNIT.get(code, "unknown")}
    if kind == "lunits":
        try:
            code = int(raw)
        except (TypeError, ValueError):
            return raw
        return {"code": code, "name": _CODE_TO_LUNITS.get(code, "unknown")}
    if kind == "aunits":
        try:
            code = int(raw)
        except (TypeError, ValueError):
            return raw
        return {"code": code, "name": _CODE_TO_AUNITS.get(code, "unknown")}
    if kind == "bool":
        try:
            return bool(int(raw))
        except (TypeError, ValueError):
            return raw
    if kind == "bit":
        try:
            return bool(int(raw) & _BIT_MASKS[key])
        except (TypeError, ValueError):
            return raw
    if kind == "degrees":
        try:
            return round(math.degrees(float(raw)), 6)
        except (TypeError, ValueError):
            return raw
    if kind == "spacing":
        try:
            x, y = _point2(raw)
        except ValueError:
            return raw
        return x if x == y else [x, y]
    if kind == "scale":
        try:
            name, paper, drawing = parse_scale(raw)
        except ValueError:
            return {"name": str(raw), "value": None}
        return {"name": name, "value": paper / drawing}
    if kind == "name":
        return str(raw)
    if kind == "char":
        try:
            code = int(raw)
        except (TypeError, ValueError):
            return raw
        # ezdxf's renderer reads DIMDSEP 0 as a comma rather than as "unset",
        # so report the marker that would actually be drawn.
        return "," if code == 0 else chr(code)
    if kind == "int":
        try:
            return int(raw)
        except (TypeError, ValueError):
            return raw
    if kind == "float":
        try:
            return float(raw)
        except (TypeError, ValueError):
            return raw
    return raw
  • Step 4: Teach the ezdxf backend where each variable really lives

In backends/ezdxf_backend.py, add between the from .base import (...) block and the from .quarantine import (...) block (isort order: .base, .contracts, .quarantine — ruff check enforces it):

from .contracts.settings import parse_scale, scale_value

Add the two constants directly after _MAX_ABANDONED_RECORDS = 10:

#: System variables that AutoCAD does not keep in the HEADER section. Grid and
#: snap belong to the active VPORT table entry (DXF codes 76 / 15 / 75 / 14);
#: measured 2026-09-16: `doc.header["$GRIDMODE"] = 1` raises `DXFKeyError`.
_VPORT_SYSVARS: dict[str, tuple[str, str]] = {
    "GRIDMODE": ("grid_on", "flag"),
    "SNAPMODE": ("snap_on", "flag"),
    "GRIDUNIT": ("grid_spacing", "point"),
    "SNAPUNIT": ("snap_spacing", "point"),
}

#: Registry-saved or never-saved (AutoCAD System Variables reference, "Saved
#: in" column). A file has nowhere to keep them, so the headless engine refuses
#: a write with `capability: registry_sysvar` instead of storing a value that
#: would vanish; `engineering.standards.sysvars` pins this set to its catalogue.
_UNSAVED_SYSVARS: frozenset[str] = frozenset(
    {
        "AUTOSNAP",
        "POLARANG",
        "POLARMODE",
        "ATTREQ",
        "ATTDIA",
        "SAVETIME",
        "FILEDIA",
        "XREFCTL",
        "PROXYSHOW",
        "CMDECHO",
        "HIGHLIGHT",
        "HPNAME",
        "HPSCALE",
        "HPANG",
        "DWGNAME",
        "DWGPREFIX",
    }
)

In capabilities(), add after the "lisp" entry of the features dict (the only shared anchor that exists in this worktree; the merge agent orders that hunk S → C → V by key name — "mleaderstyle", then this and Task 16's "dwgprops", then V's — skeleton, Merge notes):

                "registry_sysvar": FeatureCapability(
                    False, reason="registry_saved_variables_have_no_home_in_a_file"
                ),

Replace system_get_variable and system_set_variable (the two methods between system_status and system_run_command) with the following — shown at class-body indentation, paste as-is inside EzdxfBackend:

    async def system_get_variable(self, name) -> Any:
        def _sync():
            doc = self._require_doc()
            key = str(name).upper()
            if key in _VPORT_SYSVARS:
                attr, shape = _VPORT_SYSVARS[key]
                raw = getattr(self._active_vport(doc).dxf, attr)
                if shape == "point":
                    return (float(raw[0]), float(raw[1]))
                return int(raw)
            if key == "CANNOSCALE":
                return self._dictionary_variable(doc, "CANNOSCALE", "1:1")
            if key == "CANNOSCALEVALUE":
                return scale_value(self._dictionary_variable(doc, "CANNOSCALE", "1:1"))
            return doc.header.get(f"${key}", None)

        return await self._async(_sync)

    async def system_set_variable(self, name, value) -> dict:
        from ezdxf.lldxf.const import DXFKeyError

        def _sync():
            doc = self._require_doc()
            key = str(name).upper()
            if key in _UNSAVED_SYSVARS:
                raise UnsupportedCapabilityError(
                    "registry_sysvar",
                    f"system_set_variable: {key} is saved in the AutoCAD registry (or not "
                    "saved at all), never in the drawing, so the headless ezdxf backend has "
                    "nowhere to keep it. Set it on the live COM backend.",
                )
            if key in _VPORT_SYSVARS:
                attr, shape = _VPORT_SYSVARS[key]
                vport = self._active_vport(doc)
                if shape == "point":
                    x, y = float(value[0]), float(value[1])
                    if x <= 0 or y <= 0:
                        raise ValueError(f"{key}: spacing must be greater than 0, got {value!r}")
                    vport.dxf.set(attr, (x, y))
                else:
                    vport.dxf.set(attr, 1 if int(value) else 0)
            elif key == "CANNOSCALE":
                self._set_annotation_scale(doc, value)
            elif key == "CANNOSCALEVALUE":
                raise ValueError("CANNOSCALEVALUE is read-only; set CANNOSCALE (e.g. '1:50') instead.")
            else:
                try:
                    doc.header[f"${key}"] = value
                except DXFKeyError as exc:
                    # Keeps the pinned "$$DIMTXT" text (tests/test_dimension_header_vars.py)
                    # while saying why the write is refused rather than dropped.
                    raise ValueError(
                        f"system_set_variable: {exc} ezdxf has no header slot for it, so the "
                        "value would be dropped at save; system_variable_describe(name) says "
                        "where the variable lives."
                    ) from exc
                if key in ("LIMMIN", "LIMMAX"):
                    # Measured: `Drawing.update_limits()` copies the model-space
                    # LAYOUT's limits into the header on every write(), so a
                    # header-only value is overwritten at save. The layout is
                    # the source of truth; keep both in step.
                    doc.modelspace().dxf_layout.dxf.set(key.lower(), (float(value[0]), float(value[1])))
            self._mark_dirty()
            return {"ok": True, "variable": name, "value": value}

        return await self._async(_sync)

Add the helper block at the very end of EzdxfBackend — after _create_xline, the file's last method (below the # ── premium meta-tools live on AutoCADBackend … banner). This is group C's reserved anchor: S's styles block sits before # ── corner ops, V's environment block after the layouts block, P's page-setup block before # ── 3D solids — four non-adjacent positions, so no two groups edit the same lines (skeleton, Merge notes). Task 16 appends inside this block. Shown at class-body indentation, paste as-is:

    # ── settings (track E, group C) ───────────────────────────────────────────
    #
    # Where the variables the `drawing_settings` facade reaches actually live
    # in a DXF file when they are not header variables.

    @staticmethod
    def _active_vport(doc):
        """The `*Active` VPORT entry — grid and snap state live on it.

        Owned here (group C). Group V's named views use the same entry and
        carry a worktree-only copy that the merge agent deletes; this
        definition is the one that ships.
        """
        entries = doc.viewports.get("*Active")
        if not entries:
            entries = [doc.viewports.new("*Active")]
        return entries[0]

    @staticmethod
    def _variable_dictionary(doc, create: bool):
        """`AcDbVariableDictionary` under the root dictionary (CANNOSCALE lives there)."""
        vardict = doc.rootdict.get("AcDbVariableDictionary")
        if vardict is None and create:
            vardict = doc.rootdict.add_new_dict("AcDbVariableDictionary")
        return vardict

    def _dictionary_variable(self, doc, key: str, default: str) -> str:
        vardict = self._variable_dictionary(doc, create=False)
        if vardict is None:
            return default
        entry = vardict.get(key)
        return default if entry is None else str(entry.dxf.value)

    @staticmethod
    def _ensure_scale_entry(doc, name: str, paper: float, drawing: float) -> bool:
        """Add `name` to ACAD_SCALELIST unless present; True when added.

        ezdxf has no SCALE entity class, so the object is authored as raw tags
        and loaded through the factory (a `DXFTagStorage` that exports
        verbatim). Measured: it round-trips, ezdxf registers the CLASS, and
        `doc.audit()` is clean after reopen. AutoCAD only honours a CANNOSCALE
        that names an entry of this list.
        """
        from ezdxf.entities import factory
        from ezdxf.lldxf.extendedtags import ExtendedTags

        scales = doc.rootdict.get("ACAD_SCALELIST")
        if scales is None:
            scales = doc.rootdict.add_new_dict("ACAD_SCALELIST")
        if scales.get(name) is not None:
            return False
        handle = doc.entitydb.next_handle()
        unit = 1 if paper == drawing else 0
        text = (
            f"  0\nSCALE\n  5\n{handle}\n330\n{scales.dxf.handle}\n100\nAcDbScale\n"
            f" 70\n0\n300\n{name}\n140\n{paper}\n141\n{drawing}\n290\n{unit}\n"
        )
        entry = factory.load(ExtendedTags.from_text(text), doc)
        doc.entitydb.add(entry)
        doc.objects.add_object(entry)
        scales.add(name, entry)
        return True

    def _set_annotation_scale(self, doc, value: Any) -> str:
        name, paper, drawing = parse_scale(value)  # refused before any write
        self._ensure_scale_entry(doc, name, paper, drawing)
        vardict = self._variable_dictionary(doc, create=True)
        entry = vardict.get("CANNOSCALE")
        if entry is None:
            vardict.add_dict_var("CANNOSCALE", name)
        else:
            entry.dxf.value = name
        return name
  • Step 5: COM — point variables as VARIANT double arrays, and the capability key

In backends/com_backend.py::system_set_variable, extend the coercion chain (after the elif isinstance(current, float): line):

                elif isinstance(current, (tuple, list)):
                    # 2D/3D point variables (LIMMIN, GRIDUNIT, SNAPUNIT, ...) travel
                    # as VARIANT double arrays of the length AutoCAD itself reports;
                    # a bare Python tuple is marshalled as VT_VARIANT and rejected.
                    coords = [float(v) for v in value]
                    coords += [0.0] * (len(current) - len(coords))
                    coerced = _av(coords[: len(current)])

In capabilities(), after the "lisp" entry (same rule as the ezdxf map: merged order S → C → V at that spot):

                "registry_sysvar": FeatureCapability(True, "native"),
  • Step 6: The drawing_settings tool names its new keys and refusals

In server.py, the settings parameter of drawing_settings is declared as Annotated[dict | None, Field(default=None, description=(...))] = None. Replace the whole Field(...) call with the following, verbatim (the Annotated[dict | None, … ] = None wrapper around it is unchanged), and then the docstring:

        Field(
            default=None,
            description=(
                "Omit to READ every setting; pass a dict to CHANGE them. Friendly keys: "
                "units (mm/cm/m/inch/feet), linear_precision, angular_precision, ltscale, "
                "dimscale, dim_text_height, dim_arrow_size, dim_decimals, "
                'decimal_separator ("." or ","), zero_suppression, text_size, point_mode, '
                "point_size, osmode, fillet_radius, limits ([[xmin,ymin],[xmax,ymax]]), "
                "grid (bool), grid_spacing, snap (bool), snap_spacing, ortho (bool), "
                "polar (bool), polar_angle (degrees), psltscale (bool), annotation_scale "
                '("1:50"), linear_units (decimal|engineering|architectural|fractional|'
                "scientific), angular_units (degrees|dms|grads|radians|surveyor), "
                "dimstyle, textstyle (current style names). "
                'Example: {"units": "mm", "limits": [[0, 0], [420, 297]], "grid": true}.'
            ),
        ),
    """Read or change common AutoCAD drawing settings by friendly name.

    A convenience facade over the system variables (INSUNITS, LUPREC, LTSCALE,
    DIMSCALE, DIMTXT, DIMASZ, DIMDEC, DIMDSEP, DIMZIN, TEXTSIZE, OSMODE, LIMMIN/
    LIMMAX, GRIDMODE/GRIDUNIT, SNAPMODE/SNAPUNIT, ORTHOMODE, AUTOSNAP/POLARANG,
    PSLTSCALE, CANNOSCALE, LUNITS, AUNITS, DIMSTYLE, TEXTSTYLE) so the user can
    say "set units to mm, limits to A3 and the grid on" without memorising
    sysvar names. Call with no argument for a full snapshot; a write returns
    `applied`, `changed` ({key: [old, new]} — only what moved) and `errors`.

    `dim_text_height` / `dim_arrow_size` / `dim_decimals` / `decimal_separator`
    / `zero_suppression` shape the *dimension* — `text_size` is TEXTSIZE, the
    height of a standalone TEXT entity, and does not touch dimensions.

    Refusals, per key, nothing else rolled back: an unknown key; a value outside
    its range (grid/snap spacing > 0, polar_angle 0–360, precision 0–8, …); a
    malformed `limits` or `annotation_scale`; `polar` / `polar_angle` on the
    headless engine (`capability: registry_sysvar` — AutoCAD keeps them in the
    registry, a file cannot); `dimstyle` / `textstyle` when the backend has no
    styles contract. On the live engine `annotation_scale` must name a scale in
    the drawing's scale list (SCALELISTEDIT); the headless engine adds it.
    Headlessly, grid/snap are stored on the active VPORT and the annotation
    scale in the variable dictionary — the places AutoCAD reads them from.
    """
  • Step 7: Alias record

In discovery/aliases.py, replace the drawing_settings record with:

    "drawing_settings": ToolAliases(
        acad=(
            "UNITS",
            "DDUNITS",
            "OSNAP",
            "DSETTINGS",
            "LTSCALE",
            "DIMSCALE",
            "LIMITS",
            "GRID",
            "SNAP",
            "ORTHO",
            "PSLTSCALE",
            "CANNOSCALE",
        ),
        synonyms=(
            "drawing units",
            "millimetres or inches",
            "decimal precision",
            "dimension scale",
            "linetype scale",
            "object snap settings",
            "drawing limits",
            "turn the grid on",
            "grid spacing",
            "snap spacing",
            "ortho mode",
            "polar tracking",
            "annotation scale",
            "architectural units",
            "current dimension style",
            "current text style",
        ),
    ),
  • Step 8: Run the tests

Run: uv run --frozen pytest tests/test_settings_facade.py tests/test_drawing_settings.py tests/test_dimension_header_vars.py tests/test_capability_contract.py tests/test_discovery_aliases.py tests/test_com_sprint3.py -v Expected: all PASS (the COM fake tests skip with "pywin32 not installed" on a machine without it; on this Windows box they run). test_the_dollar_prefixed_name_is_still_rejected still matches $$DIMTXT because the new ValueError carries ezdxf's message.

  • Step 9: Format and commit
uv run --frozen ruff format backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py
uv run --frozen ruff check backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py
git add backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_settings_facade.py
git commit -m "feat(settings): drawing_settings gains limits, grid/snap, ortho/polar, psltscale, annotation scale, unit formats and current styles

Headlessly, grid/snap are stored on the active VPORT, CANNOSCALE in
AcDbVariableDictionary (+ an ACAD_SCALELIST entry), and LIMMIN/LIMMAX on
the model-space layout as well as the header — measured: the header alone
is overwritten at save. Registry-saved variables (AUTOSNAP, POLARANG, …)
are refused with capability registry_sysvar instead of vanishing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 15: SYSVAR_CATALOG, system_variable_describe, and a system_set_variable that refuses out-of-range values

Files:

  • Create: engineering/standards/sysvars.py (no __init__.py — group S owns it; the namespace-package import is measured to work)
  • Modify: server.py (new # ── SECTION 20: Environment (1 tool) ── header above the # ── RESOURCES ── banner with system_variable_describe; system_set_variable calls check_sysvar_value first)
  • Modify: discovery/aliases.py (record system_variable_describe)
  • Modify: README.md snapshot line 166 tools → 167 tools; regenerate docs/tool-inventory.json
  • Modify: tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged (system 7 → 8, total 166 → 167)
  • Test: tests/test_sysvar_catalog.py

Interfaces:

  • Consumes: EzdxfBackend._UNSAVED_SYSVARS (Task 14) — the catalogue's saved_in != "drawing" set is pinned equal to it; backends.contracts.settings._SETTING_MAP — every friendly_key is pinned to it.

  • Produces (engineering/standards/sysvars.py):

    • SysVar(name, type, range, enum, default, meaning, saved_in, engines, friendly_key, read_only=False) — type ∈ {"int","float","str","bool","point","enum"}, saved_in ∈ {"drawing","registry","not_saved"}, engines = {"ezdxf": bool, "com": bool}. read_only is one field beyond the skeleton's signature (defaulted, trailing): DIMSTYLE, CANNOSCALEVALUE, UCSNAME, VIEWCTR, VIEWSIZE, DWGNAME, DWGPREFIX are read-only in AutoCAD and the check needs to know.
    • SYSVAR_CATALOG: dict[str, SysVar] — 90 entries (the 33-variable DIM whitelist + every name spec §5 lists + AUTOSNAP and CANNOSCALEVALUE, which the facade needs).
    • describe_sysvar(name) -> dict (known: True + every field, or {"known": False, "name", "nearest": [...]}), search_sysvars(text) -> list[dict], check_sysvar_value(name, value) -> str | None.
  • Produces (server.py): tool system_variable_describe(name=None, search=None) → the row (+ current when a backend can read it), {"query", "matches"} for a search, or the index {"count", "names"} with neither. system_set_variable raises ToolError with the check's message before calling the backend.

  • Pack: settings (the final task registers the name in PACK_TOOL_NAMES["settings"]; until then it is advertised as core, which is everything unclaimed). Lean: no — a lean client has drawing_settings' docstring. Cost read.

  • Step 1: Write the failing tests

# tests/test_sysvar_catalog.py
"""SYSVAR_CATALOG: authored facts about AutoCAD system variables, pinned to the engines.

Every `engines["ezdxf"]` claim is checked against the real headless backend
(read, and write of the default where the variable is writable); every
`saved_in` that is not "drawing" is checked to be exactly the set the backend
refuses with `capability: registry_sysvar`. The catalogue cannot say a thing
the backend does not do.
"""

from __future__ import annotations

import pytest

from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import _SETTING_MAP
from backends.ezdxf_backend import _UNSAVED_SYSVARS
from engineering.standards.sysvars import (
    SAVED_IN,
    SYSVAR_CATALOG,
    SYSVAR_TYPES,
    SysVar,
    check_sysvar_value,
    describe_sysvar,
    search_sysvars,
)

asyncio_test = pytest.mark.asyncio

DIM_WHITELIST = (
    "DIMTXT",
    "DIMASZ",
    "DIMEXE",
    "DIMEXO",
    "DIMGAP",
    "DIMTAD",
    "DIMTIH",
    "DIMTOH",
    "DIMDEC",
    "DIMDSEP",
    "DIMLUNIT",
    "DIMZIN",
    "DIMBLK",
    "DIMTXSTY",
    "DIMLWD",
    "DIMLWE",
    "DIMSCALE",
    "DIMTOL",
    "DIMTP",
    "DIMTM",
    "DIMTOLJ",
    "DIMTFAC",
    "DIMLFAC",
    "DIMRND",
    "DIMATFIT",
    "DIMTMOVE",
    "DIMCLRD",
    "DIMCLRE",
    "DIMCLRT",
    "DIMSAH",
    "DIMBLK1",
    "DIMBLK2",
    "DIMCEN",
)

SPEC_NAMES = (
    "LTSCALE PSLTSCALE INSUNITS LUNITS LUPREC AUNITS AUPREC LIMMIN LIMMAX GRIDMODE GRIDUNIT "
    "SNAPMODE SNAPUNIT ORTHOMODE POLARMODE POLARANG OSMODE CANNOSCALE TEXTSIZE TEXTSTYLE "
    "DIMSTYLE CLAYER CELTYPE CECOLOR CELWEIGHT PDMODE PDSIZE FILLETRAD MIRRTEXT ATTREQ ATTDIA "
    "TILEMODE CTAB DWGNAME DWGPREFIX SAVETIME FILEDIA CMDECHO HIGHLIGHT REGENMODE UCSNAME "
    "VIEWCTR VIEWSIZE ANGBASE ANGDIR PLINEWID HPNAME HPSCALE HPANG LWDISPLAY XREFCTL PROXYSHOW "
    "ISOLINES FACETRES DISPSILH"
).split()

#: The two engines legitimately represent these differently (a character vs.
#: its code; a colour name vs. its ACI number), so their default cannot be
#: written verbatim headlessly. Everything else round-trips its default.
_REPRESENTATION_DIFFERS = {"DIMDSEP", "CECOLOR"}


# ── the data is complete ────────────────────────────────────────────────────


def test_catalogue_is_authored_not_sparse():
    assert len(SYSVAR_CATALOG) >= 60


def test_every_dim_whitelist_variable_and_every_spec_name_is_present():
    missing = [n for n in (*DIM_WHITELIST, *SPEC_NAMES) if n not in SYSVAR_CATALOG]
    assert missing == []


def test_every_entry_is_complete_and_well_typed():
    for name, var in SYSVAR_CATALOG.items():
        assert isinstance(var, SysVar) and var.name == name
        assert var.type in SYSVAR_TYPES, name
        assert var.meaning.strip(), name
        assert var.saved_in in SAVED_IN, name
        assert set(var.engines) == {"ezdxf", "com"}, name
        assert var.friendly_key is None or var.friendly_key in _SETTING_MAP, name
        if var.type == "enum":
            assert var.enum and all(isinstance(k, int) for k in var.enum), name
            assert var.default in var.enum, name
        else:
            assert var.enum is None, name
        if var.range is not None:
            low, high = var.range
            assert low < high, name


def test_friendly_keys_cover_the_whole_facade():
    covered = {var.friendly_key for var in SYSVAR_CATALOG.values() if var.friendly_key}
    assert covered == set(_SETTING_MAP)
    for key, (variable, _kind) in _SETTING_MAP.items():
        assert SYSVAR_CATALOG[variable].friendly_key == key, (key, variable)


def test_non_drawing_variables_are_exactly_the_ones_the_headless_engine_refuses():
    unsaved = {n for n, v in SYSVAR_CATALOG.items() if v.saved_in != "drawing"}
    assert unsaved == set(_UNSAVED_SYSVARS)
    for name in unsaved:
        assert SYSVAR_CATALOG[name].engines["ezdxf"] is False, name


# ── describe / search / check ───────────────────────────────────────────────


def test_describe_is_case_insensitive_and_complete():
    row = describe_sysvar("ltscale")
    assert row["known"] is True and row["name"] == "LTSCALE"
    assert row["type"] == "float" and row["range"][0] > 0
    assert row["saved_in"] == "drawing"
    assert row["engines"] == {"ezdxf": True, "com": True}
    assert row["friendly_key"] == "ltscale"
    assert describe_sysvar("$LIMMAX")["default"] == [420.0, 297.0]


def test_describe_unknown_names_the_nearest_without_raising():
    row = describe_sysvar("LTSCAL")
    assert row["known"] is False and row["name"] == "LTSCAL"
    assert "LTSCALE" in row["nearest"]
    assert describe_sysvar("")["known"] is False


def test_search_matches_names_and_meanings():
    names = [row["name"] for row in search_sysvars("scale")]
    for expected in ("LTSCALE", "DIMSCALE", "CANNOSCALE", "PSLTSCALE", "HPSCALE"):
        assert expected in names
    assert [row["name"] for row in search_sysvars("decimal separator")] == ["DIMDSEP"]
    assert search_sysvars("   ") == []


@pytest.mark.parametrize(
    "name, value, fragment",
    [
        ("DIMDEC", 9, "0..8"),
        ("dimdec", -1, "0..8"),
        ("DIMDEC", 2.5, "integer"),
        ("DIMTXT", "big", "number"),
        ("DIMTXT", 0, "out of range"),
        ("LUNITS", 7, "architectural"),
        ("ORTHOMODE", 2, "0 or 1"),
        ("LIMMAX", 5, "[x, y]"),
        ("DIMSTYLE", "ANSI", "read-only"),
        ("CANNOSCALEVALUE", 0.5, "read-only"),
        ("POLARANG", 0, "out of range"),
    ],
)
def test_check_refuses_with_a_message_naming_the_variable(name, value, fragment):
    message = check_sysvar_value(name, value)
    assert message is not None
    assert name.upper() in message and fragment in message


@pytest.mark.parametrize(
    "name, value",
    [
        ("DIMDEC", 3),
        ("DIMTXT", "3.5"),
        ("LUNITS", 4),
        ("ORTHOMODE", "on"),
        ("LIMMAX", [297, 210]),
        ("DIMDSEP", ","),
        ("NOSUCHVARIABLE", 12345),
    ],
)
def test_check_passes_good_values_and_unknown_names(name, value):
    assert check_sysvar_value(name, value) is None


# ── the engine claims are true ──────────────────────────────────────────────


@asyncio_test
async def test_every_ezdxf_true_entry_reads_on_a_fresh_document(backend):
    unreadable = []
    for name, var in SYSVAR_CATALOG.items():
        if var.engines["ezdxf"] and await backend.system_get_variable(name) is None:
            unreadable.append(name)
    assert unreadable == []


@asyncio_test
async def test_every_writable_ezdxf_true_entry_round_trips_its_default(backend):
    for name, var in SYSVAR_CATALOG.items():
        if not var.engines["ezdxf"] or var.read_only or name in _REPRESENTATION_DIFFERS:
            continue
        await backend.system_set_variable(name, var.default)
        back = await backend.system_get_variable(name)
        if var.type == "point":
            assert tuple(float(v) for v in back[:2]) == tuple(float(v) for v in var.default), name
        elif var.type in ("bool", "int", "enum"):
            assert int(back) == int(var.default), name
        elif var.type == "float":
            assert float(back) == pytest.approx(float(var.default)), name
        else:
            assert back == var.default, name


@asyncio_test
async def test_every_ezdxf_false_entry_is_refused_before_any_write(backend):
    for name, var in SYSVAR_CATALOG.items():
        if var.engines["ezdxf"]:
            continue
        if var.saved_in == "drawing":
            with pytest.raises(ValueError, match=name):  # no header slot in ezdxf
                await backend.system_set_variable(name, var.default)
        else:
            with pytest.raises(UnsupportedCapabilityError) as excinfo:
                await backend.system_set_variable(name, var.default)
            assert excinfo.value.capability == "registry_sysvar", name


# ── server tools ────────────────────────────────────────────────────────────


class _FakeCtx:
    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, *a, **k):
        pass


@asyncio_test
async def test_set_variable_tool_refuses_out_of_range_before_the_backend_sees_it(backend):
    from fastmcp.exceptions import ToolError

    import server

    ctx = _FakeCtx(backend)
    with pytest.raises(ToolError, match="DIMDEC"):
        await server.system_set_variable(name="DIMDEC", value=99, ctx=ctx)
    assert await backend.system_get_variable("DIMDEC") == 2
    with pytest.raises(ToolError, match="read-only"):
        await server.system_set_variable(name="DIMSTYLE", value="ANSI", ctx=ctx)
    # a name the catalogue does not know still passes straight through
    out = await server.system_set_variable(name="MEASUREMENT", value=0, ctx=ctx)
    assert out["ok"] is True
    assert await backend.system_get_variable("MEASUREMENT") == 0


@asyncio_test
async def test_set_variable_tool_refuses_before_a_live_backend_is_called(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from fastmcp.exceptions import ToolError

    import server
    from backends import com_backend as module

    calls: list[tuple] = []

    class _App:
        def GetVariable(self, name):
            calls.append(("GetVariable", name))
            return 2

        def SetVariable(self, name, value):
            calls.append(("SetVariable", name, value))

    monkeypatch.setattr(module, "_acad_app", lambda: _App())
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    with pytest.raises(ToolError, match="DIMDEC"):
        await server.system_set_variable(name="DIMDEC", value=40, ctx=_FakeCtx(backend))
    assert calls == []


@asyncio_test
async def test_describe_tool_returns_the_row_with_the_current_value(backend):
    import server

    ctx = _FakeCtx(backend)
    row = await server.system_variable_describe(name="dimdec", ctx=ctx)
    assert row["known"] is True and row["name"] == "DIMDEC" and row["current"] == 2
    unknown = await server.system_variable_describe(name="DIMDEK", ctx=ctx)
    assert unknown["known"] is False and "DIMDEC" in unknown["nearest"]
    found = await server.system_variable_describe(search="decimal separator", ctx=ctx)
    assert [row["name"] for row in found["matches"]] == ["DIMDSEP"]
    index = await server.system_variable_describe(ctx=ctx)
    assert index["count"] == len(SYSVAR_CATALOG) and "LTSCALE" in index["names"]


@asyncio_test
async def test_describe_tool_works_without_a_backend():
    import server

    class _NoBackendCtx:
        lifespan_context = {"backend": None}

    row = await server.system_variable_describe(name="LTSCALE", ctx=_NoBackendCtx())
    assert row["known"] is True and "current" not in row
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_sysvar_catalog.py -v Expected: collection ERROR — ModuleNotFoundError: No module named 'engineering.standards'.

  • Step 3: Write the catalogue
# engineering/standards/sysvars.py
"""AutoCAD system-variable catalogue: what each one is, where it lives, who honours it.

Authored data, pinned by ``tests/test_sysvar_catalog.py``. Every entry says
its type, range or enum, AutoCAD's default, a one-line meaning, where AutoCAD
saves it (``drawing`` / ``registry`` / ``not_saved``), whether each engine can
read *and write* it (``engines``), and the ``drawing_settings`` key that wraps
it when one exists (``friendly_key``).

``engines["ezdxf"]`` is a measured claim, not a hope: it is ``True`` only when
the headless backend has a real home for the value in the DXF file - a header
variable ezdxf will export, the active VPORT (grid/snap), the variable
dictionary (CANNOSCALE) or the model-space layout (limits). Registry-saved
and never-saved variables are ``False`` because a file has nowhere to keep
them, and ``EzdxfBackend.system_set_variable`` refuses them with
``capability: registry_sysvar``. A handful of drawing-saved variables are also
``False`` because ezdxf 1.4 has no header slot for them (CTAB, VIEWCTR,
VIEWSIZE, ISOLINES, FACETRES); that refusal is a plain ``ValueError``.

Sources: the AutoCAD 2026 System Variables reference (type / saved-in /
initial value columns), the DXF Reference HEADER and VPORT sections, and
``ezdxf.sections.headervars.HEADER_VAR_MAP`` for what the headless engine
exports.
"""

from __future__ import annotations

import difflib
import math
from dataclasses import asdict, dataclass, field
from typing import Any

__all__ = [
    "SAVED_IN",
    "SYSVAR_CATALOG",
    "SYSVAR_TYPES",
    "SysVar",
    "check_sysvar_value",
    "describe_sysvar",
    "search_sysvars",
]

SYSVAR_TYPES = ("int", "float", "str", "bool", "point", "enum")
SAVED_IN = ("drawing", "registry", "not_saved")


@dataclass(frozen=True)
class SysVar:
    name: str
    type: str
    range: tuple[float, float] | None
    enum: dict[int, str] | None
    default: Any
    meaning: str
    saved_in: str
    engines: dict[str, bool] = field(default_factory=lambda: {"ezdxf": True, "com": True})
    friendly_key: str | None = None
    read_only: bool = False


_BOTH = {"ezdxf": True, "com": True}
_COM_ONLY = {"ezdxf": False, "com": True}


def _v(
    name: str,
    type_: str,
    meaning: str,
    *,
    range_: tuple[float, float] | None = None,
    enum: dict[int, str] | None = None,
    default: Any = None,
    saved_in: str = "drawing",
    engines: dict[str, bool] | None = None,
    friendly_key: str | None = None,
    read_only: bool = False,
) -> SysVar:
    if engines is None:
        engines = _BOTH if saved_in == "drawing" else _COM_ONLY
    return SysVar(
        name=name,
        type=type_,
        range=range_,
        enum=enum,
        default=default,
        meaning=meaning,
        saved_in=saved_in,
        engines=dict(engines),
        friendly_key=friendly_key,
        read_only=read_only,
    )


_POSITIVE = (1e-9, 1e9)
_NON_NEGATIVE = (0.0, 1e9)
_ACI = (0, 256)
_LINEWEIGHT = (-3, 211)

_ENTRIES: tuple[SysVar, ...] = (
    # ── dimension variables (the dimstyle whitelist) ────────────────────────
    _v(
        "DIMTXT",
        "float",
        "dimension text height",
        range_=_POSITIVE,
        default=2.5,
        friendly_key="dim_text_height",
    ),
    _v(
        "DIMASZ",
        "float",
        "arrowhead size",
        range_=_POSITIVE,
        default=2.5,
        friendly_key="dim_arrow_size",
    ),
    _v(
        "DIMEXE",
        "float",
        "extension line extension beyond the dimension line",
        range_=_POSITIVE,
        default=1.25,
    ),
    _v(
        "DIMEXO",
        "float",
        "extension line offset from the origin points",
        range_=_NON_NEGATIVE,
        default=0.625,
    ),
    _v(
        "DIMGAP",
        "float",
        "gap between dimension line and text; a negative value draws a box around the text",
        default=0.625,
    ),
    _v(
        "DIMTAD",
        "enum",
        "vertical text placement relative to the dimension line",
        enum={0: "centered", 1: "above", 2: "outside", 3: "JIS", 4: "below"},
        default=1,
    ),
    _v(
        "DIMTIH",
        "bool",
        "text inside the extension lines is horizontal (1) or aligned with the line (0)",
        default=0,
    ),
    _v(
        "DIMTOH",
        "bool",
        "text outside the extension lines is horizontal (1) or aligned with the line (0)",
        default=0,
    ),
    _v(
        "DIMDEC",
        "int",
        "decimal places of the primary dimension value",
        range_=(0, 8),
        default=2,
        friendly_key="dim_decimals",
    ),
    _v(
        "DIMDSEP",
        "str",
        "decimal separator character for decimal dimensions (the headless engine stores its character code: 44 is ',')",
        default=",",
        friendly_key="decimal_separator",
    ),
    _v(
        "DIMLUNIT",
        "enum",
        "linear unit format for dimensions",
        enum={
            1: "scientific",
            2: "decimal",
            3: "engineering",
            4: "architectural",
            5: "fractional",
            6: "windows desktop",
        },
        default=2,
    ),
    _v(
        "DIMZIN",
        "int",
        "zero suppression bitmask: 1 feet, 2 inches, 4 leading, 8 trailing",
        range_=(0, 15),
        default=8,
        friendly_key="zero_suppression",
    ),
    _v("DIMBLK", "str", "arrowhead block name; empty means closed filled", default=""),
    _v("DIMTXSTY", "str", "text style used by dimension text", default="Standard"),
    _v(
        "DIMLWD",
        "int",
        "dimension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)",
        range_=_LINEWEIGHT,
        default=-2,
    ),
    _v(
        "DIMLWE",
        "int",
        "extension line lineweight code (-3 default, -2 ByBlock, -1 ByLayer, else hundredths of mm)",
        range_=_LINEWEIGHT,
        default=-2,
    ),
    _v(
        "DIMSCALE",
        "float",
        "overall scale factor applied to dimension sizes; 0 scales to the viewport",
        range_=(0.0, 1e6),
        default=1.0,
        friendly_key="dimscale",
    ),
    _v("DIMTOL", "bool", "append tolerances to dimension text", default=0),
    _v("DIMTP", "float", "upper tolerance limit", default=0.0),
    _v("DIMTM", "float", "lower tolerance limit", default=0.0),
    _v(
        "DIMTOLJ",
        "enum",
        "vertical justification of tolerance text",
        enum={0: "bottom", 1: "middle", 2: "top"},
        default=1,
    ),
    _v(
        "DIMTFAC",
        "float",
        "tolerance text height as a factor of DIMTXT",
        range_=_POSITIVE,
        default=1.0,
    ),
    _v(
        "DIMLFAC",
        "float",
        "linear measurement scale factor; negative applies in paper space only",
        default=1.0,
    ),
    _v(
        "DIMRND",
        "float",
        "rounding increment for linear dimensions; 0 rounds nothing",
        range_=_NON_NEGATIVE,
        default=0.0,
    ),
    _v(
        "DIMATFIT",
        "enum",
        "what moves outside when text and arrows do not fit",
        enum={0: "text and arrows", 1: "arrows first", 2: "text first", 3: "best fit"},
        default=3,
    ),
    _v(
        "DIMTMOVE",
        "enum",
        "how the dimension line behaves when text is moved",
        enum={0: "move line with text", 1: "add a leader", 2: "text moves freely"},
        default=0,
    ),
    _v(
        "DIMCLRD",
        "int",
        "dimension line colour (ACI: 0 ByBlock, 256 ByLayer)",
        range_=_ACI,
        default=0,
    ),
    _v(
        "DIMCLRE",
        "int",
        "extension line colour (ACI: 0 ByBlock, 256 ByLayer)",
        range_=_ACI,
        default=0,
    ),
    _v(
        "DIMCLRT",
        "int",
        "dimension text colour (ACI: 0 ByBlock, 256 ByLayer)",
        range_=_ACI,
        default=0,
    ),
    _v("DIMSAH", "bool", "use DIMBLK1 / DIMBLK2 as separate arrowheads", default=0),
    _v("DIMBLK1", "str", "first arrowhead block when DIMSAH is on", default=""),
    _v("DIMBLK2", "str", "second arrowhead block when DIMSAH is on", default=""),
    _v(
        "DIMCEN",
        "float",
        "centre mark size for radial dimensions; 0 none, negative draws centre lines",
        default=2.5,
    ),
    # ── scales, units, precision ─────────────────────────────────────────────
    _v(
        "LTSCALE",
        "float",
        "global linetype scale factor",
        range_=_POSITIVE,
        default=1.0,
        friendly_key="ltscale",
    ),
    _v(
        "PSLTSCALE",
        "bool",
        "scale linetypes by the viewport scale in paper space",
        default=1,
        friendly_key="psltscale",
    ),
    _v(
        "INSUNITS",
        "enum",
        "drawing units for inserted content and scaling",
        enum={
            0: "unitless",
            1: "inches",
            2: "feet",
            3: "miles",
            4: "millimeters",
            5: "centimeters",
            6: "meters",
            7: "kilometers",
            8: "microinches",
            9: "mils",
            10: "yards",
            11: "angstroms",
            12: "nanometers",
            13: "microns",
            14: "decimeters",
            15: "decameters",
            16: "hectometers",
            17: "gigameters",
            18: "astronomical units",
            19: "light years",
            20: "parsecs",
        },
        default=4,
        friendly_key="units",
    ),
    _v(
        "LUNITS",
        "enum",
        "linear unit display format",
        enum={1: "scientific", 2: "decimal", 3: "engineering", 4: "architectural", 5: "fractional"},
        default=2,
        friendly_key="linear_units",
    ),
    _v(
        "LUPREC",
        "int",
        "linear unit display precision (decimal places)",
        range_=(0, 8),
        default=4,
        friendly_key="linear_precision",
    ),
    _v(
        "AUNITS",
        "enum",
        "angular unit display format",
        enum={
            0: "decimal degrees",
            1: "degrees/minutes/seconds",
            2: "grads",
            3: "radians",
            4: "surveyor",
        },
        default=0,
        friendly_key="angular_units",
    ),
    _v(
        "AUPREC",
        "int",
        "angular unit display precision (decimal places)",
        range_=(0, 8),
        default=0,
        friendly_key="angular_precision",
    ),
    _v(
        "CANNOSCALE",
        "str",
        "current annotation scale name, e.g. '1:50' (a DICTIONARYVAR in AcDbVariableDictionary, not a header variable)",
        default="1:1",
        friendly_key="annotation_scale",
    ),
    _v(
        "CANNOSCALEVALUE",
        "float",
        "current annotation scale as a number (paper / drawing), derived from CANNOSCALE",
        default=1.0,
        read_only=True,
    ),
    # ── limits, grid, snap, tracking ─────────────────────────────────────────
    _v(
        "LIMMIN",
        "point",
        "lower-left corner of the drawing limits (LIMITS)",
        default=(0.0, 0.0),
        friendly_key="limits",
    ),
    _v(
        "LIMMAX",
        "point",
        "upper-right corner of the drawing limits (LIMITS)",
        default=(420.0, 297.0),
        friendly_key="limits",
    ),
    _v(
        "GRIDMODE",
        "bool",
        "grid display on/off (stored on the active VPORT, not in the header)",
        default=0,
        friendly_key="grid",
    ),
    _v(
        "GRIDUNIT",
        "point",
        "grid spacing [x, y] (stored on the active VPORT)",
        default=(10.0, 10.0),
        friendly_key="grid_spacing",
    ),
    _v(
        "SNAPMODE",
        "bool",
        "snap on/off (stored on the active VPORT)",
        default=0,
        friendly_key="snap",
    ),
    _v(
        "SNAPUNIT",
        "point",
        "snap spacing [x, y] (stored on the active VPORT)",
        default=(10.0, 10.0),
        friendly_key="snap_spacing",
    ),
    _v(
        "ORTHOMODE",
        "bool",
        "ortho mode: cursor movement constrained to horizontal/vertical",
        default=0,
        friendly_key="ortho",
    ),
    _v(
        "POLARMODE",
        "int",
        "polar tracking bitmask: 1 relative angles, 2 polar in osnap tracking, 4 additional angles, 8 tooltips (polar on/off itself is AUTOSNAP bit 8)",
        range_=(0, 15),
        default=0,
        saved_in="registry",
    ),
    _v(
        "POLARANG",
        "float",
        "polar angle increment in radians (90 degrees = 1.5708)",
        range_=(1e-9, 2 * math.pi),
        default=math.pi / 2,
        saved_in="registry",
        friendly_key="polar_angle",
    ),
    _v(
        "AUTOSNAP",
        "int",
        "AutoSnap bitmask: 1 marker, 2 magnet, 4 tooltip, 8 polar tracking on, 16 object snap tracking, 32 tracking tooltips, 64 dynamic input aperture",
        range_=(0, 127),
        default=63,
        saved_in="registry",
        friendly_key="polar",
    ),
    _v(
        "OSMODE",
        "int",
        "running object snap bitmask (1 end, 2 mid, 4 cen, 8 nod, 16 qua, 32 int, 64 ins, 128 per, 256 tan, 512 nea, 1024 quick, 2048 app, 4096 ext, 8192 par, 16384 off); headlessly held in memory only, DXF R2000+ does not carry it",
        range_=(0, 32767),
        default=4133,
        friendly_key="osmode",
    ),
    # ── current properties ───────────────────────────────────────────────────
    _v(
        "TEXTSIZE",
        "float",
        "default height for new TEXT entities (not dimension text)",
        range_=_POSITIVE,
        default=2.5,
        friendly_key="text_size",
    ),
    _v("TEXTSTYLE", "str", "current text style name", default="Standard", friendly_key="textstyle"),
    _v(
        "DIMSTYLE",
        "str",
        "current dimension style name (read-only: change it with dimstyle_set_current or drawing_settings {'dimstyle': ...})",
        default="ISO-25",
        friendly_key="dimstyle",
        read_only=True,
    ),
    _v("CLAYER", "str", "current layer name", default="0"),
    _v(
        "CELTYPE",
        "str",
        "current entity linetype (ByLayer, ByBlock or a loaded linetype)",
        default="ByLayer",
    ),
    _v(
        "CECOLOR",
        "str",
        "current entity colour ('BYLAYER', 'BYBLOCK' or an ACI number; the headless engine stores the ACI code, 256 is ByLayer)",
        default="BYLAYER",
    ),
    _v(
        "CELWEIGHT",
        "int",
        "current entity lineweight code (-1 ByLayer, -2 ByBlock, -3 default, else hundredths of mm)",
        range_=_LINEWEIGHT,
        default=-1,
    ),
    _v(
        "PDMODE",
        "int",
        "point display mode (0-4, or those plus 32 / 64 / 96)",
        range_=(0, 100),
        default=0,
        friendly_key="point_mode",
    ),
    _v(
        "PDSIZE",
        "float",
        "point display size; 0 is 5 percent of the view, negative is a percentage of the viewport",
        default=0.0,
        friendly_key="point_size",
    ),
    _v(
        "FILLETRAD",
        "float",
        "current fillet radius",
        range_=_NON_NEGATIVE,
        default=0.0,
        friendly_key="fillet_radius",
    ),
    _v("MIRRTEXT", "bool", "mirror text with MIRROR (1) or keep it readable (0)", default=0),
    _v("ATTREQ", "bool", "prompt for attribute values on INSERT", default=1, saved_in="registry"),
    _v(
        "ATTDIA",
        "bool",
        "use a dialog for attribute values on INSERT",
        default=0,
        saved_in="registry",
    ),
    _v("PLINEWID", "float", "default width for new polylines", range_=_NON_NEGATIVE, default=0.0),
    _v(
        "ANGBASE",
        "float",
        "direction of angle 0 relative to the current UCS, in radians",
        default=0.0,
    ),
    _v("ANGDIR", "bool", "positive angle direction: 0 counter-clockwise, 1 clockwise", default=0),
    # ── layouts, views, UCS ──────────────────────────────────────────────────
    _v("TILEMODE", "bool", "1 model tab active, 0 a layout tab active", default=1),
    _v(
        "CTAB",
        "str",
        "name of the current tab (Model or a layout); headlessly use layout_list / layout_set_current",
        default="Model",
        engines=_COM_ONLY,
    ),
    _v(
        "UCSNAME",
        "str",
        "name of the current UCS; empty when unnamed or world",
        default="",
        read_only=True,
    ),
    _v(
        "VIEWCTR",
        "point",
        "centre of the current view (read-only; VPORT centre in a file)",
        default=(0.0, 0.0),
        engines=_COM_ONLY,
        read_only=True,
    ),
    _v(
        "VIEWSIZE",
        "float",
        "height of the current view in drawing units (read-only)",
        default=100.0,
        engines=_COM_ONLY,
        read_only=True,
    ),
    _v("REGENMODE", "bool", "automatic regeneration on", default=1),
    _v("LWDISPLAY", "bool", "display lineweights on screen", default=0),
    # ── file, session ────────────────────────────────────────────────────────
    _v(
        "DWGNAME",
        "str",
        "name of the current drawing file (read-only)",
        default="Drawing1.dwg",
        saved_in="not_saved",
        read_only=True,
    ),
    _v(
        "DWGPREFIX",
        "str",
        "folder of the current drawing file (read-only)",
        default="",
        saved_in="not_saved",
        read_only=True,
    ),
    _v(
        "SAVETIME",
        "int",
        "automatic save interval in minutes; 0 disables",
        range_=(0, 600),
        default=10,
        saved_in="registry",
    ),
    _v(
        "FILEDIA",
        "bool",
        "show file dialogs (1) or take file names from the command line (0)",
        default=1,
        saved_in="registry",
    ),
    _v(
        "CMDECHO",
        "bool",
        "echo prompts and input during AutoLISP command execution",
        default=1,
        saved_in="not_saved",
    ),
    _v("HIGHLIGHT", "bool", "highlight selected objects", default=1, saved_in="not_saved"),
    _v(
        "XREFCTL",
        "bool",
        "write external reference log (.xlg) files",
        default=0,
        saved_in="registry",
    ),
    _v(
        "PROXYSHOW",
        "enum",
        "how proxy objects are displayed",
        enum={0: "not shown", 1: "shown", 2: "bounding box"},
        default=1,
        saved_in="registry",
    ),
    # ── hatch ────────────────────────────────────────────────────────────────
    _v(
        "HPNAME",
        "str",
        "default hatch pattern name for HATCH",
        default="ANSI31",
        saved_in="not_saved",
    ),
    _v(
        "HPSCALE",
        "float",
        "default hatch pattern scale",
        range_=_POSITIVE,
        default=1.0,
        saved_in="not_saved",
    ),
    _v(
        "HPANG",
        "float",
        "default hatch pattern angle in degrees",
        default=0.0,
        saved_in="not_saved",
    ),
    # ── 3D display ───────────────────────────────────────────────────────────
    _v(
        "ISOLINES",
        "int",
        "contour lines per surface on 3D solids",
        range_=(0, 2047),
        default=4,
        engines=_COM_ONLY,
    ),
    _v(
        "FACETRES",
        "float",
        "smoothness of shaded and rendered curved solids",
        range_=(0.01, 10.0),
        default=0.5,
        engines=_COM_ONLY,
    ),
    _v("DISPSILH", "bool", "display silhouette edges of 3D solids in wireframe", default=0),
)

SYSVAR_CATALOG: dict[str, SysVar] = {entry.name: entry for entry in _ENTRIES}


def _as_dict(var: SysVar) -> dict:
    out = asdict(var)
    out["known"] = True
    out["range"] = list(var.range) if var.range is not None else None
    out["default"] = list(var.default) if isinstance(var.default, tuple) else var.default
    return out


def describe_sysvar(name: str) -> dict:
    """The catalogue row for ``name`` (case-insensitive), or ``known: False`` plus the nearest names."""
    key = str(name).strip().upper().lstrip("$")
    var = SYSVAR_CATALOG.get(key)
    if var is not None:
        return _as_dict(var)
    names = sorted(SYSVAR_CATALOG)
    nearest = difflib.get_close_matches(key, names, n=5, cutoff=0.6)
    for candidate in names:
        if len(nearest) >= 5:
            break
        if key and candidate.startswith(key[:3]) and candidate not in nearest:
            nearest.append(candidate)
    return {"known": False, "name": key, "nearest": nearest}


def search_sysvars(text: str) -> list[dict]:
    """Catalogue rows whose name, meaning or friendly key contains every word of ``text``."""
    words = [word for word in str(text).lower().split() if word]
    if not words:
        return []
    hits = []
    for var in SYSVAR_CATALOG.values():
        haystack = " ".join((var.name, var.meaning, var.friendly_key or "")).lower()
        if all(word in haystack for word in words):
            hits.append(_as_dict(var))
    return sorted(hits, key=lambda row: row["name"])


_TRUTHY = {"0", "1", "on", "off", "true", "false"}


def check_sysvar_value(name: str, value: Any) -> str | None:
    """A refusal message when ``value`` cannot be ``name``'s value; ``None`` when fine or unknown.

    Soft by design: a variable the catalogue has not heard of is passed
    through untouched, so ``system_set_variable`` keeps working for the
    hundreds of variables nobody has authored a row for.
    """
    var = SYSVAR_CATALOG.get(str(name).strip().upper().lstrip("$"))
    if var is None:
        return None
    if var.read_only:
        return f"{var.name} is read-only: {var.meaning}"
    if var.type in ("int", "float"):
        if isinstance(value, bool) or not isinstance(value, (int, float, str)):
            return f"{var.name}: expected a number, got {value!r}"
        try:
            number = float(value)
        except ValueError:
            return f"{var.name}: expected a number, got {value!r}"
        if not math.isfinite(number):
            return f"{var.name}: must be a finite number"
        if var.type == "int" and number != int(number):
            return f"{var.name}: expected an integer, got {value!r}"
        if var.range is not None:
            low, high = var.range
            if not low <= number <= high:
                return f"{var.name}: {number:g} is out of range {low:g}..{high:g} - {var.meaning}"
        return None
    if var.type == "bool":
        if value in (0, 1) or str(value).strip().lower() in _TRUTHY:
            return None
        return f"{var.name}: expected 0 or 1 (on/off), got {value!r}"
    if var.type == "enum":
        try:
            code = int(value)
        except (TypeError, ValueError):
            return f"{var.name}: expected one of {sorted(var.enum)}, got {value!r}"
        if code not in var.enum:
            choices = ", ".join(f"{k}={v}" for k, v in var.enum.items())
            return f"{var.name}: {code} is not a valid code ({choices})"
        return None
    if var.type == "point":
        try:
            float(value[0]), float(value[1])
        except (TypeError, IndexError, ValueError):
            return f"{var.name}: expected an [x, y] point, got {value!r}"
        return None
    return None

(ruff format will re-wrap the long _v(...) lines; E501 is ignored in this project, so either shape passes the gate.)

  • Step 4: Run the pure and engine tests

Run: uv run --frozen pytest tests/test_sysvar_catalog.py -v -k "not tool" Expected: all PASS — the server-tool tests are deselected by -k until Step 5 registers the tool; the engine-truth tests prove the catalogue against the Task 14 backend.

  • Step 5: Register the tool, the check, the alias, the header, inventory, README, snapshot

In server.py, replace system_set_variable's docstring and body:

async def system_set_variable(
    name: Annotated[str, "System variable name"],
    value: Annotated[Any, "New variable value"],
    ctx: Context = None,
) -> dict:
    """Set an AutoCAD system variable (e.g. DIMSCALE, LTSCALE, MEASUREMENT).

    Refusals, before anything is written: a value outside the catalogued
    range or enum of a known variable, or a write to a read-only variable
    (DIMSTYLE, CANNOSCALEVALUE, DWGNAME, …), is refused with the catalogue's
    message — `system_variable_describe(name)` shows the range. A variable the
    catalogue does not know is passed through unchanged. The headless engine
    additionally refuses registry-saved variables (`capability:
    registry_sysvar`) because a file has nowhere to keep them, and names that
    ezdxf has no header slot for (a `ValueError` naming the variable).
    """
    from engineering.standards.sysvars import check_sysvar_value

    message = check_sysvar_value(name, value)
    if message:
        raise ToolError(f"system_set_variable refused: {message}")
    return await _backend(ctx).system_set_variable(name, value)

Insert directly above the # ── RESOURCES ── banner (after pid_tag_parse):

# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (1 tool) ────────────────────────────────────────
# ---------------------------------------------------------------------------
# Group C (settings) opens this section with `system_variable_describe` and,
# in Task 16, the two `drawing_properties_*` tools; group V's merge extends the
# header count to the full environment surface (documents, layer states,
# named views, UCS, launch, preferences, interactive prompts).


@cad_tool(
    summary="Explain a system variable: type, range, default, where it is saved, which engine honours it.",
    cost="read",
)
@mcp.tool(
    annotations={"title": "Describe System Variable", "readOnlyHint": True},
    tags={"system", "settings"},
)
async def system_variable_describe(
    name: Annotated[
        str | None, "System variable name, e.g. LTSCALE (case-insensitive, '$' optional)"
    ] = None,
    search: Annotated[
        str | None, "Free text matched against names and meanings, e.g. 'decimal separator'"
    ] = None,
    ctx: Context = None,
) -> dict:
    """What an AutoCAD system variable means, from an authored catalogue of 90.

    With `name`: `{known, name, type, range|enum, default, meaning, saved_in
    (drawing|registry|not_saved), engines: {ezdxf, com}, friendly_key,
    read_only}` plus `current` when the active backend can read it (omitted,
    with `current_unavailable`, when it cannot — no document open, or a
    registry variable headlessly). An unknown name is never an error: `known:
    false` and the nearest catalogue names come back. With `search`: every row
    whose name or meaning contains all the words. With neither: the index of
    names. `friendly_key` names the `drawing_settings` key that wraps the
    variable, which is the preferred way to set it; `engines.ezdxf: false`
    means the headless engine refuses a write (registry-saved, or no header
    slot in ezdxf).
    """
    from engineering.standards.sysvars import SYSVAR_CATALOG, describe_sysvar, search_sysvars

    if name:
        row = describe_sysvar(name)
        backend = ctx.lifespan_context.get("backend") if ctx is not None else None
        if row["known"] and backend is not None:
            try:
                row["current"] = await backend.system_get_variable(row["name"])
            except Exception as exc:  # no document, or the engine cannot hold it
                row["current_unavailable"] = str(exc)
        return row
    if search:
        return {"query": search, "matches": search_sysvars(search)}
    return {
        "count": len(SYSVAR_CATALOG),
        "names": sorted(SYSVAR_CATALOG),
        "hint": "pass name=<VARIABLE> for one row or search=<words> to filter",
    }

In discovery/aliases.py, add next to system_set_variable (alphabetical within the system block):

    "system_variable_describe": ToolAliases(
        acad=("SETVAR", "SYSVARMONITOR", "SYSVDLG"),
        synonyms=(
            "sysvar",
            "what does ltscale do",
            "explain a system variable",
            "system variable reference",
            "which variables are saved in the drawing",
            "valid range of a system variable",
            "list system variables",
        ),
    ),

(SETVAR is already in SHARED_ACAD_COMMANDS; SYSVAR is not an AutoCAD command, so it lives in synonyms, which the search index weights identically.)

In tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged: append to the docstring `system` moved 7 -> 8 when v1.6's `system_variable_describe` (the sysvar catalogue as a read-only tool) opened SECTION 20. and change "system": 7, → "system": 8, and assert sum(sizes.values()) == 166 → == 167.

Then: uv run --frozen python scripts/generate_tool_inventory.py, and change the README line 30 snapshot to 167 tools.

  • Step 6: Run the gates

Run: uv run --frozen pytest tests/test_sysvar_catalog.py tests/test_settings_facade.py tests/test_drawing_settings.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v Expected: all PASS. (If test_document_numbers_match_the_tree fails only on the README collected tests figure, that drift predates this track — the final task settles it.)

  • Step 7: Format and commit
uv run --frozen ruff format engineering/standards/sysvars.py server.py discovery/aliases.py tests/test_sysvar_catalog.py tests/test_tool_registry.py
uv run --frozen ruff check engineering/standards/sysvars.py server.py discovery/aliases.py tests/test_sysvar_catalog.py tests/test_tool_registry.py
git add engineering/standards/sysvars.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_sysvar_catalog.py tests/test_tool_registry.py
git commit -m "feat(settings): SYSVAR_CATALOG + system_variable_describe; system_set_variable refuses out-of-range values

90 authored rows (type, range/enum, default, meaning, saved-in, per-engine
support, friendly key); every ezdxf claim is checked against the real
backend and the registry-saved set is pinned to what the backend refuses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 16: drawing_properties_get / drawing_properties_set — SummaryInfo on COM, custom properties on both engines

Files:

  • Modify: backends/contracts/settings.py (append SUMMARY_FIELDS and validate_drawing_properties — shared validation, refused before either engine writes)
  • Modify: backends/ezdxf_backend.py (two methods in the # ── settings (track E, group C) ── block; dwgprops in capabilities(); import grows)
  • Modify: backends/com_backend.py (two methods in a # ── settings (track E, group C) ── block at the very end of the class, after _create_xline — C's reserved anchor; _SUMMARY_ATTRS; dwgprops in capabilities(); import)
  • Modify: server.py (SECTION 20 header (1 tool) → (3 tools); two tools after system_variable_describe)
  • Modify: discovery/aliases.py (records drawing_properties_get, drawing_properties_set; DWGPROPS joins SHARED_ACAD_COMMANDS)
  • Modify: README.md snapshot 167 tools → 169 tools; regenerate docs/tool-inventory.json
  • Modify: tests/test_tool_registry.py (drawing 11 → 13, total 167 → 169)
  • Test: tests/test_drawing_properties.py

Interfaces:

  • Consumes: doc.header.custom_vars (ezdxf CustomVars: append / replace / remove(tag, all=True) / has_tag / iteration as (tag, value)); COM Document.SummaryInfo (Title, Subject, Author, Keywords, Comments, NumCustomInfo(), GetCustomByIndex(i) -> (key, value), AddCustomInfo(key, value), SetCustomByKey(key, value), RemoveCustomByKey(key); pywin32 returns [out] parameters as a tuple, the convention the backend already relies on for GetBoundingBox / GetXData).
  • Produces (verbatim from the skeleton; group V declares the same two as @abstractmethod in EnvironmentContract, C only implements — whichever merges first, both backends define them):
async def drawing_properties_get(self) -> dict: ...
# → {"summary": {"title","subject","author","keywords","comments"} (None each headless), "summary_available": bool, "custom": {k: v}}
async def drawing_properties_set(
    self, summary: dict | None = None, custom: dict | None = None
) -> dict: ...


# ezdxf: summary with any non-None field → raise UnsupportedCapabilityError("dwgprops"); custom → header custom_vars (replace keys given; value None deletes)
# → {"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}

Both payloads also carry "backend". validate_drawing_properties(summary, custom) -> (summary_to_write, custom_to_write, custom_to_delete): unknown summary field → ValueError; non-string summary value, non-string custom key, non-string/non-null custom value → TypeError naming the key; nothing is written on either engine when it raises.

  • Tools: drawing_properties_get() (cost read), drawing_properties_set(title=None, subject=None, author=None, keywords=None, comments=None, custom=None) (cost safe), both tags={"drawing", "settings"}. Capability key dwgprops in both maps (ezdxf False — summary fields; COM True).

  • Pack: settings (registered by the final task). Lean: no — document metadata is not a drafting essential.

  • Step 1: Write the failing tests

# tests/test_drawing_properties.py
"""drawing_properties_get / set: DWG summary and custom properties on both engines.

Headlessly the five summary fields live in the DWG SummaryInfo stream, which
ezdxf cannot write, so any non-null summary field is refused with
`capability: dwgprops` before anything is touched; custom properties are
`$CUSTOMPROPERTYTAG` / `$CUSTOMPROPERTY` header pairs and work on both
engines (measured: they survive save + reopen).
"""

from __future__ import annotations

import types

import pytest

from backends.base import UnsupportedCapabilityError
from backends.contracts.settings import SUMMARY_FIELDS, validate_drawing_properties

pytestmark = pytest.mark.asyncio

EMPTY_SUMMARY = {field: None for field in SUMMARY_FIELDS}


# ── shared validation ───────────────────────────────────────────────────────


def test_validation_splits_writes_and_deletes():
    written, to_write, to_delete = validate_drawing_properties(
        {"title": "Gearbox", "author": None}, {"PROJECT": "X-1", "OLD": None}
    )
    assert written == {"title": "Gearbox"}
    assert to_write == {"PROJECT": "X-1"} and to_delete == ["OLD"]
    assert validate_drawing_properties(None, None) == ({}, {}, [])


@pytest.mark.parametrize(
    "summary, custom, exc, fragment",
    [
        ({"edition": "1"}, None, ValueError, "edition"),
        ({"title": 3}, None, TypeError, "summary.title"),
        (None, {"N": 3}, TypeError, "custom['N']"),
        (None, {"": "x"}, TypeError, "non-empty"),
        ("Gearbox", None, TypeError, "summary"),
        (None, ["PROJECT"], TypeError, "custom"),
    ],
)
def test_validation_refuses_by_name(summary, custom, exc, fragment):
    with pytest.raises(exc) as excinfo:
        validate_drawing_properties(summary, custom)
    assert fragment in str(excinfo.value)


# ── headless engine ─────────────────────────────────────────────────────────


async def test_get_reports_no_summary_and_empty_custom(backend):
    assert await backend.drawing_properties_get() == {
        "summary": EMPTY_SUMMARY,
        "summary_available": False,
        "custom": {},
        "backend": "ezdxf",
    }


async def test_custom_properties_write_replace_delete_and_survive_reopen(backend, tmp_path):
    first = await backend.drawing_properties_set(custom={"PROJECT": "X-1", "REV": "B"})
    assert first == {
        "ok": True,
        "summary_written": [],
        "custom_written": ["PROJECT", "REV"],
        "custom_deleted": [],
        "backend": "ezdxf",
    }
    second = await backend.drawing_properties_set(
        custom={"REV": None, "NOPE": None, "PROJECT": "X-2"}
    )
    assert second["custom_written"] == ["PROJECT"]
    assert second["custom_deleted"] == ["REV"], "a key that was never there is not 'deleted'"
    assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}
    assert [t for t, _ in backend._doc.header.custom_vars] == ["PROJECT"], "no duplicate tags"

    path = str(tmp_path / "props.dxf")
    await backend.drawing_save_as(path)
    await backend.drawing_open(path)
    assert (await backend.drawing_properties_get())["custom"] == {"PROJECT": "X-2"}


async def test_summary_is_refused_headlessly_before_any_write(backend):
    with pytest.raises(UnsupportedCapabilityError) as excinfo:
        await backend.drawing_properties_set(summary={"title": "T"}, custom={"LATE": "no"})
    assert excinfo.value.capability == "dwgprops"
    assert "title" in str(excinfo.value)
    assert (await backend.drawing_properties_get())["custom"] == {}


async def test_all_null_summary_is_not_a_summary_write(backend):
    res = await backend.drawing_properties_set(summary={"title": None, "author": None})
    assert res["ok"] is True and res["summary_written"] == []


async def test_malformed_custom_is_refused_by_name_before_any_write(backend):
    with pytest.raises(TypeError, match=r"custom\['N'\]"):
        await backend.drawing_properties_set(custom={"OK": "1", "N": 3})
    assert (await backend.drawing_properties_get())["custom"] == {}


def test_both_capability_maps_declare_dwgprops():
    from backends.com_backend import ComBackend
    from backends.ezdxf_backend import EzdxfBackend

    assert EzdxfBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is False
    assert ComBackend().capabilities().to_dict()["features"]["dwgprops"]["supported"] is True


# ── server tools ────────────────────────────────────────────────────────────


class _FakeCtx:
    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, *a, **k):
        pass


async def test_server_tools_round_trip_custom_and_refuse_summary(backend):
    import server

    ctx = _FakeCtx(backend)
    res = await server.drawing_properties_set(custom={"PROJECT": "X-9"}, ctx=ctx)
    assert res["custom_written"] == ["PROJECT"]
    got = await server.drawing_properties_get(ctx=ctx)
    assert got["custom"] == {"PROJECT": "X-9"} and got["summary_available"] is False
    with pytest.raises(UnsupportedCapabilityError):
        await server.drawing_properties_set(title="Gearbox", ctx=ctx)


# ── live engine, against a fake SummaryInfo ─────────────────────────────────


class _FakeSummaryInfo:
    def __init__(self):
        self.Title = "Old title"
        self.Subject = ""
        self.Author = ""
        self.Keywords = "gear"
        self.Comments = ""
        self._custom: list[tuple[str, str]] = [("PROJECT", "X-1"), ("OLD", "1")]
        self.calls: list[tuple] = []

    def NumCustomInfo(self):
        self.calls.append(("NumCustomInfo",))
        return len(self._custom)

    def GetCustomByIndex(self, index):
        self.calls.append(("GetCustomByIndex", index))
        return self._custom[index]  # pywin32 hands [out] BSTR pairs back as a tuple

    def AddCustomInfo(self, key, value):
        self.calls.append(("AddCustomInfo", key, value))
        self._custom.append((key, value))

    def SetCustomByKey(self, key, value):
        self.calls.append(("SetCustomByKey", key, value))
        self._custom = [(k, value if k == key else v) for k, v in self._custom]

    def RemoveCustomByKey(self, key):
        self.calls.append(("RemoveCustomByKey", key))
        self._custom = [(k, v) for k, v in self._custom if k != key]


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    info = _FakeSummaryInfo()
    document = types.SimpleNamespace(SummaryInfo=info)
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, info


async def test_com_get_reads_summary_and_custom(com_backend):
    backend, info = com_backend
    assert await backend.drawing_properties_get() == {
        "summary": {
            "title": "Old title",
            "subject": "",
            "author": "",
            "keywords": "gear",
            "comments": "",
        },
        "summary_available": True,
        "custom": {"PROJECT": "X-1", "OLD": "1"},
        "backend": "com",
    }


async def test_com_set_writes_summary_and_routes_custom_by_existence(com_backend):
    backend, info = com_backend
    res = await backend.drawing_properties_set(
        summary={"title": "Gearbox", "author": "U. Can", "subject": None},
        custom={"PROJECT": "X-2", "REV": "B", "OLD": None, "NOPE": None},
    )
    assert res == {
        "ok": True,
        "summary_written": ["author", "title"],
        "custom_written": ["PROJECT", "REV"],
        "custom_deleted": ["OLD"],
        "backend": "com",
    }
    assert info.Title == "Gearbox" and info.Author == "U. Can" and info.Keywords == "gear"
    mutations = [c for c in info.calls if c[0] not in ("NumCustomInfo", "GetCustomByIndex")]
    assert mutations == [
        ("SetCustomByKey", "PROJECT", "X-2"),  # existed → set
        ("AddCustomInfo", "REV", "B"),  # new → add
        ("RemoveCustomByKey", "OLD"),  # existed → remove; NOPE never touched
    ]


async def test_com_validates_before_touching_activex(com_backend):
    backend, info = com_backend
    with pytest.raises(TypeError):
        await backend.drawing_properties_set(summary={"title": "T"}, custom={"N": 3})
    with pytest.raises(ValueError):
        await backend.drawing_properties_set(summary={"edition": "1"})
    assert info.calls == [] and info.Title == "Old title"
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_drawing_properties.py -v Expected: collection ERROR — ImportError: cannot import name 'SUMMARY_FIELDS' from 'backends.contracts.settings'.

  • Step 3: Shared validation in backends/contracts/settings.py

Append at the end of the module:

# ---------------------------------------------------------------------------
# Document properties (DWGPROPS) — validation shared by both engines
# ---------------------------------------------------------------------------

#: The SummaryInfo fields AutoCAD's DWGPROPS dialog shows on its Summary tab.
SUMMARY_FIELDS = ("title", "subject", "author", "keywords", "comments")


def validate_drawing_properties(
    summary: dict | None, custom: dict | None
) -> tuple[dict[str, str], dict[str, str], list[str]]:
    """→ (summary fields to write, custom keys to write, custom keys to delete).

    Refuses the whole request by name before either engine writes anything:
    an unknown summary field is a ``ValueError``; a non-string summary value,
    a non-string or empty custom key, or a custom value that is neither a
    string nor ``None`` (``None`` deletes) is a ``TypeError``. Values are never
    coerced with ``str()`` — a number the caller meant as text is theirs to
    format.
    """
    if summary is not None and not isinstance(summary, dict):
        raise TypeError("summary: must be an object of {field: text}")
    written: dict[str, str] = {}
    for field, value in (summary or {}).items():
        if field not in SUMMARY_FIELDS:
            raise ValueError(f"summary: unknown field {field!r} (valid: {list(SUMMARY_FIELDS)})")
        if value is None:
            continue
        if not isinstance(value, str):
            raise TypeError(f"summary.{field}: must be a string, got {type(value).__name__}")
        written[field] = value
    if custom is not None and not isinstance(custom, dict):
        raise TypeError("custom: must be an object of {key: text | null}")
    to_write: dict[str, str] = {}
    to_delete: list[str] = []
    for key, value in (custom or {}).items():
        if not isinstance(key, str) or not key.strip():
            raise TypeError(f"custom: key {key!r} must be a non-empty string")
        if value is None:
            to_delete.append(key)
        elif isinstance(value, str):
            to_write[key] = value
        else:
            raise TypeError(
                f"custom[{key!r}]: must be a string or null (null deletes the key), "
                f"got {type(value).__name__}"
            )
    return written, to_write, to_delete
  • Step 4: Implement on ezdxf

Change the Task 14 import line to:

from .contracts.settings import (
    SUMMARY_FIELDS,
    parse_scale,
    scale_value,
    validate_drawing_properties,
)

In capabilities(), after "registry_sysvar" (group V's Task 21 writes this exact entry in its worktree too, so the merge agent keeps one copy — this one):

                "dwgprops": FeatureCapability(
                    False,
                    reason="summary_fields_need_live_autocad;custom_properties_supported",
                ),

Append to the # ── settings (track E, group C) ── block at the end of EzdxfBackend (after _set_annotation_scale; class-body indentation, paste as-is):

    async def drawing_properties_get(self) -> dict:
        def _sync():
            doc = self._require_doc()
            return {
                "summary": {field: None for field in SUMMARY_FIELDS},
                "summary_available": False,
                "custom": {tag: value for tag, value in doc.header.custom_vars},
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def drawing_properties_set(
        self, summary: dict | None = None, custom: dict | None = None
    ) -> dict:
        written, to_write, to_delete = validate_drawing_properties(summary, custom)
        if written:
            # The five summary fields live in the DWG SummaryInfo stream, which
            # a DXF has no slot for; refuse before the custom keys are touched.
            raise UnsupportedCapabilityError(
                "dwgprops",
                "drawing_properties_set: the summary fields (title, subject, author, keywords, "
                "comments) live in the DWG SummaryInfo, which the headless ezdxf backend cannot "
                f"write (asked for {sorted(written)}). Custom properties work on both engines; "
                "set the summary on the live COM backend.",
            )

        def _sync():
            doc = self._require_doc()
            custom_vars = doc.header.custom_vars
            for key, value in to_write.items():
                if custom_vars.has_tag(key):
                    custom_vars.replace(key, value)
                else:
                    custom_vars.append(key, value)
            deleted = []
            for key in to_delete:
                if custom_vars.has_tag(key):
                    custom_vars.remove(key, all=True)
                    deleted.append(key)
            if to_write or deleted:
                self._mark_dirty()
            return {
                "ok": True,
                "summary_written": [],
                "custom_written": sorted(to_write),
                "custom_deleted": deleted,
                "backend": "ezdxf",
            }

        return await self._async(_sync)
  • Step 5: Implement on COM

In backends/com_backend.py, directly after the from .base import (...) block (the relative-import group; isort order .base then .contracts):

from .contracts.settings import SUMMARY_FIELDS, validate_drawing_properties

Below _BUILTIN_LINETYPES = {...}:

#: `drawing_properties_*` field → `IAcadSummaryInfo` property.
_SUMMARY_ATTRS = {field: field.capitalize() for field in SUMMARY_FIELDS}

In capabilities(), after "registry_sysvar":

                "dwgprops": FeatureCapability(True, "native"),

At the very end of ComBackend — after _create_xline, the file's last method (group C's reserved anchor, skeleton Merge notes; class-body indentation, paste as-is):

    # ── settings (track E, group C) ───────────────────────────────────────────

    async def drawing_properties_get(self) -> dict:
        def _sync():
            info = _acad_doc().SummaryInfo
            summary = {field: str(getattr(info, attr) or "") for field, attr in _SUMMARY_ATTRS.items()}
            custom: dict[str, str] = {}
            for index in range(int(info.NumCustomInfo())):
                key, value = info.GetCustomByIndex(index)
                custom[str(key)] = str(value)
            return {
                "summary": summary,
                "summary_available": True,
                "custom": custom,
                "backend": "com",
            }

        return await self._run(_sync)

    async def drawing_properties_set(
        self, summary: dict | None = None, custom: dict | None = None
    ) -> dict:
        # Validated before any ActiveX call: a bad field or value raises here
        # and SummaryInfo is never touched.
        written, to_write, to_delete = validate_drawing_properties(summary, custom)

        def _sync():
            info = _acad_doc().SummaryInfo
            existing: set[str] = set()
            for index in range(int(info.NumCustomInfo())):
                key, _value = info.GetCustomByIndex(index)
                existing.add(str(key))
            for field, value in written.items():
                setattr(info, _SUMMARY_ATTRS[field], value)
            for key, value in to_write.items():
                # AddCustomInfo on an existing key raises in AutoCAD; SetCustomByKey
                # on a missing one does too — route by what is really there.
                if key in existing:
                    info.SetCustomByKey(key, value)
                else:
                    info.AddCustomInfo(key, value)
            deleted = []
            for key in to_delete:
                if key in existing:
                    info.RemoveCustomByKey(key)
                    deleted.append(key)
            return {
                "ok": True,
                "summary_written": sorted(written),
                "custom_written": sorted(to_write),
                "custom_deleted": deleted,
                "backend": "com",
            }

        return await self._run(_sync)
  • Step 6: Run the backend tests

Run: uv run --frozen pytest tests/test_drawing_properties.py tests/test_capability_contract.py -v -k "not server" Expected: all PASS (test_capability_contract proves both backends still instantiate with the new methods, whether or not group V's abstract declarations have merged).

  • Step 7: Register the tools, aliases, header, inventory, README, snapshot

In server.py, change the SECTION 20 header to # ── SECTION 20: Environment (3 tools) ── (pad the dashes so the line keeps its width) and add after system_variable_describe:

@cad_tool(
    summary="Read the drawing's title, subject, author, keywords, comments and custom properties.",
    cost="read",
)
@mcp.tool(
    annotations={"title": "Drawing Properties (read)", "readOnlyHint": True},
    tags={"drawing", "settings"},
)
async def drawing_properties_get(ctx: Context = None) -> dict:
    """The DWGPROPS dialog as data: `summary` (title, subject, author, keywords,
    comments), `summary_available`, and `custom` ({key: value}).

    On the live engine the summary comes from the document's SummaryInfo. The
    headless engine cannot read that stream, so the five summary fields are
    `null` with `summary_available: false` — not empty strings, which would
    claim the drawing has no title. Custom properties (`$CUSTOMPROPERTYTAG` /
    `$CUSTOMPROPERTY` header pairs) are read on both engines.
    """
    return await _backend(ctx).drawing_properties_get()


@cad_tool(
    summary="Set the drawing's summary fields and add, change or delete custom properties.",
    cost="safe",
)
@mcp.tool(
    annotations={"title": "Drawing Properties (write)", "readOnlyHint": False},
    tags={"drawing", "settings"},
)
async def drawing_properties_set(
    title: Annotated[str | None, "SummaryInfo Title"] = None,
    subject: Annotated[str | None, "SummaryInfo Subject"] = None,
    author: Annotated[str | None, "SummaryInfo Author"] = None,
    keywords: Annotated[str | None, "SummaryInfo Keywords"] = None,
    comments: Annotated[str | None, "SummaryInfo Comments"] = None,
    custom: Annotated[
        dict | None,
        "Custom properties to write: {key: text}; a null value deletes the key. "
        "Keys not mentioned are left alone.",
    ] = None,
    ctx: Context = None,
) -> dict:
    """Write DWGPROPS fields. Only the arguments given are touched; returns
    `summary_written`, `custom_written` and `custom_deleted` (a key that was
    never there is not reported deleted).

    Refusals, before anything is written: a summary field on the headless
    engine (`capability: dwgprops` — SummaryInfo lives in the DWG, a DXF has
    no slot for it; custom properties still work headlessly), a non-string
    value (`TypeError` naming the field or key — nothing is coerced with
    `str()`), an empty custom key. On the live engine a custom key is added
    with AddCustomInfo when new and changed with SetCustomByKey when present.
    """
    summary = {
        "title": title,
        "subject": subject,
        "author": author,
        "keywords": keywords,
        "comments": comments,
    }
    touched = [k for k, v in summary.items() if v is not None] + sorted(custom or {})
    await ctx.info(f"Setting drawing properties: {', '.join(touched) or 'nothing'}")
    return await _backend(ctx).drawing_properties_set(summary, custom)

In discovery/aliases.py: add "DWGPROPS" to SHARED_ACAD_COMMANDS with this comment line in the block above it:

#   DWGPROPS -- one dialog, three tabs: General (drawing_info), Summary and
#       Custom (drawing_properties_get / drawing_properties_set).

and add the two records next to drawing_open (alphabetical: after drawing_plan, before drawing_preflight):

    "drawing_properties_get": ToolAliases(
        acad=("DWGPROPS",),
        synonyms=(
            "drawing properties",
            "document properties",
            "summary info",
            "title subject author keywords",
            "custom properties",
            "who made this drawing",
        ),
    ),
    "drawing_properties_set": ToolAliases(
        acad=("DWGPROPS",),
        synonyms=(
            "set the drawing title",
            "set author and keywords",
            "add a custom property",
            "project number in the file properties",
            "edit summary info",
            "delete a custom property",
        ),
    ),

In tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged: append to the docstring `drawing` moved 11 -> 13 when v1.6's `drawing_properties_get` / `drawing_properties_set` (DWGPROPS on both engines) joined SECTION 20 — tagged `drawing` and `settings`, and `settings` is not a group tag, so they file under drawing. and change "drawing": 11, → "drawing": 13, and == 167 → == 169.

Then: uv run --frozen python scripts/generate_tool_inventory.py, and change the README line 30 snapshot to 169 tools.

  • Step 8: Run the gates

Run: uv run --frozen pytest tests/test_drawing_properties.py tests/test_sysvar_catalog.py tests/test_settings_facade.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v Expected: all PASS (same collected tests caveat as Task 15). Then the whole suite once: uv run --frozen pytest -x -q — green.

  • Step 9: Format and commit
uv run --frozen ruff format backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_drawing_properties.py tests/test_tool_registry.py
uv run --frozen ruff check backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py tests/test_drawing_properties.py tests/test_tool_registry.py
git add backends/contracts/settings.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py docs/tool-inventory.json README.md tests/test_drawing_properties.py tests/test_tool_registry.py
git commit -m "feat(settings): drawing_properties_get/set — SummaryInfo on COM, custom properties on both engines

Summary fields are refused headlessly with capability dwgprops before any
write; custom properties are \$CUSTOMPROPERTYTAG/\$CUSTOMPROPERTY pairs and
survive save + reopen. Validation is shared, by name, and never str()-coerces.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Group C hand-off to the merge agent: SECTION 20 header says (3 tools); the registry snapshot says system: 8, drawing: 13, total 169; README snapshot 169; PACK_TOOL_NAMES["settings"] still needs system_variable_describe, drawing_properties_get, drawing_properties_set (final task); README lines 31/38/116 still quote 166 in prose (final docs task). Capability keys added: registry_sysvar, dwgprops (both maps).

v1.6 Track E — Group P: page setup, plotting, templates (Tasks 17–20)

Group P · worktree track-e/pagesetup · files owned: engineering/standards/{papers,pdfinfo,templates,plot}.py, backends/contracts/pagesetup.py, the # ── page setup (track E) ── blocks in both backends, templates/, scripts/build_templates.py, server.py SECTION 19 + drawing_new template resolution, tests/test_page_setup.py, tests/test_templates.py, tests/test_batch_plot.py. The plan skeleton's Global Constraints, File Structure, Interfaces and Execution sections apply verbatim; every command runs through uv run --frozen.

Measured before writing (ezdxf 1.4.4, AutoCAD 2026 typelib on this machine, 2026-09-16):

  • Paperspace.page_setup() ends with reset_viewports() — it deletes every viewport on the sheet — and forces use_standard_scale(False). The implementation below writes the same DXF attributes page_setup() writes, calls reset_paper_limits(), and never touches the viewports.
  • A fresh layout has no stored plot_layout_flags; the attribute default is 688 (= 512 draw-viewports-first + 128 plot lineweights + 32 plot with plot styles + 16 use standard scale). DXFEntity.set_flag_state starts from dxf.get(name, 0), so layout.plot_centered(True) on a fresh layout writes 4 and silently drops the other three bits. The default must be materialised first (page_setup() does exactly this with dxf.get_default). After that, plot_centered(True) → 692, plot_centered(False) → 688. The centre-plot bit is 4.
  • paper_size is written as f"{name}_({w:.2f}_x_{h:.2f}_{MM|Inches})", so name="ISO_A3" produces AutoCAD's canonical ISO_A3_(420.00_x_297.00_MM) verbatim.
  • DXF code 75 (standard_scale_type) and ActiveX AcPlotScale disagree: DXF has 16=1:1, 17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 and no 1:5; ActiveX has ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27, ac2_1=28, ac10_1=31, acScaleToFit=0. Neither has 5:1. Scales without a code go through the custom numerator/denominator (SetCustomScale on COM).
  • The headless drawing_export_pdf renders into a matplotlib figure that MatplotlibBackend(adjust_figure=True) resizes to the data aspect: today's /MediaBox is [0 0 691.2 345.6] whatever the sheet is. Sizing the figure from paper_width/paper_height with adjust_figure=False and clamping the axes to the paper frame gives /MediaBox [0 0 1190.5511811024 841.8897637795] = 420.000 × 297.000 mm on A3, 432 × 279 on ANSI B, 210 × 297 on portrait A4 (measured through _new_agg_figure).
  • Two builds of the same DXF differ on exactly six lines: the values of $TDCREATE, $TDUPDATE, $FINGERPRINTGUID, $VERSIONGUID, and the two EZDXF_META DICTIONARYVAR stamps (1.4.4 @ 2026-09-16T12:33:38.192529+00:00). ezdxf writes CRLF; the repository's .gitattributes has * text=auto.
  • AcSaveAsType in the AutoCAD 2026 typelib (%LOCALAPPDATA%/Temp/gen_py/3.11/0EA72311-DF28-4318-A51B-92D7FC7DF50Bx0x1x0.py) has no acTemplateDwg. The template members are ac2000_Template=14 … ac2013_Template=62, ac2018_Template=66; acNative=64, ac2018_dxf=65, ac2013_dxf=61. Task 20 uses 66. The existing ComBackend.drawing_save_as maps "dwt" to 5, which is acR13_dxf — a DWT request there writes an R13 DXF under a .dwt name; Task 20 fixes that map in passing.
  • IAcadPlotConfiguration (what a Layout is) exposes ConfigName, CanonicalMediaName, StyleSheet, PlotType, StandardScale, UseStandardScale, PlotRotation, PaperUnits, CenterPlot, PlotWithPlotStyles, RefreshPlotDeviceInfo(), GetCanonicalMediaNames(), GetPaperSize(), GetPaperMargins(), GetCustomScale(), SetCustomScale(), GetPlotStyleTableNames(). AcPlotType: acDisplay=0, acExtents=1, acLimits=2, acView=3, acWindow=4, acLayout=5; AcPlotPaperUnits: acInches=0, acMillimeters=1.
  • $CANNOSCALE / $CANNOSCALEVALUE are not header variables ezdxf accepts (Invalid header variable); the annotation scale lives in the ACAD_SCALELIST dictionary. The template build asks the settings facade for annotation_scale (group C's key) and records a pending note when the facade does not know it yet.
  • Header variables the build script's fallback uses were all accepted by system_set_variable headlessly: DIMLWD/DIMLWE=-2, DIMZIN=8, DIMBLK="", DIMTAD, DIMLUNIT, DIMEXE/DIMEXO/DIMGAP, DIMTIH/DIMTOH, PSLTSCALE, LUNITS, AUNITS, DIMSTYLE; the facade accepted units/ltscale/dimscale/dim_text_height/dim_arrow_size/dim_decimals/decimal_separator/zero_suppression/linear_precision/angular_precision.

Task 17: Paper / ctb / scale data, PDF MediaBox reader, PageSetupContract on both engines

Files:

  • Create: engineering/standards/papers.py, engineering/standards/pdfinfo.py, backends/contracts/pagesetup.py (no engineering/standards/__init__.py — group S's Task 9 owns that file; the namespace-package import works without it, as group C relies on too)
  • Modify: backends/contracts/__init__.py (import + __all__), backends/base.py (compose PageSetupContract — the idempotent one-line edit the skeleton allows a group's first task)
  • Modify: backends/ezdxf_backend.py — new block # ── page setup (track E) ── after the change-space block (immediately before # ── 3D solids — group P's reserved anchor, see the skeleton's Merge notes), the dwt_write capability line (after "paper_space"), and one delimited edit inside drawing_export_pdf (figure sized from the layout's paper)
  • Modify: backends/com_backend.py — new block # ── page setup (track E) ── immediately before # ── 3D solids (after the viewports block — the end of the layouts block is group V's anchor), the dwt_write capability line (after "paper_space")
  • Test: tests/test_page_setup.py

Interfaces:

  • Produces (engineering/standards/papers.py): PAPER_SIZES, PAPER_INCHES, CTB_CATALOG, SCALES, DXF_STANDARD_SCALE_TYPE, ACTIVEX_PLOT_SCALE, PLOT_AREAS, PLOT_TYPE_NAMES, paper_size_mm(paper, orientation) -> tuple[float, float], canonical_media_name(paper, orientation) -> str, paper_from_size(width_mm, height_mm, tolerance=0.5) -> str | None, scale_ratio(label) -> tuple[float, float], scale_label(numerator, denominator) -> str, dxf_scale_label(code, numerator, denominator) -> str, activex_scale_label(code) -> str, plot_style_known(name) -> bool, REQUIRED_SETUP_KEYS, require_page_setup(setup) -> dict (the backend-side completeness check), resolve_page_setup(paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True) -> dict.

  • Produces (engineering/standards/pdfinfo.py): read_mediabox(path) -> tuple[float, float] (mm; raises ValueError).

  • Produces (backends/contracts/pagesetup.py): PageSetupContract with page_setup_list, page_setup_apply, plot_style_list, drawing_template_save — signatures verbatim from the skeleton. drawing_template_save is implemented in Task 20; this task declares it (abstract) and both backends get a stub that raises UnsupportedCapabilityError("dwt_write", …) so the ABC instantiates and the capability gate already sees the key.

  • Consumes: EzdxfBackend._find_layout, _layout_name, _require_doc, _mark_dirty, _async; ComBackend._find_layout(doc, raw), _layout_names(doc), _acad_doc, _run; UnsupportedCapabilityError.

  • Step 1: Write the failing tests

# tests/test_page_setup.py
"""Page setup on both engines; the PDF's own /MediaBox is the evidence.

ezdxf: apply a paper, export the layout, parse the PDF back and compare to the
paper size. COM: a fake ActiveX layout records the property sequence.
"""

from __future__ import annotations

import re
import types
import zlib

import pytest

from engineering.standards.papers import (
    CTB_CATALOG,
    PAPER_SIZES,
    SCALES,
    canonical_media_name,
    paper_from_size,
    resolve_page_setup,
    scale_label,
    scale_ratio,
)
from engineering.standards.pdfinfo import read_mediabox

pytestmark = pytest.mark.asyncio

SHEET = "Sheet"


# ── pure data ───────────────────────────────────────────────────────────────


def test_paper_sizes_are_iso_216_and_ansi_y14_1():
    assert PAPER_SIZES == {
        "ISO_A0": (841, 1189),
        "ISO_A1": (594, 841),
        "ISO_A2": (420, 594),
        "ISO_A3": (297, 420),
        "ISO_A4": (210, 297),
        "ANSI_A": (216, 279),
        "ANSI_B": (279, 432),
        "ANSI_C": (432, 559),
        "ANSI_D": (559, 864),
        "ANSI_E": (864, 1118),
    }
    assert CTB_CATALOG[0] == "monochrome.ctb" and "acad.ctb" in CTB_CATALOG
    assert SCALES["fit"] is None and SCALES["1:50"] == 0.02 and SCALES["10:1"] == 10.0


def test_canonical_media_name_uses_autocad_spelling():
    assert canonical_media_name("ISO_A3", "landscape") == "ISO_A3_(420.00_x_297.00_MM)"
    assert canonical_media_name("ISO_A3", "portrait") == "ISO_A3_(297.00_x_420.00_MM)"
    assert canonical_media_name("ANSI_B", "landscape") == "ANSI_B_(17.00_x_11.00_Inches)"


def test_paper_from_size_matches_either_orientation_within_half_a_millimetre():
    assert paper_from_size(420, 297) == "ISO_A3"
    assert paper_from_size(297, 420) == "ISO_A3"
    assert paper_from_size(431.8, 279.4) == "ANSI_B"
    assert paper_from_size(400, 300) is None


def test_scale_ratio_and_label_round_trip():
    assert scale_ratio("1:50") == (1.0, 50.0)
    assert scale_ratio("2:1") == (2.0, 1.0)
    assert scale_label(1.0, 50.0) == "1:50"
    assert scale_label(1.0, 3.0) == "1:3"
    with pytest.raises(ValueError, match="scale"):
        scale_ratio("fit")


def test_resolve_page_setup_defaults():
    setup = resolve_page_setup("ISO_A3")
    assert setup["paper"] == "ISO_A3"
    assert setup["orientation"] == "landscape"
    assert setup["size_mm"] == [420.0, 297.0]
    assert setup["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
    assert setup["plot_style"] == "monochrome.ctb" and setup["plot_style_known"] is True
    assert setup["scale"] == "fit"
    assert setup["dxf_standard_scale_type"] == 0 and setup["activex_standard_scale"] == 0
    assert setup["scale_ratio"] == [1.0, 1.0]
    assert setup["plot_area"] == "layout" and setup["plot_type"] == 5
    assert setup["device"] == "DWG To PDF.pc3"
    assert setup["margins_mm"] is None and setup["center"] is True


def test_resolve_page_setup_accepts_lowercase_and_short_paper_names():
    assert resolve_page_setup("iso_a4", "portrait")["size_mm"] == [210.0, 297.0]
    assert resolve_page_setup("A3")["paper"] == "ISO_A3"
    assert resolve_page_setup("ansi_b")["canonical_media_name"] == "ANSI_B_(17.00_x_11.00_Inches)"


def test_resolve_page_setup_scale_codes():
    one_to_five = resolve_page_setup("ISO_A3", scale="1:5")
    assert one_to_five["dxf_standard_scale_type"] is None  # DXF code 75 has no 1:5
    assert one_to_five["activex_standard_scale"] == 19  # AcPlotScale.ac1_5
    assert one_to_five["scale_ratio"] == [1.0, 5.0]
    five_to_one = resolve_page_setup("ISO_A3", scale="5:1")
    assert five_to_one["dxf_standard_scale_type"] is None
    assert five_to_one["activex_standard_scale"] is None
    assert five_to_one["scale_ratio"] == [5.0, 1.0]
    assert resolve_page_setup("ISO_A3", scale="1:50")["dxf_standard_scale_type"] == 25
    assert resolve_page_setup("ISO_A3", scale="1:50")["activex_standard_scale"] == 26


@pytest.mark.parametrize(
    "kwargs, field",
    [
        ({"paper": "ISO_A9"}, "paper"),
        ({"paper": "ISO_A3", "orientation": "sideways"}, "orientation"),
        ({"paper": "ISO_A3", "scale": "1:3"}, "scale"),
        ({"paper": "ISO_A3", "plot_area": "window"}, "plot_area"),
        ({"paper": "ISO_A3", "device": ""}, "device"),
        ({"paper": "ISO_A3", "plot_style": ""}, "plot_style"),
        ({"paper": "ISO_A3", "margins_mm": [1, 2, 3]}, "margins_mm"),
        ({"paper": "ISO_A3", "margins_mm": [-1, 0, 0, 0]}, "margins_mm"),
        ({"paper": "ISO_A3", "margins_mm": [150, 150, 0, 0]}, "margins_mm"),
        ({"paper": "ISO_A3", "center": "yes"}, "center"),
    ],
)
def test_resolve_page_setup_refuses_naming_the_field(kwargs, field):
    with pytest.raises(ValueError, match=field):
        resolve_page_setup(**kwargs)


def test_unknown_ctb_is_allowed_but_flagged():
    setup = resolve_page_setup("ISO_A3", plot_style="company.ctb")
    assert setup["plot_style"] == "company.ctb" and setup["plot_style_known"] is False


# ── the PDF reader ──────────────────────────────────────────────────────────


def test_read_mediabox_refuses_a_non_pdf(tmp_path):
    bogus = tmp_path / "x.pdf"
    bogus.write_bytes(b"not a pdf")
    with pytest.raises(ValueError, match="PDF"):
        read_mediabox(str(bogus))


def test_read_mediabox_finds_a_box_inside_a_flate_stream(tmp_path):
    body = zlib.compress(b"<< /Type /Page /MediaBox [0 0 595.276 841.89] >>")
    pdf = tmp_path / "c.pdf"
    pdf.write_bytes(
        b"%PDF-1.5\n1 0 obj\n<< /Type /ObjStm /Filter /FlateDecode >>\nstream\n"
        + body
        + b"\nendstream\nendobj\n%%EOF"
    )
    width, height = read_mediabox(str(pdf))
    assert abs(width - 210.0) < 0.05 and abs(height - 297.0) < 0.05


def test_read_mediabox_reports_a_missing_box(tmp_path):
    pdf = tmp_path / "n.pdf"
    pdf.write_bytes(b"%PDF-1.4\n1 0 obj\n<< /Type /Catalog >>\nendobj\n%%EOF")
    with pytest.raises(ValueError, match="MediaBox"):
        read_mediabox(str(pdf))


# ── headless engine ─────────────────────────────────────────────────────────


async def _sheet(backend, name: str = SHEET) -> str:
    result = await backend.layout_create(name)
    assert result["ok"], result
    return name


async def _mediabox_after_export(backend, tmp_path, layout: str):
    pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
    pdf = tmp_path / f"{layout}.pdf"
    result = await backend.drawing_export_pdf(str(pdf), layout=layout)
    assert result["ok"] is True, result
    return read_mediabox(str(pdf))


async def test_apply_iso_a3_landscape_and_the_pdf_mediabox_agrees(backend, tmp_path):
    await _sheet(backend)
    result = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", "landscape"))
    assert result["ok"] is True and result["layout"] == SHEET
    assert result["applied"]["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
    assert result["plot_style_known"] is True
    assert result["viewports_kept"] is True

    rows = await backend.page_setup_list(SHEET)
    assert len(rows) == 1
    row = rows[0]
    assert row["layout"] == SHEET
    assert row["paper"] == "ISO_A3"
    assert row["canonical_media_name"] == "ISO_A3_(420.00_x_297.00_MM)"
    assert row["size_mm"] == [420.0, 297.0]
    assert row["orientation"] == "landscape"
    assert row["plot_style"] == "monochrome.ctb"
    assert row["scale"] == "fit"
    assert row["plot_area"] == "layout"
    assert row["device"] == "DWG To PDF.pc3"
    assert row["center"] is True
    assert len(row["margins_mm"]) == 4

    width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
    assert abs(width - 420.0) <= 0.5 and abs(height - 297.0) <= 0.5


async def test_apply_ansi_b(backend, tmp_path):
    await _sheet(backend)
    await backend.page_setup_apply(SHEET, resolve_page_setup("ANSI_B", "landscape", scale="1:1"))
    row = (await backend.page_setup_list(SHEET))[0]
    assert row["paper"] == "ANSI_B" and row["size_mm"] == [432.0, 279.0]
    assert row["scale"] == "1:1"
    width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
    assert abs(width - 432.0) <= 0.5 and abs(height - 279.0) <= 0.5


async def test_apply_portrait_a4(backend, tmp_path):
    await _sheet(backend)
    await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A4", "portrait"))
    row = (await backend.page_setup_list(SHEET))[0]
    assert row["orientation"] == "portrait" and row["size_mm"] == [210.0, 297.0]
    width, height = await _mediabox_after_export(backend, tmp_path, SHEET)
    assert abs(width - 210.0) <= 0.5 and abs(height - 297.0) <= 0.5


async def test_apply_materialises_the_default_flags_before_setting_centre(backend):
    """Measured: set_flag_state starts from 0 on a fresh layout, so a naive
    plot_centered(True) writes 4 and drops lineweights/plot-styles/viewports-first."""
    await _sheet(backend)
    await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=True))
    flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
    assert flags & 4, "centre-plot bit"
    assert flags & 32, "plot with plot styles"
    assert flags & 128, "plot entity lineweights"
    assert flags & 512, "draw viewports first"
    assert flags & 16, "use standard scale (fit)"
    await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", center=False))
    flags = int(backend._doc.layouts.get(SHEET).dxf_layout.dxf.plot_layout_flags)
    assert not flags & 4 and flags & 32 and flags & 128 and flags & 512


async def test_custom_scale_clears_the_standard_scale_bit_and_writes_the_ratio(backend):
    await _sheet(backend)
    await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3", scale="1:5"))
    dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
    assert not int(dxf.plot_layout_flags) & 16
    assert (float(dxf.scale_numerator), float(dxf.scale_denominator)) == (1.0, 5.0)
    assert (await backend.page_setup_list(SHEET))[0]["scale"] == "1:5"


async def test_apply_keeps_the_viewports(backend):
    await _sheet(backend)
    await backend.viewport_create(SHEET, 210, 148.5, 260, 180, 0, 0, scale=1.0)
    before = (await backend.viewport_list(SHEET))["count"]
    await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
    assert (await backend.viewport_list(SHEET))["count"] == before


async def test_changed_reports_only_what_moved(backend):
    await _sheet(backend)
    first = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
    assert first["changed"], "a fresh layout differs from an A3 setup somewhere"
    second = await backend.page_setup_apply(SHEET, resolve_page_setup("ISO_A3"))
    assert second["changed"] == {}
    third = await backend.page_setup_apply(
        SHEET, resolve_page_setup("ISO_A3", plot_style="acad.ctb")
    )
    assert third["changed"] == {"plot_style": ["monochrome.ctb", "acad.ctb"]}


async def test_unknown_ctb_is_written_and_flagged(backend):
    await _sheet(backend)
    result = await backend.page_setup_apply(
        SHEET, resolve_page_setup("ISO_A3", plot_style="company.ctb")
    )
    assert result["plot_style_known"] is False
    assert (await backend.page_setup_list(SHEET))[0]["plot_style"] == "company.ctb"


async def test_refusals_happen_before_any_write(backend):
    await _sheet(backend)
    dxf = backend._doc.layouts.get(SHEET).dxf_layout.dxf
    before = dxf.get("paper_size")
    with pytest.raises(ValueError, match="Model"):
        await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
    with pytest.raises(ValueError, match="Nope"):
        await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))
    with pytest.raises(ValueError, match="canonical_media_name"):
        await backend.page_setup_apply(SHEET, {"paper": "ISO_A3"})
    assert dxf.get("paper_size") == before
    with pytest.raises(ValueError, match="Nope"):
        await backend.page_setup_list("Nope")


async def test_list_covers_every_paper_layout_and_skips_model(backend):
    await _sheet(backend, "A")
    await _sheet(backend, "B")
    names = [row["layout"] for row in await backend.page_setup_list()]
    assert "Model" not in names and {"A", "B"} <= set(names)


async def test_plot_style_list_headless_is_the_catalog(backend):
    rows = await backend.plot_style_list()
    assert [row["name"] for row in rows] == list(CTB_CATALOG)
    assert all(row["source"] == "catalog" and row["installed"] is None for row in rows)


async def test_dwt_write_capability_is_declared_false_headlessly(backend):
    assert backend.capabilities().to_dict()["features"]["dwt_write"]["supported"] is False


# ── live engine, against a fake ActiveX surface ──────────────────────────────

_MEDIA_RE = re.compile(r"\((\d+\.\d+)_x_(\d+\.\d+)_(MM|Inches)\)")


class _FakeLayout:
    """Records every property set and method call, in order."""

    def __init__(self, name, media_names):
        object.__setattr__(self, "log", [])
        object.__setattr__(self, "_media", tuple(media_names))
        object.__setattr__(
            self,
            "_props",
            {
                "Name": name,
                "ConfigName": "None",
                "CanonicalMediaName": "ISO_A4_(210.00_x_297.00_MM)",
                "StyleSheet": "",
                "PlotType": 5,
                "StandardScale": 16,
                "UseStandardScale": True,
                "PlotRotation": 0,
                "PaperUnits": 1,
                "CenterPlot": False,
                "PlotWithPlotStyles": True,
            },
        )

    def __getattr__(self, attr):
        props = object.__getattribute__(self, "_props")
        if attr in props:
            return props[attr]
        raise AttributeError(attr)

    def __setattr__(self, attr, value):
        self._props[attr] = value
        self.log.append(("set", attr, value))

    def RefreshPlotDeviceInfo(self):
        self.log.append(("call", "RefreshPlotDeviceInfo"))

    def GetCanonicalMediaNames(self):
        self.log.append(("call", "GetCanonicalMediaNames"))
        return self._media

    def GetPaperSize(self):
        match = _MEDIA_RE.search(self._props["CanonicalMediaName"])
        width, height = float(match.group(1)), float(match.group(2))
        if match.group(3) == "Inches":
            width, height = width * 25.4, height * 25.4
        return (width, height)

    def GetPaperMargins(self):
        return ((7.5, 20.0), (7.5, 20.0))

    def GetCustomScale(self):
        return (1.0, 1.0)

    def SetCustomScale(self, numerator, denominator):
        self.log.append(("call", "SetCustomScale", numerator, denominator))


class _FakeLayouts:
    def __init__(self, layouts):
        self.layouts = list(layouts)

    @property
    def Count(self):
        return len(self.layouts)

    def Item(self, key):
        if isinstance(key, int):
            return self.layouts[key]
        for layout in self.layouts:
            if layout.Name == key:
                return layout
        raise RuntimeError(f"no layout {key}")


MEDIA = (
    "ISO_A3_(420.00_x_297.00_MM)",
    "ISO_A4_(210.00_x_297.00_MM)",
    "ANSI_B_(17.00_x_11.00_Inches)",
)


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    document = types.SimpleNamespace(
        Layouts=_FakeLayouts([_FakeLayout("Model", MEDIA), _FakeLayout("Layout1", MEDIA)])
    )
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document


async def test_com_apply_issues_the_property_sequence(com_backend):
    backend, document = com_backend
    result = await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", "landscape"))
    assert result["ok"] is True and result["plot_style_known"] is True
    layout = document.Layouts.Item("Layout1")
    assert layout.log == [
        ("call", "RefreshPlotDeviceInfo"),
        ("set", "ConfigName", "DWG To PDF.pc3"),
        ("call", "RefreshPlotDeviceInfo"),
        ("call", "GetCanonicalMediaNames"),
        ("set", "CanonicalMediaName", "ISO_A3_(420.00_x_297.00_MM)"),
        ("set", "PaperUnits", 1),
        ("set", "PlotRotation", 0),
        ("set", "StyleSheet", "monochrome.ctb"),
        ("set", "PlotWithPlotStyles", True),
        ("set", "PlotType", 5),
        ("set", "StandardScale", 0),
        ("set", "UseStandardScale", True),
        ("set", "CenterPlot", True),
    ]
    assert result["changed"]["paper"] == ["ISO_A4", "ISO_A3"]
    assert result["changed"]["device"] == ["None", "DWG To PDF.pc3"]
    assert result["changed"]["center"] == [False, True]


async def test_com_apply_custom_scale_uses_set_custom_scale(com_backend):
    backend, document = com_backend
    await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3", scale="5:1"))
    log = document.Layouts.Item("Layout1").log
    assert ("call", "SetCustomScale", 5.0, 1.0) in log
    assert ("set", "UseStandardScale", False) in log
    assert not any(entry[:2] == ("set", "StandardScale") for entry in log)


async def test_com_apply_refuses_margins_before_any_write(com_backend):
    backend, document = com_backend
    with pytest.raises(ValueError, match="margins_mm"):
        await backend.page_setup_apply(
            "Layout1", resolve_page_setup("ISO_A3", margins_mm=[10, 10, 10, 10])
        )
    assert document.Layouts.Item("Layout1").log == []


async def test_com_apply_restores_the_device_when_the_media_name_is_unknown(com_backend):
    backend, document = com_backend
    layout = document.Layouts.Item("Layout1")
    object.__setattr__(layout, "_media", ("ISO_A4_(210.00_x_297.00_MM)",))
    with pytest.raises(ValueError, match=r"ISO_A3_\(420.00_x_297.00_MM\)"):
        await backend.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
    assert layout.ConfigName == "None", "the device write is rolled back"
    assert layout.CanonicalMediaName == "ISO_A4_(210.00_x_297.00_MM)"


async def test_com_apply_refuses_model_and_unknown_layouts(com_backend):
    backend, _ = com_backend
    with pytest.raises(ValueError, match="Model"):
        await backend.page_setup_apply("Model", resolve_page_setup("ISO_A3"))
    with pytest.raises(ValueError, match="Nope"):
        await backend.page_setup_apply("Nope", resolve_page_setup("ISO_A3"))


async def test_com_list_reads_the_properties(com_backend):
    backend, _ = com_backend
    rows = await backend.page_setup_list()
    assert [row["layout"] for row in rows] == ["Layout1"]
    row = rows[0]
    assert row["paper"] == "ISO_A4" and row["size_mm"] == [210.0, 297.0]
    assert row["orientation"] == "portrait"
    assert row["margins_mm"] == [20.0, 20.0, 7.5, 7.5]
    assert row["scale"] == "1:1" and row["plot_area"] == "layout"
    assert row["device"] == "None" and row["center"] is False


async def test_com_declares_dwt_write():
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends.com_backend import ComBackend

    assert ComBackend().capabilities().to_dict()["features"]["dwt_write"]["supported"] is True
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_page_setup.py -v Expected: collection FAILS with ModuleNotFoundError: No module named 'engineering.standards' (or No module named 'engineering.standards.papers' once group S's package has merged).

  • Step 3: The standards data — engineering/standards/papers.py

Do not create engineering/standards/__init__.py in this worktree — group S's Task 9 owns it, and a second copy is a cross-group file the merge agent would have to discard. import engineering.standards.papers resolves as a namespace-package portion without it (group C measured this with a probe module and relies on it the same way for sysvars.py), so nothing of S's is needed here. Write:

# engineering/standards/papers.py
"""Paper sizes, plot-style catalogue and plot scales — authored data.

Sources: ISO 216 (A series), ASME/ANSI Y14.1 (A–E, metric millimetre values as
AutoCAD's ``ANSI_*`` media measure, inch values as the media are *named*),
the ctb files AutoCAD ships in its Plot Styles folder, and the standard-scale
tables of two enums that do not agree with each other:

* DXF group code 75 (``standard_scale_type``, what ezdxf writes): 16=1:1,
  17=1:2, 20=1:10, 22=1:20, 25=1:50, 26=1:100, 27=2:1, 30=10:1 — no 1:5.
* ActiveX ``AcPlotScale`` (``Layout.StandardScale``, AutoCAD 2026 typelib):
  ac1_1=16, ac1_2=17, ac1_5=19, ac1_10=21, ac1_20=23, ac1_50=26, ac1_100=27,
  ac2_1=28, ac10_1=31, acScaleToFit=0.

A scale without a code on an engine goes through the custom numerator /
denominator on that engine, and the read-back reports the same label either way.
"""

from __future__ import annotations

import math
import re

__all__ = [
    "ACTIVEX_PLOT_SCALE",
    "CTB_CATALOG",
    "DXF_STANDARD_SCALE_TYPE",
    "ORIENTATIONS",
    "PAPER_INCHES",
    "PAPER_SIZES",
    "PLOT_AREAS",
    "PLOT_TYPE_NAMES",
    "REQUIRED_SETUP_KEYS",
    "SCALES",
    "activex_scale_label",
    "canonical_media_name",
    "dxf_scale_label",
    "paper_from_size",
    "paper_size_mm",
    "plot_style_known",
    "require_page_setup",
    "resolve_page_setup",
    "scale_label",
    "scale_ratio",
]

#: Portrait width × height in millimetres. Landscape swaps.
PAPER_SIZES: dict[str, tuple[int, int]] = {
    "ISO_A0": (841, 1189),
    "ISO_A1": (594, 841),
    "ISO_A2": (420, 594),
    "ISO_A3": (297, 420),
    "ISO_A4": (210, 297),
    "ANSI_A": (216, 279),
    "ANSI_B": (279, 432),
    "ANSI_C": (432, 559),
    "ANSI_D": (559, 864),
    "ANSI_E": (864, 1118),
}

#: ANSI media are *named* in inches by AutoCAD's DWG To PDF.pc3
#: (``ANSI_B_(17.00_x_11.00_Inches)``); the millimetre table above is what the
#: sheet measures. Portrait width × height.
PAPER_INCHES: dict[str, tuple[float, float]] = {
    "ANSI_A": (8.5, 11.0),
    "ANSI_B": (11.0, 17.0),
    "ANSI_C": (17.0, 22.0),
    "ANSI_D": (22.0, 34.0),
    "ANSI_E": (34.0, 44.0),
}

#: Spellings resolve_page_setup accepts, mapped to the canonical key.
_PAPER_ALIASES: dict[str, str] = {
    **{name.lower(): name for name in PAPER_SIZES},
    **{name.split("_", 1)[1].lower(): name for name in PAPER_SIZES if name.startswith("ISO_")},
    **{f"ansi {name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
    **{f"ansi-{name[-1].lower()}": name for name in PAPER_SIZES if name.startswith("ANSI_")},
}

#: Plot style tables AutoCAD ships (``<install>/…/Plot Styles``).
CTB_CATALOG: tuple[str, ...] = (
    "monochrome.ctb",
    "acad.ctb",
    "Grayscale.ctb",
    "Screening 100%.ctb",
    "Screening 75%.ctb",
    "Screening 50%.ctb",
    "Screening 25%.ctb",
    "Fill Patterns.ctb",
    "DWF Virtual Pens.ctb",
)

#: Label → paper:drawing factor (``None`` = scaled to fit).
SCALES: dict[str, float | None] = {
    "fit": None,
    "1:1": 1.0,
    "1:2": 0.5,
    "1:5": 0.2,
    "1:10": 0.1,
    "1:20": 0.05,
    "1:50": 0.02,
    "1:100": 0.01,
    "2:1": 2.0,
    "5:1": 5.0,
    "10:1": 10.0,
}

#: DXF group code 75. Labels missing here use the custom numerator/denominator.
DXF_STANDARD_SCALE_TYPE: dict[str, int] = {
    "fit": 0,
    "1:1": 16,
    "1:2": 17,
    "1:10": 20,
    "1:20": 22,
    "1:50": 25,
    "1:100": 26,
    "2:1": 27,
    "10:1": 30,
}
_DXF_SCALE_LABEL = {code: label for label, code in DXF_STANDARD_SCALE_TYPE.items()}

#: ActiveX ``AcPlotScale`` (AutoCAD 2026 typelib). Labels missing here use SetCustomScale.
ACTIVEX_PLOT_SCALE: dict[str, int] = {
    "fit": 0,
    "1:1": 16,
    "1:2": 17,
    "1:5": 19,
    "1:10": 21,
    "1:20": 23,
    "1:50": 26,
    "1:100": 27,
    "2:1": 28,
    "10:1": 31,
}
_ACTIVEX_SCALE_LABEL = {code: label for label, code in ACTIVEX_PLOT_SCALE.items()}

#: Plot area → DXF/ActiveX plot type (``AcPlotType``: acExtents=1, acLayout=5).
PLOT_AREAS: dict[str, int] = {"layout": 5, "extents": 1}
PLOT_TYPE_NAMES: dict[int, str] = {
    0: "display",
    1: "extents",
    2: "limits",
    3: "view",
    4: "window",
    5: "layout",
}

ORIENTATIONS: tuple[str, ...] = ("landscape", "portrait")

_SCALE_RE = re.compile(r"^\s*(\d+(?:\.\d+)?)\s*:\s*(\d+(?:\.\d+)?)\s*$")


def _paper_key(paper) -> str:
    key = str(paper or "").strip().lower().replace(" ", "_")
    resolved = _PAPER_ALIASES.get(key) or _PAPER_ALIASES.get(key.replace("_", " "))
    if resolved is None:
        raise ValueError(
            f"paper: unknown paper {paper!r}. Valid papers: {', '.join(PAPER_SIZES)} "
            "(short forms A0–A4 and ANSI_A–ANSI_E are accepted)"
        )
    return resolved


def _orientation(orientation) -> str:
    if orientation not in ORIENTATIONS:
        raise ValueError(f"orientation: must be one of {ORIENTATIONS}, got {orientation!r}")
    return orientation


def paper_size_mm(paper: str, orientation: str = "landscape") -> tuple[float, float]:
    """(width, height) in millimetres for ``paper`` in ``orientation``."""
    name = _paper_key(paper)
    short, long = PAPER_SIZES[name]
    if _orientation(orientation) == "landscape":
        return float(long), float(short)
    return float(short), float(long)


def canonical_media_name(paper: str, orientation: str = "landscape") -> str:
    """AutoCAD's spelling of the media: ``ISO_A3_(420.00_x_297.00_MM)`` for
    landscape A3, ``ANSI_B_(17.00_x_11.00_Inches)`` for landscape ANSI B."""
    name = _paper_key(paper)
    if name in PAPER_INCHES:
        short, long = PAPER_INCHES[name]
        unit = "Inches"
    else:
        short, long = PAPER_SIZES[name]
        unit = "MM"
    width, height = (long, short) if _orientation(orientation) == "landscape" else (short, long)
    return f"{name}_({width:.2f}_x_{height:.2f}_{unit})"


def paper_from_size(width_mm, height_mm, tolerance: float = 0.5) -> str | None:
    """The catalogue paper whose size (either orientation) is within ``tolerance`` mm."""
    try:
        w, h = float(width_mm), float(height_mm)
    except (TypeError, ValueError):
        return None
    for name, (short, long) in PAPER_SIZES.items():
        if (abs(w - long) <= tolerance and abs(h - short) <= tolerance) or (
            abs(w - short) <= tolerance and abs(h - long) <= tolerance
        ):
            return name
    return None


def scale_ratio(label: str) -> tuple[float, float]:
    """``"1:50"`` → ``(1.0, 50.0)`` (paper units : drawing units). ``fit`` has no ratio."""
    match = _SCALE_RE.match(str(label or ""))
    if match is None:
        raise ValueError(f"scale: {label!r} is not an N:M ratio")
    numerator, denominator = float(match.group(1)), float(match.group(2))
    if numerator <= 0 or denominator <= 0:
        raise ValueError(f"scale: {label!r} must have positive terms")
    return numerator, denominator


def scale_label(numerator, denominator) -> str:
    """The ``N:M`` label for a ratio: a catalogue label when the factor matches
    one, otherwise the reduced integer ratio, otherwise ``N:M`` with the floats."""
    num, den = float(numerator), float(denominator)
    if num <= 0 or den <= 0:
        return f"{num:g}:{den:g}"
    for label, factor in SCALES.items():
        if factor is not None and math.isclose(num / den, factor, rel_tol=1e-9):
            return label
    if num.is_integer() and den.is_integer():
        divisor = math.gcd(int(num), int(den))
        return f"{int(num) // divisor}:{int(den) // divisor}"
    return f"{num:g}:{den:g}"


def dxf_scale_label(code, numerator, denominator) -> str:
    """Read-back for the headless engine: the code-75 label, else the custom ratio."""
    return _DXF_SCALE_LABEL.get(int(code)) or scale_label(numerator, denominator)


def activex_scale_label(code) -> str:
    """Read-back for the live engine: the AcPlotScale label, else ``standard:<code>``."""
    return _ACTIVEX_SCALE_LABEL.get(int(code), f"standard:{int(code)}")


def plot_style_known(name) -> bool:
    return str(name or "").strip().lower() in {entry.lower() for entry in CTB_CATALOG}


def resolve_page_setup(
    paper: str,
    orientation: str = "landscape",
    plot_style: str = "monochrome.ctb",
    scale: str = "fit",
    plot_area: str = "layout",
    device: str = "DWG To PDF.pc3",
    margins_mm=None,
    center: bool = True,
) -> dict:
    """Validate a page setup request and resolve it to what both engines write.

    Raises ``ValueError`` naming the offending field; nothing here touches a
    drawing. ``margins_mm`` is ``[top, bottom, left, right]``.
    """
    name = _paper_key(paper)
    width, height = paper_size_mm(name, _orientation(orientation))

    style = str(plot_style or "").strip()
    if not style:
        raise ValueError("plot_style: must name a .ctb/.stb file (e.g. monochrome.ctb)")

    label = str(scale or "").strip()
    if label not in SCALES:
        raise ValueError(f"scale: must be one of {', '.join(SCALES)}, got {scale!r}")
    ratio = (1.0, 1.0) if label == "fit" else scale_ratio(label)

    if plot_area not in PLOT_AREAS:
        raise ValueError(f"plot_area: must be one of {', '.join(PLOT_AREAS)}, got {plot_area!r}")

    device_name = str(device or "").strip()
    if not device_name:
        raise ValueError("device: must name a plotter configuration (e.g. 'DWG To PDF.pc3')")

    margins = None
    if margins_mm is not None:
        try:
            values = [float(v) for v in margins_mm]
        except (TypeError, ValueError):
            raise ValueError(
                "margins_mm: must be four numbers [top, bottom, left, right]"
            ) from None
        if len(values) != 4:
            raise ValueError(
                f"margins_mm: expected 4 values [top, bottom, left, right], got {len(values)}"
            )
        if any(v < 0 for v in values):
            raise ValueError("margins_mm: margins cannot be negative")
        top, bottom, left, right = values
        if top + bottom >= height or left + right >= width:
            raise ValueError(
                f"margins_mm: {values} leave no printable area on a "
                f"{width:g} x {height:g} mm sheet"
            )
        margins = values

    if not isinstance(center, bool):
        raise ValueError(f"center: must be true or false, got {center!r}")

    return {
        "paper": name,
        "orientation": orientation,
        "size_mm": [width, height],
        "canonical_media_name": canonical_media_name(name, orientation),
        "plot_style": style,
        "plot_style_known": plot_style_known(style),
        "scale": label,
        "scale_factor": SCALES[label],
        "scale_ratio": [ratio[0], ratio[1]],
        "dxf_standard_scale_type": DXF_STANDARD_SCALE_TYPE.get(label),
        "activex_standard_scale": ACTIVEX_PLOT_SCALE.get(label),
        "plot_area": plot_area,
        "plot_type": PLOT_AREAS[plot_area],
        "device": device_name,
        "margins_mm": margins,
        "center": center,
    }


#: Every key resolve_page_setup emits that a backend reads. A backend refuses
#: a dict missing any of them before it writes.
REQUIRED_SETUP_KEYS: tuple[str, ...] = (
    "paper",
    "orientation",
    "size_mm",
    "canonical_media_name",
    "plot_style",
    "plot_style_known",
    "scale",
    "scale_ratio",
    "dxf_standard_scale_type",
    "activex_standard_scale",
    "plot_area",
    "plot_type",
    "device",
    "margins_mm",
    "center",
)


def require_page_setup(setup) -> dict:
    """The backend-side check: ``setup`` is a complete resolve_page_setup dict."""
    if not isinstance(setup, dict):
        raise ValueError("page_setup_apply: setup must be the dict resolve_page_setup returns")
    missing = [key for key in REQUIRED_SETUP_KEYS if key not in setup]
    if missing:
        raise ValueError(
            f"page_setup_apply: setup is missing {', '.join(missing)} — build it with "
            "engineering.standards.papers.resolve_page_setup"
        )
    return setup
  • Step 4: The PDF reader — engineering/standards/pdfinfo.py
# engineering/standards/pdfinfo.py
"""Read a PDF's page size back from the file — no dependency, no rendering.

The evidence rule for page setup (spec §8.3): the sheet size a plot produced is
what the PDF says it is, not what the layout was asked for. ``/MediaBox`` is the
one PDF object that carries it. matplotlib and AutoCAD's DWG To PDF driver both
write it in clear text; a PDF 1.5+ writer may put the page dictionary inside a
FlateDecode object stream, so when the plain scan finds nothing every stream is
inflated and searched. Anything else raises rather than guessing.
"""

from __future__ import annotations

import re
import zlib
from pathlib import Path

__all__ = ["PT_TO_MM", "read_mediabox"]

#: PostScript points to millimetres.
PT_TO_MM = 25.4 / 72.0

_NUMBER = rb"([-+]?\d*\.?\d+)"
_MEDIABOX_RE = re.compile(
    rb"/MediaBox\s*\[\s*" + _NUMBER + rb"\s+" + _NUMBER + rb"\s+" + _NUMBER + rb"\s+" + _NUMBER + rb"\s*\]"
)
_STREAM_RE = re.compile(rb"stream\r?\n(.*?)\r?\nendstream", re.DOTALL)


def read_mediabox(path: str) -> tuple[float, float]:
    """Return the first page ``/MediaBox`` as ``(width_mm, height_mm)``.

    Raises ``ValueError`` when the file is not a PDF or carries no MediaBox.
    ``/Rotate`` is not applied: the box is reported as written.
    """
    data = Path(path).read_bytes()
    if not data.startswith(b"%PDF"):
        raise ValueError(f"read_mediabox: {path} is not a PDF (no %PDF header)")
    match = _MEDIABOX_RE.search(data)
    if match is None:
        for stream in _STREAM_RE.finditer(data):
            try:
                inflated = zlib.decompress(stream.group(1))
            except zlib.error:
                continue
            match = _MEDIABOX_RE.search(inflated)
            if match is not None:
                break
    if match is None:
        raise ValueError(f"read_mediabox: no /MediaBox found in {path}")
    x0, y0, x1, y1 = (float(value) for value in match.groups())
    return (round(abs(x1 - x0) * PT_TO_MM, 3), round(abs(y1 - y0) * PT_TO_MM, 3))
  • Step 5: The contract — backends/contracts/pagesetup.py
# backends/contracts/pagesetup.py
"""Page setup, plot styles and templates (v1.6 track E, group P).

``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.
"""

from __future__ import annotations

from abc import ABC, abstractmethod


class PageSetupContract(ABC):
    @abstractmethod
    async def page_setup_list(self, layout: str | None = None) -> list[dict]:
        """Page setup of one paper-space layout, or of every one.

        Rows: ``{layout, paper, canonical_media_name, size_mm: [w, h],
        orientation, plot_style, scale, plot_area, device,
        margins_mm: [top, bottom, left, right], center}``. ``paper`` is the
        catalogue name when the size matches one within 0.5 mm, otherwise the
        media name as stored. Raises ``ValueError`` for an unknown layout.
        ``Model`` is never listed: model space is plotted through
        ``drawing_export_pdf(layout=None)`` and carries no sheet.
        """
        ...

    @abstractmethod
    async def page_setup_apply(self, layout: str, setup: dict) -> dict:
        """Write a page setup resolved by
        ``engineering.standards.papers.resolve_page_setup`` to a layout.

        Returns ``{ok, layout, applied: {...setup}, changed: {key: [old, new]},
        plot_style_known, viewports_kept}``. ``changed`` is computed from the
        read-back before and after, so re-applying the same setup reports
        ``{}``. Refuses ``Model``, an unknown layout, and a ``setup`` missing a
        resolved key, before anything is written. Never deletes viewports.
        """
        ...

    @abstractmethod
    async def plot_style_list(self) -> list[dict]:
        """``[{name, source: "catalog"|"installed", installed: bool|None}]`` —
        the authored ctb catalogue plus, on a live engine, the files present
        in the plot style search path. ``installed`` is ``None`` where no
        installation can be scanned."""
        ...

    @abstractmethod
    async def drawing_template_save(
        self, path: str, name: str | None = None, description: str | None = None
    ) -> dict:
        """Save the current drawing as a template.

        ``.dxf`` writes on both engines; ``.dwt`` writes on the live engine
        (``SaveAs(path, ac2018_Template)``) and raises
        ``UnsupportedCapabilityError("dwt_write")`` headlessly. Returns
        ``{ok, path, format: "dxf"|"dwt", backend, name, description_written}``.
        """
        ...

Register it. In backends/contracts/__init__.py add from backends.contracts.pagesetup import PageSetupContract (alphabetical, after LinetypeContract) and "PageSetupContract" to __all__. In backends/base.py add PageSetupContract to the from backends.contracts import (...) list and to the AutoCADBackend(...) bases directly after LayoutContract, — only if not already present (another group may have merged first).

  • Step 6: ezdxf implementation

Add the capability line to EzdxfBackend.capabilities() after "paper_space": FeatureCapability(True, "native"), — an existing key no other group touches, so this hunk never conflicts (S, C and V all insert after "lisp"; skeleton, Merge notes):

                "dwt_write": FeatureCapability(
                    False, reason="dwt_is_a_dwg_container_requires_live_autocad"
                ),

Add the block after the change-space block (immediately before # ── 3D solids (unsupported headlessly …)) — group P's reserved anchor; S's block sits before # ── corner ops, C's at the very end of the class, V's directly after the layouts block (skeleton, Merge notes). Tasks 18 and 20 edit inside this block:

    # ── page setup (track E) ─────────────────────────────────────────────────

    def _paper_layout(self, raw: str, operation: str):
        """Resolve a paper-space layout by name, refusing Model and unknowns."""
        doc = self._require_doc()
        resolved = self._find_layout(raw)
        if resolved is None:
            available = [n for n in doc.layouts.names_in_taborder() if n != "Model"]
            raise ValueError(
                f"{operation}: layout {raw!r} not found (paper-space layouts: "
                f"{', '.join(available) or 'none'})"
            )
        if resolved == "Model":
            raise ValueError(
                f"{operation}: Model space has no sheet; plot it with "
                "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
            )
        return doc.layouts.get(resolved)

    @staticmethod
    def _page_setup_row(layout) -> dict:
        from engineering.standards.papers import (
            PLOT_TYPE_NAMES,
            dxf_scale_label,
            paper_from_size,
            scale_label,
        )

        dxf = layout.dxf_layout.dxf
        flags = int(dxf.get("plot_layout_flags", dxf.get_default("plot_layout_flags")))
        # DXF stores every paper size and margin in millimetres, whatever
        # plot_paper_units says (ezdxf's acdb_plot_settings, verified).
        width, height = float(dxf.paper_width), float(dxf.paper_height)
        rotation = int(dxf.plot_rotation)
        if rotation in (1, 3):
            width, height = height, width
        media = str(dxf.paper_size)
        numerator, denominator = float(dxf.scale_numerator), float(dxf.scale_denominator)
        if flags & layout.USE_STANDARD_SCALE:
            scale = dxf_scale_label(int(dxf.standard_scale_type), numerator, denominator)
        else:
            scale = scale_label(numerator, denominator)
        return {
            "layout": layout.name,
            "paper": paper_from_size(width, height) or media,
            "canonical_media_name": media,
            "size_mm": [round(width, 2), round(height, 2)],
            "orientation": "landscape" if width >= height else "portrait",
            "plot_style": str(dxf.current_style_sheet),
            "scale": scale,
            "plot_area": PLOT_TYPE_NAMES.get(int(dxf.plot_type), str(dxf.plot_type)),
            "device": str(dxf.plot_configuration_file),
            "margins_mm": [
                round(float(dxf.top_margin), 3),
                round(float(dxf.bottom_margin), 3),
                round(float(dxf.left_margin), 3),
                round(float(dxf.right_margin), 3),
            ],
            "center": bool(flags & layout.PLOT_CENTERED),
        }

    @staticmethod
    def _paper_frame(layout) -> dict:
        """The sheet as a matplotlib figure: size in inches plus the axes window.

        Used by ``drawing_export_pdf`` so the PDF's /MediaBox *is* the paper.
        ``plot_rotation`` 1/3 swaps the figure; the content is not rotated by
        the headless renderer (reported, not hidden).
        """
        dxf = layout.dxf_layout.dxf
        width, height = float(dxf.paper_width), float(dxf.paper_height)
        rotation = int(dxf.plot_rotation)
        unit = 25.4 if int(dxf.plot_paper_units) == 0 else 1.0
        x0 = -(float(dxf.left_margin) + float(dxf.plot_origin_x_offset))
        y0 = -(float(dxf.bottom_margin) + float(dxf.plot_origin_y_offset))
        fig_w, fig_h = (height, width) if rotation in (1, 3) else (width, height)
        return {
            "size_mm": [round(fig_w, 2), round(fig_h, 2)],
            "figsize": (fig_w / 25.4, fig_h / 25.4),
            "xlim": (x0 / unit, (x0 + width) / unit),
            "ylim": (y0 / unit, (y0 + height) / unit),
            "rotation_applied": rotation == 0,
        }

    async def page_setup_list(self, layout: str | None = None) -> list[dict]:
        def _sync():
            doc = self._require_doc()
            if layout is not None and self._layout_name(layout):
                return [self._page_setup_row(self._paper_layout(layout, "page_setup_list"))]
            return [
                self._page_setup_row(doc.layouts.get(name))
                for name in doc.layouts.names_in_taborder()
                if name != "Model"
            ]

        return await self._async(_sync)

    async def page_setup_apply(self, layout: str, setup: dict) -> dict:
        from engineering.standards.papers import require_page_setup

        resolved = require_page_setup(setup)

        def _sync():
            from ezdxf.lldxf.const import STD_SCALES

            target = self._paper_layout(layout, "page_setup_apply")
            before = self._page_setup_row(target)
            dxf = target.dxf_layout.dxf
            # Measured: a fresh layout has no stored plot_layout_flags, and
            # set_flag_state starts from 0 — toggling one bit would drop
            # plot-styles (32), lineweights (128) and viewports-first (512).
            if not dxf.hasattr("plot_layout_flags"):
                dxf.plot_layout_flags = dxf.get_default("plot_layout_flags")
            width, height = (float(v) for v in resolved["size_mm"])
            dxf.page_setup_name = ""
            dxf.plot_configuration_file = resolved["device"]
            dxf.paper_size = resolved["canonical_media_name"]
            dxf.paper_width = width
            dxf.paper_height = height
            dxf.plot_paper_units = 1  # millimetres
            dxf.plot_rotation = 0  # orientation rides on width/height, as AutoCAD names media
            if resolved["margins_mm"] is not None:
                top, bottom, left, right = (float(v) for v in resolved["margins_mm"])
                dxf.top_margin, dxf.bottom_margin = top, bottom
                dxf.left_margin, dxf.right_margin = left, right
            dxf.plot_origin_x_offset = 0.0
            dxf.plot_origin_y_offset = 0.0
            dxf.unit_factor = 1.0
            dxf.current_style_sheet = resolved["plot_style"]
            target.use_plot_styles(True)
            target.set_plot_type(int(resolved["plot_type"]))
            code = resolved["dxf_standard_scale_type"]
            if code is not None:
                dxf.standard_scale_type = int(code)
                numerator, denominator = STD_SCALES.get(int(code), (1.0, 1.0))
                dxf.scale_numerator, dxf.scale_denominator = float(numerator), float(denominator)
                target.use_standard_scale(True)
            else:
                numerator, denominator = (float(v) for v in resolved["scale_ratio"])
                dxf.standard_scale_type = 16
                dxf.scale_numerator, dxf.scale_denominator = numerator, denominator
                target.use_standard_scale(False)
            target.plot_centered(bool(resolved["center"]))
            # Limits follow the paper; viewports are deliberately left alone
            # (Paperspace.page_setup() would delete them).
            target.reset_paper_limits()
            self._mark_dirty()
            after = self._page_setup_row(target)
            changed = {
                key: [before[key], after[key]]
                for key in after
                if key != "layout" and before.get(key) != after[key]
            }
            return {
                "ok": True,
                "layout": target.name,
                "applied": dict(resolved),
                "changed": changed,
                "plot_style_known": bool(resolved["plot_style_known"]),
                "viewports_kept": True,
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def plot_style_list(self) -> list[dict]:
        from engineering.standards.papers import CTB_CATALOG

        return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]

    async def drawing_template_save(self, path, name=None, description=None) -> dict:
        # Completed in Task 20; declared here so the ABC instantiates.
        raise UnsupportedCapabilityError(
            "dwt_write", "drawing_template_save: not implemented yet (Task 20)"
        )

Then the delimited edit inside drawing_export_pdf — replace the seven lines from fig = _new_agg_figure() through result = {"ok": True, "path": path} (fig = …, ax = …, ctx = …, out = …, Frontend(ctx, out).draw_layout(…), fig.savefig(…), result = …; the from ezdxf.addons.drawing … imports above them and the if resolved is not None and resolved != "Model": block below them stay) with:

                # Track E: for a sheet, the figure *is* the paper. Sized from
                # the layout's page setup and left unadjusted, so the PDF's
                # /MediaBox is the paper size — the evidence page_setup_apply
                # is judged by. Measured: adjust_figure=True gave 691.2 x 345.6
                # pt whatever the sheet was.
                paper = None
                if resolved is not None and resolved != "Model":
                    paper = self._paper_frame(target)
                fig = _new_agg_figure(figsize=paper["figsize"] if paper else None)
                ax = fig.add_axes([0, 0, 1, 1])
                ctx = RenderContext(doc)
                out = MatplotlibBackend(ax, adjust_figure=paper is None)
                Frontend(ctx, out).draw_layout(target, finalize=True)
                if paper is not None:
                    ax.set_xlim(*paper["xlim"])
                    ax.set_ylim(*paper["ylim"])
                    ax.set_aspect("equal", adjustable="box")
                fig.savefig(path, dpi=150)
                result = {"ok": True, "path": path}
                if paper is not None:
                    result["paper_mm"] = paper["size_mm"]
                    if not paper["rotation_applied"]:
                        result["rotation_applied"] = False

(tests/test_viewport_ops.py::_ink counts the pixels of a PNG rendered through this path; a layout that was never set up now renders at the DXF default 420 × 297 mm — 2480 × 1754 px at 150 dpi — so that test runs a few seconds longer. It still passes: its subtraction argument does not depend on the raster size.)

  • Step 7: COM implementation

Add to ComBackend.capabilities() after "paper_space": FeatureCapability(True, "native"), (same rule as on ezdxf — an anchor nobody else touches):

                "dwt_write": FeatureCapability(True, "native"),

Add the block immediately before # ── 3D solids (native ActiveX; gated behind ENABLE_3D at the tool layer) (after the viewports block — the same position as on ezdxf; the end of the layouts block, right after layout_copy, is group V's anchor and must stay untouched):

    # ── page setup (track E) ─────────────────────────────────────────────────
    #
    # Layout is an IAcadPlotConfiguration. Constants from the AutoCAD 2026
    # typelib: AcPlotPaperUnits acMillimeters=1; AcPlotRotation ac0degrees=0;
    # AcPlotType acExtents=1 / acLayout=5; AcPlotScale acScaleToFit=0, ac1_1=16
    # (the full table is engineering.standards.papers.ACTIVEX_PLOT_SCALE).
    # Fake-tested in tests/test_page_setup.py; executed live by
    # scripts/smoke_settings_com.py (Task 24).

    _AC_MILLIMETERS = 1
    _AC_NO_ROTATION = 0

    @staticmethod
    def _com_page_setup_row(layout) -> dict:
        from engineering.standards.papers import (
            PLOT_TYPE_NAMES,
            activex_scale_label,
            paper_from_size,
            scale_label,
        )

        def _get(attr):
            try:
                return getattr(layout, attr)
            except Exception as exc:
                log.debug("Layout.%s read failed: %s", attr, exc)
                return None

        units = _get("PaperUnits")
        factor = 25.4 if units == 0 else 1.0
        size = None
        try:
            width, height = layout.GetPaperSize()
            size = [round(float(width) * factor, 2), round(float(height) * factor, 2)]
        except Exception as exc:
            log.debug("Layout.GetPaperSize failed: %s", exc)
        margins = None
        try:
            (left, bottom), (right, top) = layout.GetPaperMargins()
            margins = [round(float(v) * factor, 3) for v in (top, bottom, left, right)]
        except Exception as exc:
            log.debug("Layout.GetPaperMargins failed: %s", exc)
        rotation = _get("PlotRotation") or 0
        if size and int(rotation) in (1, 3):
            size = [size[1], size[0]]
        if _get("UseStandardScale"):
            scale = activex_scale_label(_get("StandardScale") or 0)
        else:
            try:
                numerator, denominator = layout.GetCustomScale()
                scale = scale_label(float(numerator), float(denominator))
            except Exception as exc:
                log.debug("Layout.GetCustomScale failed: %s", exc)
                scale = None
        media = _get("CanonicalMediaName")
        plot_type = _get("PlotType")
        return {
            "layout": layout.Name,
            "paper": (paper_from_size(*size) if size else None) or media,
            "canonical_media_name": media,
            "size_mm": size,
            "orientation": (
                None if not size else ("landscape" if size[0] >= size[1] else "portrait")
            ),
            "plot_style": _get("StyleSheet"),
            "scale": scale,
            "plot_area": PLOT_TYPE_NAMES.get(plot_type, plot_type),
            "device": _get("ConfigName"),
            "margins_mm": margins,
            "center": bool(_get("CenterPlot")),
        }

    def _com_paper_layout(self, doc, raw: str, operation: str):
        resolved = self._find_layout(doc, raw)
        if resolved is None:
            available = [n for n in self._layout_names(doc) if n != "Model"]
            raise ValueError(
                f"{operation}: layout {raw!r} not found (paper-space layouts: "
                f"{', '.join(available) or 'none'})"
            )
        if resolved == "Model":
            raise ValueError(
                f"{operation}: Model space has no sheet; plot it with "
                "drawing_export_pdf(layout=None) or apply the setup to a paper-space layout"
            )
        return doc.Layouts.Item(resolved)

    async def page_setup_list(self, layout: str | None = None) -> list[dict]:
        def _sync():
            doc = _acad_doc()
            if layout is not None and self._layout_name(layout):
                target = self._com_paper_layout(doc, layout, "page_setup_list")
                return [self._com_page_setup_row(target)]
            return [
                self._com_page_setup_row(doc.Layouts.Item(name))
                for name in self._layout_names(doc)
                if name != "Model"
            ]

        return await self._run(_sync)

    async def page_setup_apply(self, layout: str, setup: dict) -> dict:
        from engineering.standards.papers import require_page_setup

        resolved = require_page_setup(setup)
        if resolved["margins_mm"] is not None:
            raise ValueError(
                "page_setup_apply: margins_mm cannot be written on the COM backend — AutoCAD "
                "takes the printable margins from the plotter configuration (.pc3). Omit "
                "margins_mm; the device's margins are read back in the result."
            )

        def _sync():
            doc = _acad_doc()
            target = self._com_paper_layout(doc, layout, "page_setup_apply")
            before = self._com_page_setup_row(target)
            previous_device = before["device"]
            target.RefreshPlotDeviceInfo()
            target.ConfigName = resolved["device"]
            target.RefreshPlotDeviceInfo()
            media = resolved["canonical_media_name"]
            names = [str(n) for n in (target.GetCanonicalMediaNames() or ())]
            if media not in names:
                # Roll the one write back before refusing, so a refusal leaves
                # the layout as it was.
                target.ConfigName = previous_device
                nearest = [n for n in names if n.split("_(")[0] == media.split("_(")[0]]
                raise ValueError(
                    f"page_setup_apply: device {resolved['device']!r} has no media "
                    f"{media!r} (same paper on this device: {', '.join(nearest) or 'none'}; "
                    f"{len(names)} media in total)"
                )
            target.CanonicalMediaName = media
            target.PaperUnits = self._AC_MILLIMETERS
            target.PlotRotation = self._AC_NO_ROTATION
            target.StyleSheet = resolved["plot_style"]
            target.PlotWithPlotStyles = True
            target.PlotType = int(resolved["plot_type"])
            code = resolved["activex_standard_scale"]
            if code is not None:
                target.StandardScale = int(code)
                target.UseStandardScale = True
            else:
                numerator, denominator = (float(v) for v in resolved["scale_ratio"])
                target.SetCustomScale(numerator, denominator)
                target.UseStandardScale = False
            target.CenterPlot = bool(resolved["center"])
            after = self._com_page_setup_row(target)
            changed = {
                key: [before[key], after[key]]
                for key in after
                if key != "layout" and before.get(key) != after[key]
            }
            return {
                "ok": True,
                "layout": target.Name,
                "applied": dict(resolved),
                "changed": changed,
                "plot_style_known": bool(resolved["plot_style_known"]),
                "viewports_kept": True,
                "backend": "com",
            }

        return await self._run(_sync)

    async def plot_style_list(self) -> list[dict]:
        # Completed in Task 18 (installed ctb scan); the catalogue rows already answer.
        from engineering.standards.papers import CTB_CATALOG

        return [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]

    async def drawing_template_save(self, path, name=None, description=None) -> dict:
        # Completed in Task 20; declared here so the ABC instantiates.
        raise UnsupportedCapabilityError(
            "dwt_write", "drawing_template_save: not implemented yet (Task 20)"
        )

UnsupportedCapabilityError is already imported into com_backend.py from .base (check the from .base import (...) list; add it there if it is not).

  • Step 8: Run the tests

Run: uv run --frozen pytest tests/test_page_setup.py tests/test_capability_contract.py tests/test_viewport_ops.py tests/test_layouts_solids.py -v Expected: all PASS. test_capability_contract proves both backends still instantiate with the new abstract members.

  • Step 9: Commit
git add engineering/standards/papers.py engineering/standards/pdfinfo.py backends/contracts/pagesetup.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py tests/test_page_setup.py
git commit -m "feat(pagesetup): page_setup_list/apply on both engines — the PDF's own /MediaBox is the evidence"

Task 18: SECTION 19 opens — page_setup_list, page_setup_apply, plot_style_list, batch_plot (+ installed-ctb scan, mediabox read-back)

Files:

  • Create: engineering/standards/plot.py
  • Modify: backends/com_backend.py (plot_style_list in the # ── page setup (track E) ── block: installed ctb scan)
  • Modify: server.py — new # ── SECTION 19: Page Setup & Templates (4 tools) ── inserted immediately before the # ── RESOURCES banner (group S inserts SECTION 18 at the same spot; the merge agent orders them 18 then 19)
  • Modify: discovery/aliases.py — SHARED_ACAD_COMMANDS gains PAGESETUP and PLOT; a # ── Page setup & templates (track E) ── alias block
  • Modify: README.md snapshot line 166 tools → 170 tools, the <sub>166 tools in 20 groups line → 170 tools in 20 groups, "Paper space" row; regenerate docs/tool-inventory.json
  • Modify: tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged — layouts: 12 → 16, total 166 → 170, docstring sentence
  • Test: tests/test_batch_plot.py

Interfaces:

  • Produces (engineering/standards/plot.py): async def batch_plot(backend, layouts, output_dir, file_pattern="{drawing}-{layout}.pdf") -> dict → {ok, drawing, output_dir, format: "pdf", sheets: [{layout, ok, path, bytes, mediabox_mm: [w, h] | None, paper: str | None, error?}], count}; safe_filename(text) -> str.

  • Produces (MCP tools): page_setup_list(layout=None), page_setup_apply(layout, paper, orientation="landscape", plot_style="monochrome.ctb", scale="fit", plot_area="layout", device="DWG To PDF.pc3", margins_mm=None, center=True), plot_style_list(), batch_plot(output_dir, layouts=None, format="pdf", file_pattern="{drawing}-{layout}.pdf"). (The spec writes batch_plot(layouts=None, output_dir, …); a required parameter cannot follow a defaulted one in Python, so output_dir is first.)

  • Pack: all four are core. Lean: page_setup_apply and batch_plot join LEAN_TOOL_NAMES in the final task (spec §8.2); page_setup_list and plot_style_list do not.

  • Consumes: resolve_page_setup, read_mediabox, validate_path(allow_write=True), backend.layout_list/drawing_info/drawing_export_pdf, _acad_app().Preferences.Files.PrintStyleSheetPath.

  • Step 1: Write the failing tests

# tests/test_batch_plot.py
"""batch_plot: every sheet to PDF, each sheet size read back from the PDF.

The composition is exercised on the headless engine end to end; the COM-only
piece of this task (the installed ctb scan) is fake-tested; the SECTION 19
tools are called directly with a minimal context.
"""

from __future__ import annotations

import types

import pytest
from fastmcp.exceptions import ToolError

import server
from engineering.standards.papers import CTB_CATALOG, resolve_page_setup
from engineering.standards.plot import batch_plot, safe_filename

pytestmark = pytest.mark.asyncio


class _Ctx:
    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, message):
        pass

    async def warning(self, message):
        pass

    async def report_progress(self, progress, total):
        pass


async def _two_sheets(backend):
    """Exactly two paper layouts: the default tab renamed, plus one more."""
    await backend.layout_rename("Layout1", "A3")
    await backend.layout_create("B")
    await backend.page_setup_apply("A3", resolve_page_setup("ISO_A3", "landscape"))
    await backend.page_setup_apply("B", resolve_page_setup("ANSI_B", "landscape"))
    await backend.entity_create_line(0, 0, 100, 50)


def test_safe_filename_strips_what_a_filesystem_refuses():
    assert safe_filename("Sheet 1: A3/rev*B") == "Sheet_1_A3_rev_B"
    assert safe_filename("Layout1") == "Layout1"


async def test_every_paper_layout_is_plotted_and_measured(backend, tmp_path):
    pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
    await _two_sheets(backend)
    result = await batch_plot(backend, None, str(tmp_path / "out"))
    assert result["ok"] is True and result["format"] == "pdf"
    assert result["count"] == 2
    by_layout = {row["layout"]: row for row in result["sheets"]}
    assert set(by_layout) == {"A3", "B"}, "Model is not plotted unless named"
    a3, b = by_layout["A3"], by_layout["B"]
    assert a3["ok"] and a3["bytes"] > 0 and a3["path"].endswith("untitled-A3.pdf")
    assert abs(a3["mediabox_mm"][0] - 420) <= 0.5 and abs(a3["mediabox_mm"][1] - 297) <= 0.5
    assert a3["paper"] == "ISO_A3"
    assert abs(b["mediabox_mm"][0] - 432) <= 0.5 and abs(b["mediabox_mm"][1] - 279) <= 0.5
    assert b["paper"] == "ANSI_B"


async def test_model_is_included_only_when_named(backend, tmp_path):
    pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
    await _two_sheets(backend)
    result = await batch_plot(backend, ["Model", "A3"], str(tmp_path))
    names = [row["layout"] for row in result["sheets"]]
    assert names == ["Model", "A3"]
    model = result["sheets"][0]
    assert model["ok"] and model["mediabox_mm"] is not None
    assert model["paper"] is None, "model space has no sheet; the size is reported, not named"


async def test_refusals_write_nothing(backend, tmp_path):
    await _two_sheets(backend)
    out = tmp_path / "nothing"
    with pytest.raises(ValueError, match="Nope"):
        await batch_plot(backend, ["Nope"], str(out))
    with pytest.raises(ValueError, match="layout"):
        await batch_plot(backend, None, str(out), file_pattern="all.pdf")
    with pytest.raises(ValueError, match="file_pattern"):
        await batch_plot(backend, None, str(out), file_pattern="sub/{layout}.pdf")
    with pytest.raises(ValueError, match="layouts"):
        await batch_plot(backend, [], str(out))
    assert not out.exists() or not any(out.iterdir())


async def test_a_fresh_drawing_plots_its_one_default_sheet_at_the_dxf_default_size(backend, tmp_path):
    """No page setup ever applied: the sheet still plots, at ezdxf's stored
    default (A3, 420 x 297), and the row says so rather than guessing."""
    pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
    result = await batch_plot(backend, None, str(tmp_path), file_pattern="{layout}.pdf")
    assert [row["layout"] for row in result["sheets"]] == ["Layout1"]
    row = result["sheets"][0]
    assert row["path"].endswith("Layout1.pdf") and row["paper"] == "ISO_A3"


# ── the tools ───────────────────────────────────────────────────────────────


async def test_page_setup_tools_round_trip(backend):
    ctx = _Ctx(backend)
    await backend.layout_create("A3")
    applied = await server.page_setup_apply("A3", "ISO_A3", ctx=ctx)
    assert applied["ok"] and applied["applied"]["paper"] == "ISO_A3"
    listed = await server.page_setup_list(ctx=ctx)
    assert listed["count"] == 1 and listed["layouts"][0]["paper"] == "ISO_A3"
    only = await server.page_setup_list("A3", ctx=ctx)
    assert only["layouts"][0]["layout"] == "A3"


async def test_page_setup_apply_tool_refuses_before_writing(backend):
    ctx = _Ctx(backend)
    await backend.layout_create("A3")
    with pytest.raises(ToolError, match="scale"):
        await server.page_setup_apply("A3", "ISO_A3", scale="1:3", ctx=ctx)
    with pytest.raises(ToolError, match="Model"):
        await server.page_setup_apply("Model", "ISO_A3", ctx=ctx)
    row = (await backend.page_setup_list("A3"))[0]
    assert row["device"] == "", "nothing was written"


async def test_plot_style_list_tool(backend):
    result = await server.plot_style_list(ctx=_Ctx(backend))
    assert result["ok"] and result["count"] == len(CTB_CATALOG)
    assert result["backend"] == "ezdxf"


async def test_batch_plot_tool_validates_the_output_directory(backend, tmp_path):
    pytest.importorskip("matplotlib", reason="rendering needs the [pdf] extra")
    await _two_sheets(backend)
    with pytest.raises(ToolError):
        await server.batch_plot("../../outside", ctx=_Ctx(backend))
    with pytest.raises(ToolError, match="format"):
        await server.batch_plot(str(tmp_path), format="png", ctx=_Ctx(backend))
    result = await server.batch_plot(str(tmp_path), layouts=["A3"], ctx=_Ctx(backend))
    assert result["count"] == 1 and result["sheets"][0]["paper"] == "ISO_A3"


# ── COM: the installed ctb scan ─────────────────────────────────────────────


@pytest.fixture
def com_backend(monkeypatch, tmp_path):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    styles = tmp_path / "Plot Styles"
    styles.mkdir()
    (styles / "monochrome.ctb").write_bytes(b"")
    (styles / "Anka.ctb").write_bytes(b"")
    (styles / "readme.txt").write_bytes(b"")
    app = types.SimpleNamespace(
        Preferences=types.SimpleNamespace(
            Files=types.SimpleNamespace(PrintStyleSheetPath=f"{styles};C:\\does\\not\\exist")
        )
    )
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, app


async def test_com_plot_style_list_marks_installed_and_adds_local_files(com_backend):
    backend, _ = com_backend
    rows = await backend.plot_style_list()
    by_name = {row["name"]: row for row in rows}
    assert by_name["monochrome.ctb"] == {"name": "monochrome.ctb", "source": "catalog", "installed": True}
    assert by_name["acad.ctb"]["installed"] is False
    assert by_name["Anka.ctb"] == {"name": "Anka.ctb", "source": "installed", "installed": True}
    assert "readme.txt" not in by_name


async def test_com_plot_style_list_survives_an_unreachable_preferences_object(com_backend, monkeypatch):
    backend, _ = com_backend
    from backends import com_backend as module

    def _boom():
        raise RuntimeError("no application")

    monkeypatch.setattr(module, "_acad_app", _boom)
    rows = await backend.plot_style_list()
    assert [row["name"] for row in rows] == list(CTB_CATALOG)
    assert all(row["installed"] is None for row in rows)
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_batch_plot.py -v Expected: collection FAILS with ModuleNotFoundError: No module named 'engineering.standards.plot'.

  • Step 3: The composition — engineering/standards/plot.py
# engineering/standards/plot.py
"""batch_plot: every sheet through drawing_export_pdf, each PDF measured back.

Composes the existing per-layout export; the only thing it adds is the
evidence — ``mediabox_mm`` is parsed from the file each sheet produced, so a
caller sees the size that was plotted, not the size that was asked for.
"""

from __future__ import annotations

import re
from pathlib import Path

from engineering.standards.papers import paper_from_size
from engineering.standards.pdfinfo import read_mediabox

__all__ = ["DEFAULT_FILE_PATTERN", "batch_plot", "safe_filename"]

DEFAULT_FILE_PATTERN = "{drawing}-{layout}.pdf"

_UNSAFE = re.compile(r"[<>:\"/\\|?*\s]+")


def safe_filename(text: str) -> str:
    """A layout or drawing name as a filename fragment: no separators, no
    reserved characters, runs of them collapsed to one underscore."""
    cleaned = _UNSAFE.sub("_", str(text or "").strip()).strip("_")
    return cleaned or "untitled"


async def batch_plot(
    backend,
    layouts: list[str] | None,
    output_dir: str,
    file_pattern: str = DEFAULT_FILE_PATTERN,
) -> dict:
    """Plot layouts to PDF and read each sheet size back from the file.

    ``layouts=None`` plots every paper-space layout; ``Model`` is plotted only
    when named. Refuses (``ValueError``, before any PDF is written): an empty
    list, a layout that does not exist, a pattern with a path separator, and
    a pattern without ``{layout}`` when more than one sheet would be written.
    The output folder is created, then every output path goes through
    ``security.validate_path(allow_write=True)`` before the first export.
    """
    from security import validate_path

    listing = await backend.layout_list()
    available = [str(name) for name in (listing.get("layouts") or [])]
    lowered = {name.lower(): name for name in available}
    if layouts is None:
        targets = [name for name in available if name.lower() != "model"]
        if not targets:
            raise ValueError("batch_plot: the drawing has no paper-space layout to plot")
    else:
        requested = [str(name).strip() for name in layouts]
        if not requested:
            raise ValueError("batch_plot: layouts is empty — pass null for every sheet")
        unknown = [name for name in requested if name.lower() not in lowered]
        if unknown:
            raise ValueError(
                f"batch_plot: layout(s) not found: {', '.join(unknown)} "
                f"(available: {', '.join(available)})"
            )
        targets = [lowered[name.lower()] for name in requested]

    pattern = str(file_pattern or DEFAULT_FILE_PATTERN)
    if "/" in pattern or "\\" in pattern:
        raise ValueError("batch_plot: file_pattern must be a file name, not a path")
    if "{layout}" not in pattern and len(targets) > 1:
        raise ValueError(
            "batch_plot: file_pattern needs {layout} when more than one sheet is plotted, "
            "or every sheet would overwrite the last"
        )
    try:
        pattern.format(drawing="x", layout="y")
    except (KeyError, IndexError, ValueError) as exc:
        raise ValueError(
            f"batch_plot: file_pattern {pattern!r} may only use {{drawing}} and {{layout}}"
        ) from exc

    info = await backend.drawing_info()
    drawing = safe_filename(Path(info.name).stem)
    destination = Path(validate_path(output_dir, allow_write=True))
    planned = [
        (name, destination / pattern.format(drawing=drawing, layout=safe_filename(name)))
        for name in targets
    ]
    # validate_path(allow_write=True) requires the parent folder to exist, so
    # the output folder is created before the per-file fence check; an empty
    # folder is the most a refused call leaves behind.
    destination.mkdir(parents=True, exist_ok=True)
    for _name, path in planned:
        validate_path(str(path), allow_write=True)

    sheets: list[dict] = []
    for name, path in planned:
        is_model = name.lower() == "model"
        result = await backend.drawing_export_pdf(str(path), layout=None if is_model else name)
        if not result.get("ok", False):
            sheets.append({"layout": name, "ok": False, "path": str(path), "error": result.get("error")})
            continue
        row = {"layout": name, "ok": True, "path": str(path), "bytes": path.stat().st_size}
        try:
            width, height = read_mediabox(str(path))
            row["mediabox_mm"] = [width, height]
            row["paper"] = None if is_model else paper_from_size(width, height)
        except ValueError as exc:
            row["mediabox_mm"] = None
            row["paper"] = None
            row["error"] = str(exc)
        sheets.append(row)

    return {
        "ok": all(row["ok"] for row in sheets),
        "drawing": drawing,
        "output_dir": str(destination),
        "format": "pdf",
        "sheets": sheets,
        "count": len(sheets),
    }
  • Step 4: COM plot_style_list — the installed scan

Replace the Task 17 stub in backends/com_backend.py:

    async def plot_style_list(self) -> list[dict]:
        """The ctb catalogue, marked against the files in AutoCAD's plot style path.

        ``Preferences.Files.PrintStyleSheetPath`` is a ``;``-separated list of
        folders. Every read is guarded: a folder that cannot be listed is
        skipped, and when the Preferences object is unreachable the catalogue
        rows come back with ``installed: None`` rather than a fabricated False.
        """
        from engineering.standards.papers import CTB_CATALOG

        def _sync():
            rows = [{"name": name, "source": "catalog", "installed": None} for name in CTB_CATALOG]
            try:
                raw = str(_acad_app().Preferences.Files.PrintStyleSheetPath or "")
            except Exception as exc:
                log.debug("Preferences.Files.PrintStyleSheetPath unreadable: %s", exc)
                return rows
            installed: dict[str, str] = {}
            for folder in (part.strip() for part in raw.split(";")):
                if not folder:
                    continue
                try:
                    entries = sorted(Path(folder).iterdir())
                except OSError as exc:
                    log.debug("plot style folder %r not listable: %s", folder, exc)
                    continue
                for entry in entries:
                    if entry.suffix.lower() in (".ctb", ".stb"):
                        installed.setdefault(entry.name.lower(), entry.name)
            catalog_lower = {row["name"].lower() for row in rows}
            for row in rows:
                row["installed"] = row["name"].lower() in installed
            for key, name in installed.items():
                if key not in catalog_lower:
                    rows.append({"name": name, "source": "installed", "installed": True})
            return rows

        return await self._run(_sync)
  • Step 5: Register SECTION 19 in server.py

Insert immediately before the # ── RESOURCES banner:

# ---------------------------------------------------------------------------
# ── SECTION 19: Page Setup & Templates (4 tools) ────────────────────────────
# ---------------------------------------------------------------------------


@cad_tool(summary="Read each sheet's page setup: paper, orientation, ctb, scale, device.", cost="read")
@mcp.tool(
    annotations={"title": "List Page Setups", "readOnlyHint": True},
    tags={"layout", "plot", "query"},
)
async def page_setup_list(
    layout: Annotated[
        str | None, "One paper-space layout, or omit for every sheet (Model is never listed)."
    ] = None,
    ctx: Context = None,
) -> dict:
    """The page setup stored on each paper-space layout.

    `paper` is the catalogue name (ISO_A3, ANSI_B, …) when the stored size
    matches one within 0.5 mm, otherwise the media name as stored; `size_mm`,
    `orientation`, `plot_style` (ctb), `scale` ("fit", "1:50", or a custom
    ratio), `plot_area`, `device`, `margins_mm` [top, bottom, left, right] and
    `center` are read back from the LAYOUT object (headless) or the AutoCAD
    Layout (live). Refuses an unknown layout name and `Model` (model space has
    no sheet; plot it with `drawing_export_pdf(layout=None)`).
    """
    try:
        rows = await _backend(ctx).page_setup_list(layout)
    except ValueError as exc:
        raise ToolError(str(exc)) from exc
    return {"ok": True, "layouts": rows, "count": len(rows)}


@cad_tool(
    summary="Set a sheet's paper, orientation, ctb, plot scale and device (PAGESETUP).",
    cost="safe",
)
@mcp.tool(
    annotations={"title": "Apply Page Setup", "destructiveHint": False},
    tags={"layout", "plot"},
)
async def page_setup_apply(
    layout: Annotated[str, "Paper-space layout to set up (never 'Model')."],
    paper: Annotated[
        str, "ISO_A0…ISO_A4 or ANSI_A…ANSI_E (short forms A3, ansi_b accepted)."
    ],
    orientation: Annotated[str, "landscape | portrait"] = "landscape",
    plot_style: Annotated[
        str, "Plot style table, e.g. monochrome.ctb, acad.ctb, Grayscale.ctb (plot_style_list)."
    ] = "monochrome.ctb",
    scale: Annotated[
        str, "fit | 1:1 | 1:2 | 1:5 | 1:10 | 1:20 | 1:50 | 1:100 | 2:1 | 5:1 | 10:1"
    ] = "fit",
    plot_area: Annotated[str, "layout | extents"] = "layout",
    device: Annotated[str, "Plotter configuration (.pc3) or printer name."] = "DWG To PDF.pc3",
    margins_mm: Annotated[
        list[float] | None,
        "[top, bottom, left, right] in mm. Headless only: AutoCAD takes margins from the .pc3.",
    ] = None,
    center: Annotated[bool, "Centre the plot on the paper."] = True,
    ctx: Context = None,
) -> dict:
    """AutoCAD's PAGESETUP as one call, on both engines.

    Paper sizes are ISO 216 / ANSI Y14.1; the media is written in AutoCAD's own
    spelling (`ISO_A3_(420.00_x_297.00_MM)`). `changed` lists only the values
    that moved — re-applying the same setup reports `{}`. An unknown ctb is
    written and reported `plot_style_known: false` (a missing ctb only matters
    at plot time). Viewports on the sheet are never touched.

    Refused before anything is written: an unknown paper, orientation, scale
    or plot_area, an empty device or plot_style, margins that leave no
    printable area, `Model`, an unknown layout. On the live engine `margins_mm`
    is refused (the .pc3 owns them) and a media the device does not offer is
    refused with the device's names for the same paper.

    The proof is the PDF: `batch_plot` reads each sheet's `/MediaBox` back.
    """
    from engineering.standards.papers import resolve_page_setup

    try:
        setup = resolve_page_setup(
            paper, orientation, plot_style, scale, plot_area, device, margins_mm, center
        )
        await ctx.info(f"Applying page setup {setup['paper']} {orientation} to {layout}")
        return await _backend(ctx).page_setup_apply(layout, setup)
    except ValueError as exc:
        raise ToolError(str(exc)) from exc


@cad_tool(summary="List the plot style tables (ctb): the AutoCAD catalogue, plus what is installed.", cost="read")
@mcp.tool(
    annotations={"title": "List Plot Styles", "readOnlyHint": True},
    tags={"layout", "plot", "query"},
)
async def plot_style_list(ctx: Context = None) -> dict:
    """The ctb files AutoCAD ships (`monochrome.ctb`, `acad.ctb`, `Grayscale.ctb`,
    the Screening set, …) and, on the live engine, the files actually present
    in `Preferences.Files.PrintStyleSheetPath` (`source: "installed"`,
    `installed: true/false` per row). Headlessly `installed` is `null`: there
    is no installation to scan, and the catalogue is not evidence of one.
    Never refuses; an unreadable Preferences object degrades to the catalogue.
    """
    backend = _backend(ctx)
    rows = await backend.plot_style_list()
    return {"ok": True, "styles": rows, "count": len(rows), "backend": backend.name}


@cad_tool(
    summary="Plot every sheet (or the named ones) to PDF; each sheet size is read back from its PDF.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Batch Plot", "destructiveHint": False},
    tags={"layout", "plot", "export"},
)
async def batch_plot(
    output_dir: Annotated[str, "Folder for the PDFs (created if missing)."],
    layouts: Annotated[
        list[str] | None,
        "Layouts to plot; omit for every paper-space layout. 'Model' only when named.",
    ] = None,
    format: Annotated[str, "pdf (the only format this release plots)."] = "pdf",
    file_pattern: Annotated[
        str, "File name per sheet; {drawing} and {layout} are substituted."
    ] = "{drawing}-{layout}.pdf",
    ctx: Context = None,
) -> dict:
    """AutoCAD's PUBLISH for a PDF set, through `drawing_export_pdf(layout=…)`.

    One row per sheet: `path`, `bytes`, and `mediabox_mm` **parsed from the
    PDF that was written** (`/MediaBox`, points → mm), with `paper` naming the
    catalogue size it matches within 0.5 mm — so the sheet size is verified,
    never assumed. Model space is included only when named.

    Refused before any file is written: a format other than pdf, an empty
    `layouts` list, a layout that does not exist, a `file_pattern` containing a
    path separator or lacking `{layout}` when more than one sheet is plotted,
    and any output path outside the allowed directories. A sheet whose PDF has
    no readable MediaBox is reported with `mediabox_mm: null` and an `error`.
    """
    from engineering.standards.plot import batch_plot as _batch_plot

    if str(format).lower() != "pdf":
        raise ToolError(f"batch_plot: format must be 'pdf' (got {format!r}); PDF is the only plot format")
    destination = validate_path(output_dir, allow_write=True)
    await ctx.info(f"Batch plotting {layouts or 'every sheet'} to {destination}")
    try:
        return await _batch_plot(_backend(ctx), layouts, str(destination), file_pattern)
    except ValueError as exc:
        raise ToolError(str(exc)) from exc
  • Step 6: Aliases

In discovery/aliases.py, extend the comment block above SHARED_ACAD_COMMANDS and the set:

#   PAGESETUP -- the Page Setup Manager both shows and edits a sheet's setup
#       (page_setup_list vs page_setup_apply).
#   PLOT -- one sheet to PDF (drawing_export_pdf) or every sheet in one go
#       (batch_plot); a drafter typing it could mean either.
        "PAGESETUP",
        "PLINE",
        "PLOT",

Add the block at the end of TOOL_ALIASES (before the closing }), alphabetical within it:

    # ── Page setup & templates (track E) ────────────────────────────────────
    "batch_plot": ToolAliases(
        acad=("PUBLISH", "PLOT"),
        synonyms=(
            "print all sheets to pdf",
            "plot every layout",
            "publish the sheet set",
            "batch print",
            "one pdf per sheet",
            "plot the whole drawing set",
        ),
    ),
    "page_setup_apply": ToolAliases(
        acad=("PAGESETUP",),
        synonyms=(
            "page setup",
            "set the paper size",
            "a3 landscape",
            "plot scale",
            "monochrome ctb",
            "which printer",
            "set up the sheet for printing",
        ),
    ),
    "page_setup_list": ToolAliases(
        acad=("PAGESETUP",),
        synonyms=(
            "what paper is this sheet",
            "page setup of each layout",
            "plot settings",
            "sheet size and orientation",
            "which ctb is set",
        ),
    ),
    "plot_style_list": ToolAliases(
        acad=("STYLESMANAGER",),
        synonyms=(
            "ctb files",
            "plot style table",
            "pen assignments",
            "monochrome or grayscale",
            "which plot styles are installed",
        ),
    ),
  • Step 7: Inventory, README, registry snapshot

Run uv run --frozen python scripts/generate_tool_inventory.py. In README.md: snapshot line 166 tools → 170 tools; the <sub>166 tools in 20 groups line → 170 tools in 20 groups; the "Paper space" row becomes:

| Paper space | tab lifecycle, viewports, `entity_change_space` (CHSPACE), `page_setup_apply` (ISO 216 / ANSI Y14.1 paper, ctb, scale, device — on both engines), `batch_plot` with every sheet size read back from its PDF's `/MediaBox`, `drawing_export_pdf(layout=…)` |

In tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged: "layouts": 12 → 16, assert sum(sizes.values()) == 166 → 170, and add to the docstring: "layouts moved 12 -> 16 when v1.6's SECTION 19 opened with page_setup_list / page_setup_apply / plot_style_list / batch_plot — all tagged layout, which _GROUP_TAG_PRIORITY ranks above query/export, so they file with the sheets they set up." Update the docstring's headline (161 tools, 20 groups) figure the same way the earlier bumps did (170 tools, 20 groups).

  • Step 8: Run the gates

Run: uv run --frozen pytest tests/test_batch_plot.py tests/test_page_setup.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v Expected: all PASS (test_discovery_transform is included because PLOT now routes to two tools; no golden case expects drawing_export_pdf, so the accuracy bars hold — the ≥ 10 new golden queries for PAGESETUP / PLOT / PUBLISH / SAVEAS are the final task's). (If test_document_numbers_match_the_tree fails only on the README collected tests figure, that drift predates this track and is the final task's; every other check must pass.)

  • Step 9: Commit
git add engineering/standards/plot.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_batch_plot.py
git commit -m "feat(pagesetup): SECTION 19 — page_setup_list/apply, plot_style_list, batch_plot with /MediaBox read-back"

Task 19: Five bundled templates built by the server's own tools, drawing_new(template=name), drawing_template_list

Files:

  • Create: engineering/standards/templates.py, scripts/build_templates.py, templates/iso_a3_mech.dxf, templates/iso_a1_arch.dxf, templates/iso_a3_pid.dxf, templates/ansi_b_mech.dxf, templates/ansi_d_arch.dxf (built, then committed)
  • Modify: server.py — drawing_new (template resolution + template: in the result), SECTION 19 header (4 tools) → (5 tools), new tool drawing_template_list after batch_plot
  • Modify: discovery/aliases.py — drawing_template_list record in the track-E block; drawing_new gains one synonym
  • Modify: pyproject.toml (only-include += "templates"), Dockerfile (COPY templates/ ./templates/), .gitattributes (templates/*.dxf text eol=crlf, templates/*.dwt binary) — the wheel/Docker parity gate (test_dockerfile_copies_every_wheel_include) requires both edits together; no other group touches these files
  • Modify: README.md snapshot 170 tools → 171 tools, <sub>… 171 tools in 20 groups, a "Templates" row; regenerate docs/tool-inventory.json
  • Modify: tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged — templates: 2 → 3, total 170 → 171
  • Test: tests/test_templates.py

Interfaces:

  • Produces (engineering/standards/templates.py): TEMPLATES_DIR: Path, TemplateSpec (frozen dataclass: name, standard, sheet, paper, orientation, layout, layer_set, dimstyle_preset, dimstyle, textstyle, scale, title_block, settings, description), TEMPLATE_CATALOG: dict[str, TemplateSpec] (five rows, spec §6), template_rows() -> list[dict] (catalogue rows + files: {dxf, dwt} with present flags), resolve_template(name_or_path: str, engine: str) -> tuple[str, str] ((path, "bundled"|"bundled_dxf"|"path"), ValueError naming the catalogue on an unknown name or a missing bundled file).

  • Produces (scripts/build_templates.py): build_template(spec, out_dir) -> dict, build_all(out_dir) -> list[dict], normalised_lines(path) -> list[str], drifted_names(rebuilt_dir) -> list[str] (the masked compare), check_templates() -> list[str] (CLI: temp rebuild + drifted_names), CLI --check, --out DIR.

  • Produces (MCP): drawing_new(template=<name|path>) returns template: {name|path, path, source}; tool drawing_template_list().

  • Pack core; lean: no (drawing_new already is; the list is a lookup).

  • Consumes: apply_layer_set, apply_iso_a3_titleblock/TitleBlockMetadata, resolve_page_setup, backend.page_setup_apply/layout_rename/layout_set_current/drawing_settings/system_set_variable/drawing_save_as; group S's dimstyle_create/textstyle_create + engineering.standards.dimstyles.resolve_dimstyle / textstyles.TEXT_PRESETS when present (after the S merge), else header DIM variables.

  • Step 1: Write the failing tests

# tests/test_templates.py
"""Bundled templates: catalogue, resolution, reproducible build, drawing_new(template=)."""

from __future__ import annotations

import importlib.util
from pathlib import Path

import pytest
from fastmcp.exceptions import ToolError

import server
from engineering.standards.templates import (
    TEMPLATE_CATALOG,
    TEMPLATES_DIR,
    resolve_template,
    template_rows,
)

pytestmark = pytest.mark.asyncio

ROOT = Path(__file__).resolve().parents[1]
NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch")


def _build_module():
    spec = importlib.util.spec_from_file_location(
        "build_templates", ROOT / "scripts" / "build_templates.py"
    )
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    return module


class _Ctx:
    def __init__(self, backend):
        self.lifespan_context = {"backend": backend}

    async def info(self, message):
        pass

    async def warning(self, message):
        pass

    async def report_progress(self, progress, total):
        pass


# ── catalogue ───────────────────────────────────────────────────────────────


def test_catalog_has_the_five_spec_rows():
    assert tuple(TEMPLATE_CATALOG) == NAMES
    rows = {spec.name: spec for spec in TEMPLATE_CATALOG.values()}
    assert (rows["iso_a3_mech"].paper, rows["iso_a3_mech"].layer_set) == ("ISO_A3", "mech")
    assert (rows["iso_a1_arch"].paper, rows["iso_a1_arch"].layer_set) == ("ISO_A1", "iso13567")
    assert rows["iso_a1_arch"].settings["annotation_scale"] == "1:50"
    assert (rows["iso_a3_pid"].paper, rows["iso_a3_pid"].layer_set) == ("ISO_A3", "pid")
    assert (rows["ansi_b_mech"].paper, rows["ansi_b_mech"].dimstyle) == ("ANSI_B", "ANSI")
    assert (rows["ansi_d_arch"].paper, rows["ansi_d_arch"].scale) == ("ANSI_D", "fit")
    assert [spec.title_block for spec in TEMPLATE_CATALOG.values()] == [True, False, True, False, False]
    assert all(spec.orientation == "landscape" for spec in TEMPLATE_CATALOG.values())
    assert {spec.textstyle for spec in TEMPLATE_CATALOG.values()} == {"ISOCP", "ROMANS"}


def test_template_rows_report_files():
    rows = template_rows()
    assert [row["name"] for row in rows] == list(NAMES)
    for row in rows:
        assert set(row["files"]) == {"dxf", "dwt"}
        assert row["files"]["dxf"]["path"].endswith(f"{row['name']}.dxf")
        assert isinstance(row["files"]["dxf"]["present"], bool)


def test_resolve_template_bundled_path_and_unknown(tmp_path):
    path, source = resolve_template("iso_a3_mech", "ezdxf")
    assert source == "bundled" and path == str(TEMPLATES_DIR / "iso_a3_mech.dxf")
    path, source = resolve_template("ISO_A3_MECH", "ezdxf")
    assert source == "bundled"
    # COM prefers the .dwt twin and says when it fell back to the DXF
    path, source = resolve_template("iso_a3_mech", "com")
    assert source in ("bundled", "bundled_dxf")
    assert path.endswith(".dwt" if source == "bundled" else ".dxf")
    explicit = tmp_path / "mine.dwt"
    assert resolve_template(str(explicit), "ezdxf") == (str(explicit), "path")
    assert resolve_template("sheet.dxf", "com") == ("sheet.dxf", "path")
    with pytest.raises(ValueError, match="iso_a3_mech, iso_a1_arch, iso_a3_pid, ansi_b_mech, ansi_d_arch"):
        resolve_template("iso_a2_mech", "ezdxf")
    with pytest.raises(ValueError, match="template"):
        resolve_template("", "ezdxf")


# ── the build ───────────────────────────────────────────────────────────────


async def test_build_is_deterministic_run_to_run(tmp_path):
    build = _build_module()
    first = await build.build_all(tmp_path / "one")
    second = await build.build_all(tmp_path / "two")
    assert [row["name"] for row in first] == list(NAMES)
    for name in NAMES:
        a = build.normalised_lines(tmp_path / "one" / f"{name}.dxf")
        b = build.normalised_lines(tmp_path / "two" / f"{name}.dxf")
        assert a == b, f"{name} differs between two builds"
    assert all(row["page_setup"]["ok"] for row in first)
    assert all(row["layers"] for row in first)


async def test_committed_templates_match_a_fresh_build(tmp_path):
    """The spec's reproducibility claim — the same comparison the script's --check runs."""
    build = _build_module()
    missing = [name for name in NAMES if not (TEMPLATES_DIR / f"{name}.dxf").is_file()]
    assert not missing, f"bundled templates missing: {missing} — run scripts/build_templates.py"
    await build.build_all(tmp_path)
    drifted = build.drifted_names(tmp_path)
    assert drifted == [], f"templates drifted from the script: {drifted}"


# ── drawing_new(template=) ──────────────────────────────────────────────────


async def test_drawing_new_from_a_bundled_template(backend):
    result = await server.drawing_new(template="iso_a3_mech", ctx=_Ctx(backend))
    assert result["ok"] is True
    assert result["template"] == {
        "name": "iso_a3_mech",
        "path": str(TEMPLATES_DIR / "iso_a3_mech.dxf"),
        "source": "bundled",
    }
    layers = {layer.name for layer in await backend.layer_list()}
    assert {"GEOMETRY", "DIM", "HIDDEN", "CENTER", "TITLEBLOCK"} <= layers
    row = (await backend.page_setup_list("A3"))[0]
    assert row["paper"] == "ISO_A3" and row["orientation"] == "landscape"
    assert row["plot_style"] == "monochrome.ctb" and row["scale"] == "1:1"
    sheet = backend._doc.layouts.get("A3")
    assert any(e.dxftype() == "LWPOLYLINE" for e in sheet), "the ISO 5457 frame is on the sheet"
    assert float(await backend.system_get_variable("DIMTXT")) == 2.5
    if hasattr(backend, "dimstyle_create"):  # after group S merges
        assert await backend.system_get_variable("DIMSTYLE") == "ISO-25"


async def test_drawing_new_from_the_pid_and_ansi_templates(backend):
    await server.drawing_new(template="iso_a3_pid", ctx=_Ctx(backend))
    assert "PROCESS-PIPING-MAIN" in {layer.name for layer in await backend.layer_list()}
    await server.drawing_new(template="ansi_d_arch", ctx=_Ctx(backend))
    assert "M-GEOMET-E-N" in {layer.name for layer in await backend.layer_list()}
    row = (await backend.page_setup_list("D"))[0]
    assert row["paper"] == "ANSI_D" and row["size_mm"] == [864.0, 559.0] and row["scale"] == "fit"
    assert (await backend.drawing_settings())["settings"]["decimal_separator"] == "."


async def test_drawing_new_unknown_template_is_refused_with_the_names(backend):
    await backend.entity_create_line(0, 0, 1, 1)
    before = (await backend.drawing_info()).entity_count
    with pytest.raises(ToolError, match="iso_a3_mech"):
        await server.drawing_new(template="iso_a2_mech", ctx=_Ctx(backend))
    assert (await backend.drawing_info()).entity_count == before, "the refusal replaced nothing"


async def test_drawing_new_missing_template_path_is_refused(backend, tmp_path):
    with pytest.raises(ToolError, match="not found"):
        await server.drawing_new(template=str(tmp_path / "gone.dxf"), ctx=_Ctx(backend))


async def test_drawing_new_from_an_explicit_dxf_path_reports_source_path(backend, tmp_path):
    await backend.layer_create("MINE", color=3)
    saved = tmp_path / "mine.dxf"
    await backend.drawing_save_as(str(saved), "dxf")
    result = await server.drawing_new(template=str(saved), ctx=_Ctx(backend))
    assert result["template"]["source"] == "path" and result["template"]["path"] == str(saved.resolve())
    assert "MINE" in {layer.name for layer in await backend.layer_list()}


async def test_drawing_template_list_tool(backend):
    result = await server.drawing_template_list(ctx=_Ctx(backend))
    assert result["ok"] and result["count"] == 5
    assert [row["name"] for row in result["templates"]] == list(NAMES)
    assert result["engine"] == "ezdxf"
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_templates.py -v Expected: collection FAILS with ModuleNotFoundError: No module named 'engineering.standards.templates'.

  • Step 3: The catalogue — engineering/standards/templates.py
# engineering/standards/templates.py
"""The five bundled drawing templates: what each contains, where its files live.

Built by ``scripts/build_templates.py`` through the server's own backend
(layers, styles, settings, the ISO 5457 A3 frame where the sheet is A3, page
setup) and saved as DXF; the ``.dwt`` twins are produced on a live AutoCAD by
``scripts/smoke_settings_com.py --build-dwt`` and committed once.
"""

from __future__ import annotations

from dataclasses import asdict, dataclass, field
from pathlib import Path

__all__ = ["TEMPLATES_DIR", "TEMPLATE_CATALOG", "TemplateSpec", "resolve_template", "template_rows"]

#: ``<repo>/templates`` — shipped in the wheel (pyproject ``only-include``) and the Docker image.
TEMPLATES_DIR = Path(__file__).resolve().parents[2] / "templates"

_ISO_SETTINGS = {
    "units": "mm",
    "ltscale": 1.0,
    "dimscale": 1.0,
    "linear_precision": 2,
    "angular_precision": 0,
    "decimal_separator": ",",
}
_ANSI_SETTINGS = {**_ISO_SETTINGS, "decimal_separator": "."}


@dataclass(frozen=True)
class TemplateSpec:
    name: str
    standard: str  # "ISO" | "ANSI"
    sheet: str  # human label, e.g. "A3 landscape"
    paper: str  # engineering.standards.papers.PAPER_SIZES key
    orientation: str
    layout: str  # the paper-space tab's name
    layer_set: str  # engineering.layers.LAYER_SET_REGISTRY key
    dimstyle_preset: str  # group S preset id: "iso-25" | "ansi"
    dimstyle: str  # style name in the drawing
    textstyle: str  # "ISOCP" | "ROMANS"
    scale: str  # page setup scale label
    title_block: bool
    settings: dict = field(default_factory=dict)  # drawing_settings facade keys
    description: str = ""


TEMPLATE_CATALOG: dict[str, TemplateSpec] = {
    spec.name: spec
    for spec in (
        TemplateSpec(
            name="iso_a3_mech",
            standard="ISO",
            sheet="A3 landscape",
            paper="ISO_A3",
            orientation="landscape",
            layout="A3",
            layer_set="mech",
            dimstyle_preset="iso-25",
            dimstyle="ISO-25",
            textstyle="ISOCP",
            scale="1:1",
            title_block=True,
            settings=dict(_ISO_SETTINGS),
            description="ISO A3 mechanical part drawing",
        ),
        TemplateSpec(
            name="iso_a1_arch",
            standard="ISO",
            sheet="A1 landscape",
            paper="ISO_A1",
            orientation="landscape",
            layout="A1",
            layer_set="iso13567",
            dimstyle_preset="iso-25",
            dimstyle="ISO-25",
            textstyle="ISOCP",
            scale="fit",
            title_block=False,
            settings={**_ISO_SETTINGS, "annotation_scale": "1:50"},
            description="ISO A1 architectural sheet, ISO 13567 layers, 1:50 annotation",
        ),
        TemplateSpec(
            name="iso_a3_pid",
            standard="ISO",
            sheet="A3 landscape",
            paper="ISO_A3",
            orientation="landscape",
            layout="A3",
            layer_set="pid",
            dimstyle_preset="iso-25",
            dimstyle="ISO-25",
            textstyle="ISOCP",
            scale="1:1",
            title_block=True,
            settings=dict(_ISO_SETTINGS),
            description="ISO A3 P&ID sheet",
        ),
        TemplateSpec(
            name="ansi_b_mech",
            standard="ANSI",
            sheet="ANSI B landscape",
            paper="ANSI_B",
            orientation="landscape",
            layout="B",
            layer_set="mech",
            dimstyle_preset="ansi",
            dimstyle="ANSI",
            textstyle="ROMANS",
            scale="1:1",
            title_block=False,
            settings=dict(_ANSI_SETTINGS),
            description="ANSI B mechanical part drawing (metric)",
        ),
        TemplateSpec(
            name="ansi_d_arch",
            standard="ANSI",
            sheet="ANSI D landscape",
            paper="ANSI_D",
            orientation="landscape",
            layout="D",
            layer_set="iso13567",
            dimstyle_preset="ansi",
            dimstyle="ANSI",
            textstyle="ROMANS",
            scale="fit",
            title_block=False,
            settings=dict(_ANSI_SETTINGS),
            description="ANSI D architectural sheet (metric)",
        ),
    )
}


def _file(name: str, suffix: str) -> dict:
    path = TEMPLATES_DIR / f"{name}{suffix}"
    return {"path": str(path), "present": path.is_file()}


def template_rows() -> list[dict]:
    """Catalogue rows for ``drawing_template_list``: the spec plus its files."""
    rows = []
    for spec in TEMPLATE_CATALOG.values():
        row = asdict(spec)
        row["files"] = {"dxf": _file(spec.name, ".dxf"), "dwt": _file(spec.name, ".dwt")}
        rows.append(row)
    return rows


def resolve_template(name_or_path: str, engine: str) -> tuple[str, str]:
    """``(path, source)`` for a catalogue name or a template file path.

    A catalogue name resolves to ``templates/<name>.dxf`` (headless) or
    ``templates/<name>.dwt`` (live), falling back to the DXF with
    ``source="bundled_dxf"`` when the ``.dwt`` twin is missing. Anything with a
    path separator or a ``.dwt``/``.dxf`` suffix is a path (``source="path"``,
    not checked here). A bare word that is not in the catalogue is refused
    with the catalogue names.
    """
    raw = str(name_or_path or "").strip()
    if not raw:
        raise ValueError("template: pass a bundled template name or a path to a .dwt/.dxf file")
    key = raw.lower()
    if key in TEMPLATE_CATALOG:
        dxf = TEMPLATES_DIR / f"{key}.dxf"
        dwt = TEMPLATES_DIR / f"{key}.dwt"
        if engine == "com" and dwt.is_file():
            return str(dwt), "bundled"
        if dxf.is_file():
            return str(dxf), "bundled" if engine != "com" else "bundled_dxf"
        raise ValueError(
            f"template {key!r} is in the catalogue but {dxf} is missing — run "
            "`uv run --frozen python scripts/build_templates.py`"
        )
    looks_like_path = ("/" in raw or "\\" in raw) or Path(raw).suffix.lower() in (".dwt", ".dxf")
    if looks_like_path:
        return raw, "path"
    raise ValueError(
        f"unknown template {raw!r}. Bundled templates: {', '.join(TEMPLATE_CATALOG)}; "
        "or pass a path to a .dwt/.dxf file"
    )
  • Step 4: The build script — scripts/build_templates.py
# scripts/build_templates.py
"""Build the bundled templates through the server's own backend, reproducibly.

    uv run --frozen python scripts/build_templates.py            # write templates/*.dxf
    uv run --frozen python scripts/build_templates.py --check    # exit 1 if a rebuild differs
    uv run --frozen python scripts/build_templates.py --out DIR  # build elsewhere

Each template is: drawing_new -> apply_layer_set -> styles -> drawing_settings
-> layout renamed to the sheet -> ISO 5457 frame (A3 sheets) -> page_setup_apply
-> drawing_save_as DXF. Nothing is hand-authored, so what the tools produce is
what ships.

Styles: once group S's ``dimstyle_create`` / ``textstyle_create`` are on the
backend they are used (ISO-25 / ISOCP, ANSI / ROMANS, set current). Until then
the same values (spec §4.1) are written as header DIM variables so the template
already dimensions correctly; the final task re-runs this script after the S
merge and re-commits the five files.

``--check`` rebuilds into a temporary folder and compares line by line after
masking the values ezdxf cannot keep constant — measured on 2026-09-16, exactly
six lines differ between two builds: $TDCREATE, $TDUPDATE, $FINGERPRINTGUID,
$VERSIONGUID and the two EZDXF_META DICTIONARYVAR stamps.
"""

from __future__ import annotations

import argparse
import asyncio
import re
import sys
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
if str(ROOT) not in sys.path:
    sys.path.insert(0, str(ROOT))

from backends.ezdxf_backend import EzdxfBackend  # noqa: E402
from engineering import TitleBlockMetadata, apply_iso_a3_titleblock  # noqa: E402
from engineering.layers import apply_layer_set  # noqa: E402
from engineering.standards.papers import resolve_page_setup  # noqa: E402
from engineering.standards.templates import (  # noqa: E402
    TEMPLATE_CATALOG,
    TEMPLATES_DIR,
    TemplateSpec,
)

#: Header variables whose values change on every save.
VOLATILE_HEADER_VARS = frozenset(
    {
        "$TDCREATE",
        "$TDUPDATE",
        "$TDUCREATE",
        "$TDUUPDATE",
        "$TDINDWG",
        "$TDUSRTIMER",
        "$FINGERPRINTGUID",
        "$VERSIONGUID",
    }
)
#: The EZDXF_META stamps: ``1.4.4 @ 2026-09-16T12:33:38.192529+00:00``.
_EZDXF_STAMP = re.compile(r"^\d+\.\d+\.\d+\S* @ \d{4}-\d{2}-\d{2}T")

#: Spec §4.1 as header variables — the fallback until dimstyle_create lands.
#: DIMDSEP goes through the facade (decimal_separator); DIMTXSTY is omitted
#: because the text style it names is created by textstyle_create.
_FALLBACK_DIMVARS: dict[str, dict[str, float | int]] = {
    "iso-25": {
        "DIMTXT": 2.5,
        "DIMASZ": 2.5,
        "DIMEXE": 1.25,
        "DIMEXO": 0.625,
        "DIMGAP": 0.625,
        "DIMTAD": 1,
        "DIMTIH": 0,
        "DIMTOH": 0,
        "DIMDEC": 2,
        "DIMLUNIT": 2,
        "DIMZIN": 8,
        "DIMLWD": -2,
        "DIMLWE": -2,
        "DIMSCALE": 1.0,
    },
    "ansi": {
        "DIMTXT": 3.0,
        "DIMASZ": 3.0,
        "DIMEXE": 1.5,
        "DIMEXO": 1.5,
        "DIMGAP": 1.0,
        "DIMTAD": 0,
        "DIMTIH": 1,
        "DIMTOH": 1,
        "DIMDEC": 2,
        "DIMLUNIT": 2,
        "DIMZIN": 8,
        "DIMLWD": -2,
        "DIMLWE": -2,
        "DIMSCALE": 1.0,
    },
}


async def _apply_styles(backend, spec: TemplateSpec) -> dict:
    if hasattr(backend, "dimstyle_create") and hasattr(backend, "textstyle_create"):
        from engineering.standards.dimstyles import resolve_dimstyle
        from engineering.standards.textstyles import TEXT_PRESETS

        font, width_factor, oblique = TEXT_PRESETS[spec.textstyle]
        text = await backend.textstyle_create(
            spec.textstyle, font, 0.0, width_factor, oblique, set_current=True
        )
        dim = await backend.dimstyle_create(
            spec.dimstyle, resolve_dimstyle(spec.dimstyle_preset, None), set_current=True
        )
        return {"mode": "styles", "dimstyle": dim["name"], "textstyle": text["name"]}
    applied = {}
    for variable, value in _FALLBACK_DIMVARS[spec.dimstyle_preset].items():
        applied[variable] = (await backend.system_set_variable(variable, value))["ok"]
    return {
        "mode": "header_dimvars",
        "applied": applied,
        "pending": "dimstyle_create/textstyle_create not on this backend yet (group S)",
    }


async def build_template(spec: TemplateSpec, out_dir: Path) -> dict:
    """Build one template into ``out_dir/<name>.dxf`` and report what went in."""
    out_dir = Path(out_dir)
    out_dir.mkdir(parents=True, exist_ok=True)
    backend = EzdxfBackend()
    await backend.connect()
    try:
        await backend.drawing_new()
        layers = await apply_layer_set(backend, spec.layer_set)
        styles = await _apply_styles(backend, spec)
        settings = await backend.drawing_settings(dict(spec.settings))
        pending = sorted((settings.get("errors") or {}))  # keys the facade does not know yet
        renamed = await backend.layout_rename("Layout1", spec.layout)
        if not renamed.get("ok"):
            raise RuntimeError(f"{spec.name}: layout rename failed: {renamed}")
        title_block = None
        if spec.title_block:
            title_block = await apply_iso_a3_titleblock(
                backend,
                metadata=TitleBlockMetadata(
                    title=spec.description.upper(),
                    drawing_no="TEMPLATE",
                    scale=spec.scale if spec.scale != "fit" else "1:1",
                ),
                layout=spec.layout,
            )
        setup = resolve_page_setup(
            spec.paper,
            spec.orientation,
            "monochrome.ctb",
            spec.scale,
            "layout",
            "DWG To PDF.pc3",
            [0.0, 0.0, 0.0, 0.0],  # the frame is drawn at the paper corner; a PDF has no unprintable band
            True,
        )
        page_setup = await backend.page_setup_apply(spec.layout, setup)
        await backend.layout_set_current("Model")
        path = out_dir / f"{spec.name}.dxf"
        await backend.drawing_save_as(str(path), "dxf")
        return {
            "name": spec.name,
            "path": str(path),
            "layers": layers,
            "styles": styles,
            "settings_applied": settings.get("applied", {}),
            "settings_pending": pending,
            "title_block": bool(title_block),
            "page_setup": {"ok": page_setup["ok"], "paper": page_setup["applied"]["paper"]},
        }
    finally:
        await backend.disconnect()


async def build_all(out_dir: Path = TEMPLATES_DIR) -> list[dict]:
    return [await build_template(spec, out_dir) for spec in TEMPLATE_CATALOG.values()]


def normalised_lines(path: Path) -> list[str]:
    """The DXF's lines with every value that legitimately changes per save masked."""
    lines = Path(path).read_text(encoding="utf-8").splitlines()
    out: list[str] = []
    mask_at = -1
    for index, line in enumerate(lines):
        if index == mask_at:
            out.append("<volatile>")
            continue
        stripped = line.strip()
        if stripped in VOLATILE_HEADER_VARS:
            mask_at = index + 2  # "  9" / "$TDCREATE" / " 40" / <value>
        if _EZDXF_STAMP.match(stripped):
            out.append("<ezdxf-stamp>")
            continue
        out.append(line)
    return out


def drifted_names(rebuilt: Path) -> list[str]:
    """Names whose committed DXF differs from the build in ``rebuilt`` (masked compare)."""
    drifted = []
    for name in TEMPLATE_CATALOG:
        committed = TEMPLATES_DIR / f"{name}.dxf"
        if not committed.is_file():
            drifted.append(f"{name} (missing)")
            continue
        fresh = normalised_lines(Path(rebuilt) / f"{name}.dxf")
        current = normalised_lines(committed)
        if fresh != current:
            first = next(
                (i for i, (a, b) in enumerate(zip(fresh, current)) if a != b),
                min(len(fresh), len(current)),
            )
            drifted.append(f"{name} (first difference at line {first + 1})")
    return drifted


def check_templates() -> list[str]:
    """CLI ``--check``: rebuild into a temporary folder and report drift."""
    with tempfile.TemporaryDirectory() as folder:
        asyncio.run(build_all(Path(folder)))
        return drifted_names(Path(folder))


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--check", action="store_true", help="rebuild to a temp dir and diff")
    parser.add_argument("--out", type=Path, default=TEMPLATES_DIR, help="output folder")
    args = parser.parse_args(argv)
    if args.check:
        drifted = check_templates()
        if drifted:
            print("templates drifted from scripts/build_templates.py: " + ", ".join(drifted))
            return 1
        print(f"{len(TEMPLATE_CATALOG)} templates reproduce byte-for-byte (volatile stamps masked)")
        return 0
    for row in asyncio.run(build_all(args.out)):
        pending = f" pending={row['settings_pending']}" if row["settings_pending"] else ""
        print(f"{row['name']}: {row['path']} styles={row['styles']['mode']}{pending}")
    return 0


if __name__ == "__main__":
    sys.exit(main())
  • Step 5: drawing_new(template=) and drawing_template_list in server.py

Replace the drawing_new tool:

@cad_tool(
    summary="Start a drawing: blank with the engineering layers, or from a bundled/own template.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "New Drawing", "destructiveHint": False},
    tags={"drawing"},
)
async def drawing_new(
    template: Annotated[
        str | None,
        "Bundled template name (drawing_template_list: iso_a3_mech, iso_a1_arch, iso_a3_pid, "
        "ansi_b_mech, ansi_d_arch) or a path to a .dwt/.dxf template file.",
    ] = None,
    bootstrap: Annotated[
        bool,
        Field(
            description="Auto-load CENTER/HIDDEN/PHANTOM linetypes and create standard "
            "engineering layers (GEOMETRY, DIM, CENTER, HIDDEN, PHANTOM, "
            "HATCH, TEXT, TITLEBLOCK). Disable for vanilla DXF.",
        ),
    ] = True,
    ctx: Context = None,
) -> dict:
    """Create a new drawing, optionally from a template.

    A bundled name resolves to `templates/<name>.dxf` headlessly and
    `templates/<name>.dwt` on the live engine (falling back to the DXF with
    `source: "bundled_dxf"` when no `.dwt` twin is committed); a path is used
    as given. The result carries `template: {name|path, path, source}`.

    Refused before anything is replaced: a bare name that is not in the
    catalogue (the message lists the five), a template path that does not
    exist, and a path outside the allowed directories. With `bootstrap=True`
    (default) the standard engineering linetypes and layers are ensured
    afterwards — idempotent on a template that already has them.
    """
    from pathlib import Path as _P

    from engineering.standards.templates import resolve_template

    backend = _backend(ctx)
    template_info: dict | None = None
    if template is not None:
        try:
            resolved, source = resolve_template(template, backend.name)
        except ValueError as exc:
            raise ToolError(f"drawing_new: {exc}") from exc
        if source == "path":
            resolved = str(validate_path(resolved, allow_write=False))
            if not _P(resolved).is_file():
                raise ToolError(f"drawing_new: template file not found: {resolved}")
            template_info = {"path": resolved, "source": source}
        else:
            template_info = {"name": template.strip().lower(), "path": resolved, "source": source}
        template = resolved
    await ctx.info(f"Creating new drawing (template={template}, bootstrap={bootstrap})")
    raw = await backend.drawing_new(template)
    result = dict(raw) if isinstance(raw, dict) else {"result": raw}
    if template_info is not None:
        result["template"] = template_info
    if bootstrap:
        try:
            from engineering import (
                ensure_engineering_layers,
                ensure_standard_linetypes,
            )

            lt_status = await ensure_standard_linetypes(backend)
            layer_status = await ensure_engineering_layers(backend)
            result["bootstrap"] = {"ok": True, "linetypes": lt_status, "layers": layer_status}
        except Exception as exc:
            await ctx.warning(f"Engineering bootstrap failed: {exc}")
            result["bootstrap"] = {"ok": False, "error": str(exc)}
            result["status"] = "degraded"
    return result

(The bundled file is the server's own, read-only; it is deliberately not passed through validate_path, whose ALLOWED_PATHS fence is for the caller's files — a deployment that fences paths to a project folder must still be able to start from a bundled template.)

Change the SECTION 19 header to (5 tools) and add after batch_plot:

@cad_tool(
    summary="List the five bundled drawing templates: standard, sheet, layers, styles, page setup.",
    cost="read",
)
@mcp.tool(
    annotations={"title": "List Drawing Templates", "readOnlyHint": True},
    tags={"template", "drawing", "query"},
)
async def drawing_template_list(ctx: Context = None) -> dict:
    """The templates `drawing_new(template=<name>)` can start from.

    Each row: `standard` (ISO/ANSI), `sheet`, `paper`, `layout` (the sheet tab's
    name), `layer_set` (mech / pid / iso13567), `dimstyle` / `textstyle`
    (ISO-25 / ISOCP or ANSI / ROMANS), `scale`, `title_block` (the ISO 5457 A3
    frame, A3 sheets only), the `settings` applied, and `files.dxf` /
    `files.dwt` with `present` flags — the `.dwt` twins exist only once built
    on a live AutoCAD (`scripts/smoke_settings_com.py --build-dwt`). Never
    refuses; it reads a catalogue.
    """
    from engineering.standards.templates import TEMPLATES_DIR, template_rows

    rows = template_rows()
    return {
        "ok": True,
        "templates": rows,
        "count": len(rows),
        "templates_dir": str(TEMPLATES_DIR),
        "engine": _backend(ctx).name,
    }
  • Step 6: Aliases, packaging, README, snapshot

discovery/aliases.py, in the track-E block (alphabetical):

    "drawing_template_list": ToolAliases(
        acad=(),
        synonyms=(
            "which templates are there",
            "bundled templates",
            "iso a3 template",
            "ansi b sheet",
            "start from a standard sheet",
            "dwt list",
        ),
    ),

and extend drawing_new's synonyms with "start from a bundled template".

pyproject.toml: append "templates", to only-include. Dockerfile: add COPY templates/ ./templates/ after the discovery/ line. .gitattributes: append

templates/*.dxf text eol=crlf
templates/*.dwt binary

(ezdxf writes CRLF; pinning the checkout to CRLF keeps the committed bytes equal to a fresh build on every platform, and normalised_lines compares lines anyway.)

Build and commit the five files: uv run --frozen python scripts/build_templates.py then uv run --frozen python scripts/build_templates.py --check (expected: 5 templates reproduce byte-for-byte (volatile stamps masked)).

Run uv run --frozen python scripts/generate_tool_inventory.py. README.md: snapshot 170 tools → 171 tools; <sub>170 tools in 20 groups → 171 tools in 20 groups; add after the "Paper space" row:

| Templates | five bundled templates built by the server's own tools and pinned reproducible (`iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch`) — `drawing_new(template="iso_a3_mech")`, `drawing_template_list` |

tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged: "templates": 2 → 3, total 170 → 171, docstring: "templates moved 2 -> 3 when v1.6's drawing_template_list joined SECTION 19 (tagged template, which ranks above drawing)."

  • Step 7: Run the gates

Run: uv run --frozen pytest tests/test_templates.py tests/test_page_setup.py tests/test_batch_plot.py tests/test_drawing.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v Expected: all PASS (test_dockerfile_copies_every_wheel_include now sees templates on both sides).

  • Step 8: Commit
git add engineering/standards/templates.py scripts/build_templates.py templates/*.dxf server.py discovery/aliases.py pyproject.toml Dockerfile .gitattributes README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_templates.py
git commit -m "feat(templates): five bundled templates built by the server's own tools; drawing_new(template=name)"

Task 20: drawing_template_save on both engines, dwt_write capability, templates/README.md

Files:

  • Modify: backends/ezdxf_backend.py (drawing_template_save in the # ── page setup (track E) ── block replaces the Task 17 stub; a _DWT_WRITE_REFUSAL message next to _DWG_WRITE_REFUSAL)
  • Modify: backends/com_backend.py (drawing_template_save replaces the stub; _AC_SAVEAS_TEMPLATE = 66; drawing_save_as's fmt_map["dwt"] corrected 5 → 66)
  • Modify: server.py — SECTION 19 header (5 tools) → (6 tools), tool drawing_template_save after drawing_template_list
  • Modify: discovery/aliases.py — SHARED_ACAD_COMMANDS gains SAVEAS; drawing_template_save record
  • Create: templates/README.md
  • Modify: README.md snapshot 171 tools → 172 tools, <sub>… 172 tools in 20 groups, "Templates" row gains drawing_template_save; regenerate docs/tool-inventory.json
  • Modify: tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged — templates: 3 → 4, total 171 → 172
  • Test: tests/test_templates.py (append the drawing_template_save tests)

Interfaces:

  • Produces: backend.drawing_template_save(path, name=None, description=None) -> {ok, path, format, backend, name, description_written, …} (skeleton signature); MCP tool drawing_template_save(path, name=None, description=None).

  • Capability key dwt_write: ezdxf False (declared in Task 17), COM True. The headless .dwt refusal raises UnsupportedCapabilityError("dwt_write", …) naming the DXF route and the COM route; the middleware turns it into the standard {ok: false, capability: "dwt_write"} payload.

  • COM constant: AcSaveAsType.ac2018_Template = 66 (measured in the AutoCAD 2026 typelib; the spec's acTemplateDwg does not exist there).

  • Pack core; lean: no (drawing_save_as is not lean either).

  • Step 1: Write the failing tests

Add import types and from backends.base import UnsupportedCapabilityError to the import block at the top of tests/test_templates.py, then append:

# ── drawing_template_save ───────────────────────────────────────────────────


async def test_template_save_dxf_headless_round_trips(backend, tmp_path):
    await backend.layer_create("MINE", color=3)
    out = tmp_path / "mine_template.dxf"
    result = await backend.drawing_template_save(str(out), name="Mine", description="my sheet")
    assert result == {
        "ok": True,
        "path": str(out),
        "format": "dxf",
        "backend": "ezdxf",
        "name": "Mine",
        "description_written": False,
        "description_note": (
            "DXF has no template description field; it is a DWG/DWT summary property "
            "(capability 'dwgprops')"
        ),
    }
    assert out.is_file()
    await server.drawing_new(template=str(out), ctx=_Ctx(backend))
    assert "MINE" in {layer.name for layer in await backend.layer_list()}


async def test_template_save_dwt_headless_is_refused_with_the_capability(backend, tmp_path):
    out = tmp_path / "mine.dwt"
    with pytest.raises(UnsupportedCapabilityError) as excinfo:
        await backend.drawing_template_save(str(out))
    assert excinfo.value.capability == "dwt_write"
    message = str(excinfo.value)
    assert ".dxf" in message and "com" in message.lower()
    assert not out.exists()


async def test_template_save_refuses_other_suffixes(backend, tmp_path):
    with pytest.raises(ValueError, match=r"\.dxf"):
        await backend.drawing_template_save(str(tmp_path / "mine.txt"))


async def test_template_save_tool_validates_the_path(backend, tmp_path):
    with pytest.raises(ToolError):
        await server.drawing_template_save("../../outside.dxf", ctx=_Ctx(backend))
    result = await server.drawing_template_save(str(tmp_path / "t.dxf"), ctx=_Ctx(backend))
    assert result["ok"] and result["name"] == "t"


class _SaveRecorder:
    def __init__(self):
        self.calls = []
        self.SummaryInfo = types.SimpleNamespace()
        self.Name = "Drawing1.dwg"

    def SaveAs(self, path, fmt=None):
        self.calls.append(("SaveAs", path, fmt))
        self.Name = Path(path).name


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    document = _SaveRecorder()
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document


async def test_com_template_save_dwt_uses_ac2018_template(com_backend, tmp_path):
    backend, document = com_backend
    out = str(tmp_path / "mine.dwt")
    result = await backend.drawing_template_save(out, description="A3 mech")
    assert document.calls == [("SaveAs", out, 66)]
    assert document.SummaryInfo.Comments == "A3 mech"
    assert result == {
        "ok": True,
        "path": out,
        "format": "dwt",
        "backend": "com",
        "name": "mine",
        "description_written": True,
        "document_rebound": True,
    }


async def test_com_template_save_dxf_uses_the_dxf_code(com_backend, tmp_path):
    backend, document = com_backend
    out = str(tmp_path / "mine.dxf")
    result = await backend.drawing_template_save(out)
    assert document.calls == [("SaveAs", out, 61)]
    assert result["format"] == "dxf" and result["description_written"] is False


async def test_com_save_as_dwt_no_longer_writes_an_r13_dxf(com_backend, tmp_path):
    """drawing_save_as mapped 'dwt' to 5, which is AcSaveAsType.acR13_dxf."""
    backend, document = com_backend
    out = str(tmp_path / "x.dwt")
    await backend.drawing_save_as(out, "dwt")
    assert document.calls == [("SaveAs", out, 66)]


async def test_com_template_save_refuses_other_suffixes_before_saving(com_backend, tmp_path):
    backend, document = com_backend
    with pytest.raises(ValueError, match=r"\.dwt"):
        await backend.drawing_template_save(str(tmp_path / "x.dwg"))
    assert document.calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_templates.py -k "template_save or save_as_dwt" -v Expected: the headless DXF test and both COM save tests FAIL with UnsupportedCapabilityError: drawing_template_save: not implemented yet (Task 20); the headless .dwt test fails on ".dxf" in message (the stub's message names no route); the suffix tests fail with the stub's error instead of ValueError; test_com_save_as_dwt_no_longer_writes_an_r13_dxf fails with [("SaveAs", out, 5)]; test_template_save_tool_validates_the_path fails with AttributeError: module 'server' has no attribute 'drawing_template_save'.

  • Step 3: ezdxf implementation

Next to _DWG_WRITE_REFUSAL in backends/ezdxf_backend.py:

# A DWT is a DWG container with a template flag. ezdxf cannot write it, and a
# DXF under a .dwt name is exactly the mislabelled file the DWG refusal exists
# to prevent. The DXF route works headlessly end to end: drawing_new(template=)
# opens a .dxf template.
_DWT_WRITE_REFUSAL = (
    "drawing_template_save: the headless ezdxf backend cannot write DWT — a .dwt is a DWG "
    "container, and ezdxf has no DWG writer. Save the template as .dxf instead "
    "(drawing_new(template='{stem}.dxf') opens it headlessly), or switch to the live COM "
    "backend (AUTOCAD_MCP_BACKEND=com, needs Windows + AutoCAD) for a real .dwt."
)

Replace the stub in the page-setup block:

    async def drawing_template_save(self, path, name=None, description=None) -> dict:
        """Save the current drawing as a template — DXF only, headlessly.

        ``.dwt`` is refused with capability ``dwt_write`` before anything is
        written; ``.dxf`` goes through ``drawing_save_as`` (the document is
        rebound to the new path, as AutoCAD's SAVEAS does). A description has
        no home in a DXF header — it is a DWG/DWT summary property — so it is
        reported ``description_written: False`` rather than dropped silently.
        """
        suffix = Path(path).suffix.lower()
        if suffix == ".dwt":
            raise UnsupportedCapabilityError(
                "dwt_write", _DWT_WRITE_REFUSAL.format(stem=Path(path).stem)
            )
        if suffix != ".dxf":
            raise ValueError(
                f"drawing_template_save: path must end in .dxf (headless) or .dwt (live "
                f"AutoCAD), got {path!r}"
            )
        saved = await self.drawing_save_as(path, "dxf")
        result = {
            "ok": True,
            "path": saved["path"],
            "format": "dxf",
            "backend": "ezdxf",
            "name": name or Path(path).stem,
            "description_written": False,
        }
        if description is not None:
            result["description_note"] = (
                "DXF has no template description field; it is a DWG/DWT summary property "
                "(capability 'dwgprops')"
            )
        return result
  • Step 4: COM implementation

In backends/com_backend.py, in the page-setup block, add the constants below _AC_NO_ROTATION and replace the stub:

    #: AcSaveAsType, AutoCAD 2026 typelib: ac2018_Template = 66 (acNative = 64,
    #: ac2013_dxf = 61). The spec's "acTemplateDwg" is not a member of the enum.
    _AC_SAVEAS_TEMPLATE = 66
    _AC_SAVEAS_DXF = 61

    async def drawing_template_save(self, path, name=None, description=None) -> dict:
        """``SaveAs(path, ac2018_Template)`` for ``.dwt``, the DXF code for ``.dxf``.

        AutoCAD's template description is its summary-info Comments field, so
        ``description`` is written there first (``description_written`` says
        whether it took). SaveAs rebinds the active document to the new file,
        as the SAVEAS command does — reported as ``document_rebound``.
        Fake-tested; executed live by ``scripts/smoke_settings_com.py --build-dwt``.
        """
        suffix = Path(path).suffix.lower()
        if suffix not in (".dwt", ".dxf"):
            raise ValueError(
                f"drawing_template_save: path must end in .dwt or .dxf, got {path!r}"
            )

        def _sync():
            doc = _acad_doc()
            description_written = False
            if description is not None:
                try:
                    doc.SummaryInfo.Comments = str(description)
                    description_written = True
                except Exception as exc:
                    log.debug("SummaryInfo.Comments write failed: %s", exc)
            code = self._AC_SAVEAS_TEMPLATE if suffix == ".dwt" else self._AC_SAVEAS_DXF
            doc.SaveAs(path, code)
            return {
                "ok": True,
                "path": path,
                "format": suffix[1:],
                "backend": "com",
                "name": name or Path(path).stem,
                "description_written": description_written,
                "document_rebound": True,
            }

        return await self._run(_sync)

And in drawing_save_as, change the map line to:

            # AutoCAD AcSaveAsType: 12 = ac2000_dwg, 61 = ac2013_dxf, 66 = ac2018_Template.
            # "dwt" was 5 (acR13_dxf) until v1.6: a DWT request wrote an R13 DXF
            # under a .dwt name.
            fmt_map = {"dwg": 12, "dxf": 61, "dwt": 66}
  • Step 5: The tool, alias, README, snapshot

SECTION 19 header → (6 tools); add after drawing_template_list:

@cad_tool(
    summary="Save the current drawing as a template: .dwt on live AutoCAD, .dxf headlessly.",
    cost="mutate",
)
@mcp.tool(
    annotations={"title": "Save As Template", "destructiveHint": False},
    tags={"template", "drawing"},
)
async def drawing_template_save(
    path: Annotated[str, "Destination .dwt (live AutoCAD) or .dxf (either engine)."],
    name: Annotated[str | None, "Template name for the report (default: the file stem)."] = None,
    description: Annotated[
        str | None, "Template description → DWT summary Comments on the live engine."
    ] = None,
    ctx: Context = None,
) -> dict:
    """AutoCAD's SAVEAS → Drawing Template, on both engines.

    Live AutoCAD writes a real `.dwt` (`SaveAs(path, ac2018_Template)`) and
    puts `description` into the template's summary Comments; the active
    document is rebound to the new file, as SAVEAS does. Headlessly a `.dxf`
    template is written — `drawing_new(template=<path>)` opens it — and a
    `.dwt` request is refused with capability `dwt_write` (the message names
    the DXF route and the COM route); a description is reported
    `description_written: false` because DXF has nowhere to keep it. Any other
    suffix, and any path outside the allowed directories, is refused before
    a byte is written.
    """
    validated = validate_path(path, allow_write=True)
    await ctx.info(f"Saving template: {validated}")
    try:
        return await _backend(ctx).drawing_template_save(str(validated), name, description)
    except ValueError as exc:
        raise ToolError(str(exc)) from exc

discovery/aliases.py: add to the SHARED_ACAD_COMMANDS comment block

#   SAVEAS -- the same command saves a copy (drawing_save_as) or a template
#       (drawing_template_save, its "Drawing Template" file type).

and "SAVEAS", to the set (alphabetical, after "QSELECT"); in the track-E block:

    "drawing_template_save": ToolAliases(
        acad=("SAVEAS",),
        synonyms=(
            "save as template",
            "make a dwt",
            "template from this drawing",
            "save as dwt",
            "reuse this sheet setup",
        ),
    ),

Create templates/README.md:

# Bundled drawing templates

Five templates, built by the server's own tools — never hand-edited — and
listed by `drawing_template_list`. Start from one with
`drawing_new(template="iso_a3_mech")`.

| name | standard | sheet | layer set | styles | page setup |
|---|---|---|---|---|---|
| `iso_a3_mech` | ISO | A3 landscape, ISO 5457 frame + ISO 7200 title block | mech | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
| `iso_a1_arch` | ISO | A1 landscape | iso13567 | ISO-25 / ISOCP, annotation scale 1:50 | ISO_A1, monochrome.ctb, fit |
| `iso_a3_pid` | ISO | A3 landscape, ISO 5457 frame + title block | pid | ISO-25 / ISOCP | ISO_A3, monochrome.ctb, 1:1 |
| `ansi_b_mech` | ANSI (metric) | ANSI B landscape | mech | ANSI / ROMANS | ANSI_B, monochrome.ctb, 1:1 |
| `ansi_d_arch` | ANSI (metric) | ANSI D landscape | iso13567 | ANSI / ROMANS | ANSI_D, monochrome.ctb, fit |

## The two files per template

- `<name>.dxf` — the headless twin. Produced by
  `uv run --frozen python scripts/build_templates.py`, which runs
  `drawing_new → apply_layer_set → styles → drawing_settings → layout rename →
  titleblock_apply_iso_a3 (A3 sheets) → page_setup_apply → drawing_save_as`.
  `scripts/build_templates.py --check` rebuilds into a temporary folder and
  fails if any committed DXF differs (the save-time stamps `$TDCREATE`,
  `$TDUPDATE`, `$FINGERPRINTGUID`, `$VERSIONGUID` and the two `EZDXF_META`
  entries are masked; everything else must be byte-identical).
  `tests/test_templates.py` runs the same check.
- `<name>.dwt` — the live twin, used by `drawing_new(template=…)` on the COM
  backend. AutoCAD's DWT is a DWG container, which no headless library
  writes, so these are produced **on the live machine** by
  `uv run --frozen python scripts/smoke_settings_com.py --build-dwt` (Task 24's
  smoke): it opens each DXF over COM and calls `drawing_template_save(…dwt)`
  (`SaveAs(path, ac2018_Template)`), then the files are committed once. While
  a `.dwt` is missing, `resolve_template` falls back to the DXF and reports
  `source: "bundled_dxf"`; `drawing_template_list` shows `files.dwt.present`.

Line endings: ezdxf writes CRLF and `.gitattributes` pins `templates/*.dxf`
to CRLF so a checkout on any platform equals a fresh build.

Rebuild after changing anything the build depends on (layer sets, the title
block, the dimension presets, the settings facade) and commit the result
together with the change — the reproducibility test is the gate.

Regenerate the inventory; README.md snapshot 171 tools → 172 tools, <sub>… 172 tools in 20 groups, the Templates row ends …, drawing_template_list, drawing_template_save(.dwt on live AutoCAD,dwt_write refused headlessly). tests/test_tool_registry.py: "templates": 3 → 4, total 171 → 172, docstring "…then 3 -> 4 when drawing_template_save joined."

  • Step 6: Run the gates

Run: uv run --frozen pytest tests/test_templates.py tests/test_page_setup.py tests/test_batch_plot.py tests/test_drawing.py tests/test_discovery_aliases.py tests/test_discovery_transform.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py -v Expected: all PASS. Then the whole suite once for this group: uv run --frozen pytest -x -q — expected green apart from the README collected tests figure (final task).

  • Step 7: Commit
git add backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py templates/README.md README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_templates.py
git commit -m "feat(templates): drawing_template_save on both engines — dwt_write is a declared capability"

Hand-off to the final tasks (24–26): LEAN_TOOL_NAMES += page_setup_apply, batch_plot; discovery golden queries for PAGESETUP / PLOT / PUBLISH / SAVEAS; scripts/smoke_settings_com.py --build-dwt produces and commits the five .dwt twins and records the live /MediaBox of an A3 plot; re-run scripts/build_templates.py after group S merges (styles then come from dimstyle_create / textstyle_create) and re-commit the DXFs; CLAUDE.md SECTION 19 paragraph and CHANGELOG Unreleased.

v1.6 Track E — group V (environment): Tasks 21–23

Part of docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md. Its Global Constraints, File Structure, Interfaces and Execution sections are binding here; this file holds the three tasks of group V (worktree track-e/environment). Every command runs through uv run --frozen ....

What this group ships: the whole EnvironmentContract (including the two drawing_properties_* members that group C implements), an ezdxf document registry so document_* works headlessly, document_list/activate/close on both engines with drawing_close folded in as the active-document case, portable layer states as ACADMCP_LAYERSTATES XRECORDs, named views and UCS on both engines, and the live-only trio — system_launch, system_preferences_get/set, user_pick_point / user_select / system_prompt_message — with @capability refusals on ezdxf declared in both capability maps.

Counts this group moves (before the merge agent re-derives them): baseline is Track A's 166 tools. Task 21 → 169 (SECTION 20 (3 tools)), Task 22 → 179 ((13 tools)), Task 23 → 185 ((19 tools)). Group C's three SECTION 20 tools make the merged header (22 tools). Group-tag buckets in tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged move as: drawing 11 → 14 (T21), layers 14 → 18 and view 4 → 10 (T22), system 7 → 13 (T23). No new tag is added to _GROUP_TAG_PRIORITY: document tools carry {"drawing"}, layer states {"layer"}, named views and UCS {"view"} (AutoCAD's own ribbon files UCS under View ▸ Coordinates), the live trio and preferences {"system"}. Every tag set is a single tag on purpose: in _GROUP_TAG_PRIORITY (repo order: … layout, solid, view, validation, analysis, block, layer, linetype, create, modify, query, drawing, system) view and layer outrank create / modify / query, and all of those outrank drawing / system — so a document tool tagged drawing + query would file under entity_query, a preferences tool tagged system + create under entity_creation; one tag per tool is what keeps each in its intended bucket.

Pack and lean decision (applies to every tool in this group): all 19 tools belong to the new settings pack (PACK_TOOL_NAMES["settings"], wired by the final task with group C's three); none joins LEAN_TOOL_NAMES — a lean client drives one drawing, does not need layer states or UCS, and cannot prompt an operator it does not have. The decision is recorded in each tool's docstring line Pack: settings · lean: no.

How this part was checked (2026-09-16): every ```python block parses (ast.parse; the nine that do not are the dict-entry fragments meant to be pasted into an existing literal). The pure modules, the ezdxf blocks and the COM blocks were exec'd over the real EzdxfBackend / ComBackend in a scratch harness and the plan's own tests run against them: 38 headless tests (test_documents, test_layer_states, test_views_ucs), 31 COM-fake and pure tests (test_environment_live plus the com_ halves of the other three), and the pre-existing test_ezdxf_quarantine, test_undo_redo, test_sprint4_ezdxf, test_preflight, test_ezdxf_honesty, test_layout_lifecycle, test_paper_space, test_drawing, test_layer_block, test_change_space suites (194 tests) all pass against the registry-backed backend. The contract module with Task 22's members spliced in composes onto both patched backends with no abstract member left, and the six @capability members refuse headlessly with their keys. Not exercised in the harness: the server.py registrations and gates (they need the files in place), and live AutoCAD.

Shared-command additions to discovery/aliases.py::SHARED_ACAD_COMMANDS: CLOSE (T21: drawing_close closes the active document, document_close closes one by name — the ERASE precedent), LAYERSTATE, VIEW, UCS (T22: umbrella dialog/option commands that this server splits into save/restore/list/delete — the LAYER / LAYOUT precedent) and OPTIONS (T23: one dialog both reads and writes a preference — the SETVAR precedent). tests/test_discovery_aliases.py::test_shared_command_allowlist_has_no_stale_entries proves each one is really shared in the task that adds it.


Task 21: Multi-document on both engines — EnvironmentContract, ezdxf document registry, document_list/activate/close, drawing_close folded in

Files:

  • Create: backends/contracts/environment.py (the whole module — every member of the Interfaces block, including the # ── properties ── pair group C implements)
  • Modify: backends/contracts/__init__.py (export EnvironmentContract)
  • Modify: backends/base.py (compose EnvironmentContract into AutoCADBackend — idempotent one-line edit; skip if another group merged it first)
  • Modify: backends/ezdxf_backend.py (_DocState registry; __init__, disconnect, _cleanup_undo_stack, _quarantine_document, drawing_new, drawing_open, drawing_close, system_status, capability map; new # ── environment (track E) ── block with document_*, anchored immediately after the # ── layouts / paper space ── block — group V's reserved position, see the skeleton's Merge notes)
  • Modify: backends/com_backend.py (drawing_close, capability map; new # ── environment (track E) ── block with document_*, anchored immediately after the # ── layouts / paper space ── block)
  • Modify: server.py (new # ── SECTION 20: Environment (3 tools) ── header before # ── RESOURCES; drawing_close docstring)
  • Modify: discovery/aliases.py (three records + CLOSE in SHARED_ACAD_COMMANDS), README.md (snapshot 166 tools → 169 tools, "What you get" row), docs/tool-inventory.json (regenerated), tests/test_tool_registry.py (snapshot drawing 11 → 14, total 169)
  • Test: tests/test_documents.py

Interfaces:

  • Produces: EnvironmentContract — after Task 22 it is the plan's Interfaces block verbatim (document_list/activate/close, layer_state_*, view_named_*, ucs_*, system_launch, preferences_get/set, user_pick_point, user_select, system_prompt_message, drawing_properties_get/set). An @abstractmethod with no implementation stops both backends from instantiating, and two test modules construct ComBackend at import, so a task that declared all ten layer_state_* / view_named_* / ucs_* members without implementing them could not end green. This task therefore ships the module with the document_* trio (implemented here on both engines), the six @capability members (their COM implementations come in Task 23; until then the declared refusal is the behaviour on both engines, which is exactly what @capability is for) and the two drawing_properties_* members; Task 22 appends the ten abstract members together with their implementations at the marked insertion point. @capability is never used as a stand-in for the abstract members — the gate forbids it, and it would also mis-declare a real engine boundary.
  • Produces (ezdxf): _DocState, EzdxfBackend._docs: dict[str, _DocState], _active_key, _register_document(doc, path) -> (key, reloaded), _resolve_document_key(name_or_path) -> key, _close_document_sync(key, *, save, discard) -> dict; the old attribute names (_doc, _doc_path, _dirty, _current_layer, _current_space, _undo_stack, _redo_stack, _transaction_stack, _plan_spec, _preflight_result, _gdt_datums_defined, _gdt_datums_referenced) keep working as views over the active entry. drawing_new / drawing_open results gain document (the registry key) and open_documents; drawing_open of a path that is already open reloads it from disk and reports reloaded: true.
  • Produces (COM): document_list/activate/close over app.Documents, doc.Activate(), doc.Close(SaveChanges).
  • Result shapes: document_list -> [{"name", "path", "active", "saved", "entity_count"}] (ezdxf rows also carry "key"), document_activate -> {"ok", "active", "previous", "name", "path"}, document_close -> {"ok", "closed", "saved", "discarded_changes", "active", "open_documents"}.

Measured before writing (2026-09-16, ezdxf 1.4.4): the per-document attributes on EzdxfBackend are _doc, _doc_path (not _path), _dirty, _current_layer, _current_space, _undo_stack, _redo_stack, _transaction_stack (not _txn_*), plus the mixin-owned _plan_spec, _preflight_result, _gdt_datums_defined, _gdt_datums_referenced that PremiumContract._reset_document_state writes and GdtContract reads with getattr(self, ..., None). _quarantine, _abandoned_calls and _lock stay backend-wide: one lock guards one worker, and the quarantine's release rule ("the runaway keeps the Drawing it captured; a fresh one is bound") is preserved by evicting the quarantined entry from the registry when drawing_new/drawing_open bind a fresh document — pre-registry that entry was simply replaced, and keeping it would let document_activate hand back a drawing another thread is still mutating.

  • Step 1: Write the failing tests
# tests/test_documents.py
"""Multi-document on both engines.

The headless backend used to hold exactly one ``Drawing``: ``drawing_new``
replaced it and silently discarded whatever was there. It now keeps a
registry keyed by path (or ``untitled-N``); every later tool call targets the
active entry, and ``drawing_close`` is the active-document case of
``document_close``.

Two rules are pinned here. ``document_close`` never drops unsaved work
without being told to (``discard=True``), and it refuses ``save=True`` on a
document that has nowhere to write (an untitled one) instead of guessing a
path — on the live engine ``Close(True)`` on an untitled drawing opens
AutoCAD's Save dialog and blocks the COM thread until the timeout, so the
refusal is what keeps the server answerable. ``drawing_close`` keeps its 1.4
contract (``save=True`` saves when it can; an untitled document is closed and
the result says the changes were discarded) because two shipped tests rely on
it; the strict path is the new tool.
"""

from __future__ import annotations

import os
import types

import pytest
from fastmcp import Client

import server
from backends.ezdxf_backend import EzdxfBackend

pytestmark = pytest.mark.asyncio


# ── headless engine ─────────────────────────────────────────────────────────


async def test_fixture_document_is_registered_and_active(backend):
    rows = await backend.document_list()
    assert rows == [
        {
            "name": "untitled-1",
            "path": None,
            "key": "untitled-1",
            "active": True,
            "saved": True,
            "entity_count": 0,
        }
    ]


async def test_two_documents_hold_separate_entities(backend):
    first = (await backend.document_list())[0]["name"]
    second = (await backend.drawing_new())["document"]
    assert second == "untitled-2"
    await backend.entity_create_circle(0, 0, 5)

    rows = {r["name"]: r for r in await backend.document_list()}
    assert rows[second]["entity_count"] == 1 and rows[second]["active"] is True
    assert rows[first]["entity_count"] == 0 and rows[first]["active"] is False

    switched = await backend.document_activate(first)
    assert switched == {
        "ok": True,
        "active": first,
        "previous": second,
        "name": first,
        "path": None,
    }
    assert (await backend.drawing_info()).entity_count == 0
    await backend.entity_create_line(0, 0, 1, 1)
    await backend.document_activate(second)
    assert (await backend.drawing_info()).entity_count == 1
    rows = {r["name"]: r for r in await backend.document_list()}
    assert rows[first]["entity_count"] == 1 and rows[first]["saved"] is False


async def test_per_document_state_follows_the_active_entry(backend):
    """Layer, current space, dirty flag and undo stack are per document."""
    await backend.layer_create("A-ONLY")
    await backend.layer_set_current("A-ONLY")
    await backend.drawing_new()
    assert backend._current_layer == "0"
    assert "A-ONLY" not in [lyr.name for lyr in await backend.layer_list()]
    await backend.document_activate("untitled-1")
    assert backend._current_layer == "A-ONLY"
    assert backend._dirty is True
    await backend.document_activate("untitled-2")
    assert backend._dirty is False


async def test_opening_a_file_twice_reloads_it_instead_of_registering_twice(backend, tmp_path):
    path = tmp_path / "part.dxf"
    await backend.drawing_save(str(path))
    await backend.entity_create_circle(0, 0, 1)  # unsaved edit
    reopened = await backend.drawing_open(str(path))
    assert reopened["reloaded"] is True
    assert reopened["open_documents"] == 1
    assert (await backend.drawing_info()).entity_count == 0, "reload reads the disk, not memory"


async def test_saving_rekeys_an_untitled_document_to_its_file(backend, tmp_path):
    path = tmp_path / "gear.dxf"
    await backend.drawing_save(str(path))
    rows = await backend.document_list()
    assert rows[0]["key"] == os.path.abspath(str(path))
    assert rows[0]["name"] == "gear.dxf" and rows[0]["path"] == str(path)
    assert backend._active_key == os.path.abspath(str(path))


async def test_activate_by_basename_full_path_or_key(backend, tmp_path):
    path = tmp_path / "gear.dxf"
    await backend.drawing_save(str(path))
    key = os.path.abspath(str(path))
    await backend.drawing_new()
    assert (await backend.document_activate("gear.dxf"))["active"] == key
    await backend.document_activate("untitled-2")
    assert (await backend.document_activate(str(path)))["name"] == "gear.dxf"
    await backend.document_activate("untitled-2")
    assert (await backend.document_activate(key))["previous"] == "untitled-2"
    with pytest.raises(ValueError, match="no open document named 'nope'"):
        await backend.document_activate("nope")
    with pytest.raises(TypeError, match="name_or_path"):
        await backend.document_activate(None)


async def test_close_unsaved_refuses_unless_discard(backend):
    await backend.entity_create_line(0, 0, 1, 1)
    with pytest.raises(ValueError, match="untitled-1.*unsaved changes"):
        await backend.document_close("untitled-1")
    assert len(await backend.document_list()) == 1, "a refusal closes nothing"
    with pytest.raises(ValueError, match="never been saved"):
        await backend.document_close("untitled-1", save=True)
    with pytest.raises(ValueError, match="mutually exclusive"):
        await backend.document_close("untitled-1", save=True, discard=True)

    result = await backend.document_close("untitled-1", discard=True)
    assert result == {
        "ok": True,
        "closed": "untitled-1",
        "saved": False,
        "discarded_changes": True,
        "active": None,
        "open_documents": 0,
    }


async def test_close_with_save_writes_the_file(backend, tmp_path):
    path = tmp_path / "saved.dxf"
    await backend.drawing_save(str(path))
    await backend.entity_create_circle(0, 0, 2)
    before = path.read_bytes()
    result = await backend.document_close(None, save=True)
    assert result["saved"] is True and result["discarded_changes"] is False
    assert path.read_bytes() != before


async def test_closing_the_last_document_leaves_the_no_document_state(backend):
    await backend.document_close("untitled-1")
    assert await backend.document_list() == []
    status = await backend.system_status()
    assert status["has_document"] is False
    assert status["open_documents"] == 0 and status["active_document"] is None
    with pytest.raises(RuntimeError, match="No document open"):
        await backend.drawing_info()
    # ...and the backend recovers exactly as before.
    assert (await backend.drawing_new())["document"] == "untitled-2"


async def test_closing_the_active_document_activates_the_most_recently_used(backend):
    await backend.drawing_new()  # untitled-2
    await backend.drawing_new()  # untitled-3
    await backend.document_activate("untitled-1")
    await backend.document_activate("untitled-3")
    result = await backend.document_close("untitled-3")
    assert result["active"] == "untitled-1", "most recently activated, not most recently created"


async def test_closing_a_background_document_keeps_the_active_one(backend):
    await backend.drawing_new()  # untitled-2, active
    await backend.entity_create_circle(0, 0, 1)
    result = await backend.document_close("untitled-1")
    assert result["active"] == "untitled-2"
    assert (await backend.drawing_info()).entity_count == 1


async def test_drawing_close_keeps_its_legacy_contract(backend, tmp_path):
    await backend.entity_create_line(0, 0, 1, 1)
    closed = await backend.drawing_close(save=True)
    assert closed["ok"] is True and closed["saved"] is False
    assert closed["discarded_changes"] is True
    assert "never been saved" in closed["warning"]
    assert await backend.document_list() == []

    await backend.drawing_new()
    path = tmp_path / "keep.dxf"
    await backend.drawing_save(str(path))
    await backend.entity_create_circle(0, 0, 3)
    before = path.read_bytes()
    closed = await backend.drawing_close(save=True)
    assert closed["saved"] is True and "warning" not in closed
    assert path.read_bytes() != before


async def test_disconnect_drops_every_document_and_its_snapshots(monkeypatch):
    import config

    monkeypatch.setattr(config.settings, "ezdxf_undo_depth", 3)
    backend = EzdxfBackend()
    await backend.connect()
    await backend.drawing_new()
    await backend.entity_create_line(0, 0, 1, 1)
    await backend.drawing_new()
    await backend.entity_create_line(0, 0, 2, 2)
    snapshots = [p for state in backend._docs.values() for p in state.undo_stack]
    assert len(snapshots) == 4 and all(p.exists() for p in snapshots)
    await backend.disconnect()
    assert backend._docs == {} and backend._active_key is None
    assert not any(p.exists() for p in snapshots)


async def test_document_list_tool_over_the_wire(monkeypatch):
    monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
    async with Client(server.mcp) as client:
        await client.call_tool("drawing_new", {"bootstrap": False})
        await client.call_tool("drawing_new", {"bootstrap": False})
        payload = (await client.call_tool("document_list", {})).structured_content
        assert payload["count"] == 2 and payload["active"] == "untitled-2"
        closed = (
            await client.call_tool("document_close", {"name_or_path": "untitled-1"})
        ).structured_content
        assert closed["closed"] == "untitled-1" and closed["active"] == "untitled-2"


# ── live engine, against a fake ActiveX surface ──────────────────────────────


class _FakeDoc:
    def __init__(self, app, name, full_name, *, saved=True, count=0):
        self._app = app
        self.Name = name
        self.FullName = full_name
        self.Saved = saved
        self.ModelSpace = types.SimpleNamespace(Count=count)
        self.calls: list[tuple[str, tuple]] = []

    def Activate(self):
        self.calls.append(("Activate", ()))
        self._app.ActiveDocument = self

    def Close(self, save_changes):
        self.calls.append(("Close", (save_changes,)))
        self._app.Documents._docs.remove(self)
        docs = self._app.Documents._docs
        self._app.ActiveDocument = docs[-1] if docs else None


class _FakeDocuments:
    def __init__(self):
        self._docs: list[_FakeDoc] = []

    @property
    def Count(self):
        return len(self._docs)

    def Item(self, index):
        return self._docs[index]


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    app = types.SimpleNamespace(Documents=_FakeDocuments(), ActiveDocument=None)
    first = _FakeDoc(app, "First.dwg", "C:/work/First.dwg", saved=True, count=3)
    second = _FakeDoc(app, "Second.dwg", "C:/work/Second.dwg", saved=False, count=1)
    untitled = _FakeDoc(app, "Drawing1.dwg", "", saved=False, count=0)
    app.Documents._docs.extend([first, second, untitled])
    app.ActiveDocument = first
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    monkeypatch.setattr(module, "_acad_doc", lambda: app.ActiveDocument)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, app


async def test_com_document_list_reads_the_documents_collection(com_backend):
    backend, app = com_backend
    rows = await backend.document_list()
    assert rows == [
        {"name": "First.dwg", "path": "C:/work/First.dwg", "active": True, "saved": True, "entity_count": 3},
        {"name": "Second.dwg", "path": "C:/work/Second.dwg", "active": False, "saved": False, "entity_count": 1},
        {"name": "Drawing1.dwg", "path": None, "active": False, "saved": False, "entity_count": 0},
    ]


async def test_com_activate_calls_activate_and_resets_document_scope(com_backend):
    backend, app = com_backend
    await backend._ensure_document_state()
    backend._plan_spec = object()
    result = await backend.document_activate("Second.dwg")
    assert result == {
        "ok": True,
        "active": "Second.dwg",
        "previous": "First.dwg",
        "name": "Second.dwg",
        "path": "C:/work/Second.dwg",
    }
    assert app.Documents._docs[1].calls == [("Activate", ())]
    assert backend._plan_spec is None, "a document switch clears drawing-scoped state"
    with pytest.raises(ValueError, match="no open document named 'Third.dwg'"):
        await backend.document_activate("Third.dwg")


async def test_com_close_refuses_before_touching_activex(com_backend):
    backend, app = com_backend
    second = app.Documents._docs[1]
    with pytest.raises(ValueError, match="Second.dwg.*unsaved changes"):
        await backend.document_close("Second.dwg")
    untitled = app.Documents._docs[2]
    with pytest.raises(ValueError, match="never been saved"):
        await backend.document_close("Drawing1.dwg", save=True)
    assert second.calls == [] and untitled.calls == []


async def test_com_close_passes_savechanges_through(com_backend):
    backend, app = com_backend
    first, second, untitled = app.Documents._docs
    saved = await backend.document_close("Second.dwg", save=True)
    assert second.calls == [("Close", (True,))]
    assert saved["saved"] is True and saved["open_documents"] == 2
    dropped = await backend.document_close("Drawing1.dwg", discard=True)
    assert untitled.calls == [("Close", (False,))]
    assert dropped["discarded_changes"] is True and dropped["open_documents"] == 1
    clean = await backend.document_close(None)  # the active, clean First.dwg
    assert first.calls == [("Close", (False,))], "nothing to save, so nothing is asked to be saved"
    assert clean["active"] is None


async def test_com_drawing_close_refuses_the_untitled_save_dialog(com_backend):
    backend, app = com_backend
    app.ActiveDocument = app.Documents._docs[2]  # the untitled one
    with pytest.raises(ValueError, match="Save dialog"):
        await backend.drawing_close(save=True)
    assert app.Documents._docs[2].calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_documents.py -v Expected: FAIL — AttributeError: 'EzdxfBackend' object has no attribute 'document_list' on the headless tests; the COM tests fail the same way (or skip where pywin32 is absent).

  • Step 3: Create the contract module and compose it

backends/contracts/environment.py — the Interfaces block verbatim, plus group C's two members in their marked block. Task 22 appends the ten layer_state_* / view_named_* / ucs_* abstract members at the marked insertion point; nothing else in this module changes later. Every @capability( line carries # noqa: B027 — bugbear flags the empty body that the decorator supplies, every existing contract (analysis, entity_creation, layouts) and group S's styles.py carry the same noqa, and ruff check . (F Task 24 Step 5, Task 26 Step 4) is red without it.

"""Environment: documents, layer states, named views, UCS, application,
preferences, drawing properties and operator prompts (v1.6 track E).

``AutoCADBackend`` composes every contract in this package, so importing
``backends.base.AutoCADBackend`` is unchanged for callers.

Ownership: group V owns this module. The ``# ── properties ──`` block is
declared here and *implemented* by group C on both backends; the six
``@capability`` members are implemented on the COM backend only and refuse on
ezdxf with a key both capability maps declare.
"""

from __future__ import annotations

from abc import ABC, abstractmethod

from backends.capability import capability


class EnvironmentContract(ABC):
    # ── documents ────────────────────────────────────────────────────────────

    @abstractmethod
    async def document_list(self) -> list[dict]:
        """``[{"name", "path", "active": bool, "saved": bool, "entity_count"}]``."""
        ...

    @abstractmethod
    async def document_activate(self, name_or_path: str) -> dict:
        """Make one open document the target of every later call.
        → ``{"ok", "active", "previous"}``."""
        ...

    @abstractmethod
    async def document_close(
        self, name_or_path: str | None = None, save: bool = False, discard: bool = False
    ) -> dict:
        """Close one document (default: the active one).

        Unsaved and neither ``save`` nor ``discard`` → ``ValueError`` naming the
        document; ``save`` on a document that has never been saved →
        ``ValueError`` (no path to write, and the live engine would open a
        modal Save dialog). → ``{"ok", "closed", "saved": bool, "active": name|None}``.
        """
        ...

    # ── layer states, named views, UCS: appended by Task 22 ──────────────────

    # ── application, preferences, operator prompts (COM only) ───────────────

    @capability(  # noqa: B027 — @capability supplies the body
        "live_application",
        reason="only a live CAD application can be launched or attached",
    )
    async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
        """→ ``{"launched": bool, "attached": bool, "version", "document"}``."""
        ...

    @capability(  # noqa: B027 — @capability supplies the body
        "preferences",
        reason="AutoCAD preferences live in the running application, not in a file",
    )
    async def preferences_get(self, keys: list[str] | None = None) -> dict:
        """→ ``{"values": {key: value}, "read_only": [keys]}``."""
        ...

    @capability(  # noqa: B027 — @capability supplies the body
        "preferences",
        reason="AutoCAD preferences live in the running application, not in a file",
    )
    async def preferences_set(self, key: str, value) -> dict:
        """→ ``{"ok", "key", "old", "new"}``."""
        ...

    @capability(  # noqa: B027 — @capability supplies the body
        "interactive_prompt",
        reason="prompting the operator needs a live AutoCAD session",
    )
    async def user_pick_point(self, prompt: str) -> dict:
        """→ ``{"cancelled": bool, "x", "y"}`` | ``{"timed_out": True}``."""
        ...

    @capability(  # noqa: B027 — @capability supplies the body
        "interactive_prompt",
        reason="prompting the operator needs a live AutoCAD session",
    )
    async def user_select(self, prompt: str, mode: str = "single") -> dict:
        """→ ``{"cancelled", "handles": [...]}``."""
        ...

    @capability(  # noqa: B027 — @capability supplies the body
        "interactive_prompt",
        reason="prompting the operator needs a live AutoCAD session",
    )
    async def system_prompt_message(self, text: str) -> dict:
        """→ ``{"ok"}``."""
        ...

    # ── properties (declared here; implemented by group C on both engines) ───

    @abstractmethod
    async def drawing_properties_get(self) -> dict:
        """→ ``{"summary": {"title","subject","author","keywords","comments"}
        (None each headless), "summary_available": bool, "custom": {k: v}}``."""
        ...

    @abstractmethod
    async def drawing_properties_set(
        self, summary: dict | None = None, custom: dict | None = None
    ) -> dict:
        """ezdxf: ``summary`` with any non-None field → raise
        ``UnsupportedCapabilityError("dwgprops")``; ``custom`` → header
        custom_vars (replace keys given; value None deletes).
        → ``{"ok", "summary_written": [...], "custom_written": [...], "custom_deleted": [...]}``."""
        ...

The two drawing_properties_* members are @abstractmethod in the final module, so both backends would fail to instantiate until group C's implementations exist — and group C's code is not present in the track-e/environment worktree. A stub that returns a value would be the quiet failure the honesty pass forbids, so in this worktree the two members are declared under a temporary guard that the merge agent removes:

# TEMPORARY (track-e/environment worktree only — the merge agent deletes this
# block and the two decorators below become @abstractmethod): until group C's
# implementations land, declaring these abstract would stop both backends from
# instantiating here. They refuse with a declared key rather than pretend.

…and both members carry @capability("dwgprops", reason="drawing properties arrive with group C's merge") in this worktree, with dwgprops declared in both capability maps in steps 4 and 5 — as the byte-identical FeatureCapability lines group C's Task 16 declares (ezdxf FeatureCapability(False, reason="summary_fields_need_live_autocad;custom_properties_supported"), COM FeatureCapability(True, "native")), so the merge agent keeps one copy per map (C's) and drops V's line together with the decorators — a repeated dict key is ruff F601. The merge agent replaces the two decorators with @abstractmethod when C's implementations are present. This is stated in the commit message.

Add the temporary import to the module head (capability is already imported) and write the two members as:

    @capability("dwgprops", reason="drawing properties arrive with group C's merge")  # noqa: B027
    async def drawing_properties_get(self) -> dict:
        ...

    @capability("dwgprops", reason="drawing properties arrive with group C's merge")  # noqa: B027
    async def drawing_properties_set(
        self, summary: dict | None = None, custom: dict | None = None
    ) -> dict:
        ...

(the docstrings from the listing above stay on them).

backends/contracts/__init__.py — add the import and the __all__ entry:

from backends.contracts.environment import EnvironmentContract
    "EnvironmentContract",

backends/base.py — import EnvironmentContract alongside the others and add it to the AutoCADBackend bases after SettingsContract:

    SettingsContract,
    EnvironmentContract,
):

Run: uv run --frozen python -c "from backends.base import AutoCADBackend; print('EnvironmentContract' in [c.__name__ for c in AutoCADBackend.__mro__])" → True.

  • Step 4: The ezdxf document registry

In backends/ezdxf_backend.py, add to the imports:

from dataclasses import dataclass, field

Insert immediately before class EzdxfBackend(AutoCADBackend)::

# ---------------------------------------------------------------------------
# ── environment (track E): document registry ────────────────────────────────
# ---------------------------------------------------------------------------


@dataclass
class _DocState:
    """Everything the backend keeps per open document.

    Before v1.6 these were twelve attributes on the backend itself, which is
    why it could hold exactly one drawing. The registry holds one of these
    per open document; the old attribute names survive as descriptors over
    the *active* entry so no existing method had to change.
    """

    key: str
    doc: Any = None
    path: str | None = None
    dirty: bool = False
    current_layer: str = "0"
    current_space: str = "Model"
    undo_stack: list[Path] = field(default_factory=list)
    redo_stack: list[Path] = field(default_factory=list)
    transaction_stack: list[Path] = field(default_factory=list)
    plan_spec: Any = None
    preflight_result: Any = None
    gdt_datums_defined: set = field(default_factory=set)
    gdt_datums_referenced: set = field(default_factory=set)
    activated_seq: int = 0

    @property
    def label(self) -> str:
        return Path(self.path).name if self.path else self.key

    def discard_snapshots(self) -> None:
        for stack in (self.undo_stack, self.redo_stack, self.transaction_stack):
            while stack:
                try:
                    stack.pop().unlink()
                except OSError:
                    pass


class _ActiveField:
    """A backend attribute that lives on the active document's ``_DocState``.

    A data descriptor, so it also overrides the plain class attributes the
    ``PremiumContract`` mixin declares (``_plan_spec = None``) — the mixin's
    ``self._plan_spec = plan`` lands on the active document.
    """

    def __init__(self, attr: str):
        self.attr = attr

    def __get__(self, instance, owner=None):
        if instance is None:
            return self
        return getattr(instance._state, self.attr)

    def __set__(self, instance, value):
        setattr(instance._state, self.attr, value)

Replace the __init__ body of EzdxfBackend and add the descriptors/properties right after the class docstring:

class EzdxfBackend(AutoCADBackend):
    """File-based ezdxf backend – no live AutoCAD needed."""

    # Old per-backend attributes, now routed to the active document (v1.6).
    # `_doc` and `_doc_path` are explicit properties below (binding and
    # re-keying rules); the rest are plain views.
    _dirty = _ActiveField("dirty")
    _current_layer = _ActiveField("current_layer")
    _current_space = _ActiveField("current_space")
    _undo_stack = _ActiveField("undo_stack")
    _redo_stack = _ActiveField("redo_stack")
    _transaction_stack = _ActiveField("transaction_stack")
    _plan_spec = _ActiveField("plan_spec")
    _preflight_result = _ActiveField("preflight_result")
    _gdt_datums_defined = _ActiveField("gdt_datums_defined")
    _gdt_datums_referenced = _ActiveField("gdt_datums_referenced")

    def __init__(self):
        if not _EZDXF_OK:
            raise RuntimeError("ezdxf is not installed. Run: pip install ezdxf")
        # v1.6: one entry per open document; the old attributes above are
        # views over the active one. `_void` is what they read and write when
        # nothing is open, so `_reset_document_state()` on disconnect and
        # `system_status()` on an empty backend keep working.
        self._docs: dict[str, _DocState] = {}
        self._active_key: str | None = None
        self._void = _DocState(key="")
        self._untitled_counter = 0
        self._activation_counter = 0
        self._connected = False
        self._lock = asyncio.Lock()
        # R32: the document a timed-out call was abandoned on is untrusted until
        # it is replaced. See backends/quarantine.py for what that buys.
        self._quarantine: QuarantineRecord | None = None
        self._abandoned_calls: list[QuarantineRecord] = []

    @property
    def _state(self) -> _DocState:
        if self._active_key is None:
            return self._void
        return self._docs[self._active_key]

    @property
    def _doc(self):
        return self._state.doc

    @_doc.setter
    def _doc(self, value):
        # `_restore_snapshot` and `transaction_rollback` rebind the active
        # document; nothing may bind a Drawing while none is active — that is
        # `_register_document`'s job, and it is what keeps the registry honest.
        if self._active_key is None:
            if value is None:
                return
            raise RuntimeError("EzdxfBackend: bind a Drawing through _register_document")
        self._state.doc = value

    @property
    def _doc_path(self) -> str | None:
        return self._state.path

    @_doc_path.setter
    def _doc_path(self, value: str | None):
        # `drawing_save` / `drawing_save_as` assign here. A document keyed
        # `untitled-N` that is saved to a file becomes keyed by that file, so
        # `document_list` shows one identity per document and `document_close`
        # can be told the file name. Two entries may end up sharing a path
        # (save_as onto a file that is open in another entry); the resolver
        # then reports the ambiguity instead of guessing.
        state = self._state
        state.path = value
        if self._active_key is None or value is None:
            return
        new_key = os.path.abspath(value)
        if new_key != self._active_key and new_key not in self._docs:
            del self._docs[self._active_key]
            state.key = new_key
            self._docs[new_key] = state
            self._active_key = new_key

Replace disconnect and _cleanup_undo_stack:

    async def disconnect(self) -> None:
        for state in self._docs.values():
            state.discard_snapshots()
        self._docs.clear()
        self._active_key = None
        self._void = _DocState(key="")
        self._reset_document_state()
        # The documents go away with the backend, so nothing is left to protect.
        # The record stays in _abandoned_calls for anyone reading the transcript.
        self._clear_quarantine("disconnect")
        self._connected = False

    def _cleanup_undo_stack(self):
        self._state.discard_snapshots()

Add the registry helpers right after _cleanup_undo_stack:

    # ── document registry (v1.6) ──────────────────────────────────────────────

    def _register_document(self, doc, path: str | None) -> tuple[str, bool]:
        """Add ``doc`` to the registry and make it active.

        Returns ``(key, reloaded)``. A path that is already open is *reloaded*:
        its old entry (and its snapshots) is dropped and the fresh read takes
        its place — the pre-registry behaviour of ``drawing_open``, kept so a
        save-then-reopen verification still reads the disk.
        """
        if path is None:
            self._untitled_counter += 1
            key = f"untitled-{self._untitled_counter}"
            reloaded = False
        else:
            key = os.path.abspath(path)
            previous = self._docs.pop(key, None)
            reloaded = previous is not None
            if previous is not None:
                previous.discard_snapshots()
        self._docs[key] = _DocState(key=key, doc=doc, path=path)
        self._activate_key(key)
        return key, reloaded

    def _activate_key(self, key: str) -> None:
        self._activation_counter += 1
        self._docs[key].activated_seq = self._activation_counter
        self._active_key = key

    def _most_recent_key(self) -> str | None:
        if not self._docs:
            return None
        return max(self._docs.values(), key=lambda state: state.activated_seq).key

    def _resolve_document_key(self, name_or_path: str | None) -> str:
        """Registry key for ``None`` (the active document), a key, a path or a basename."""
        if name_or_path is None:
            if self._active_key is None:
                raise RuntimeError("No document open. Call drawing_new() or drawing_open() first.")
            return self._active_key
        if not isinstance(name_or_path, str):
            raise TypeError(f"name_or_path must be a string, got {type(name_or_path).__name__}")
        wanted = name_or_path.strip()
        if not wanted:
            raise ValueError("name_or_path must not be empty")
        if wanted in self._docs:
            return wanted
        try:
            as_path = os.path.abspath(wanted)
        except (OSError, ValueError):
            as_path = wanted
        by_path = [
            key
            for key, state in self._docs.items()
            if state.path and os.path.abspath(state.path) == as_path
        ]
        if len(by_path) == 1:
            return by_path[0]
        by_name = [key for key, state in self._docs.items() if state.label.lower() == wanted.lower()]
        if len(by_name) == 1:
            return by_name[0]
        if len(by_name) > 1:
            raise ValueError(f"document {wanted!r} is ambiguous: {by_name}; pass the full path")
        raise ValueError(f"no open document named {wanted!r}; open documents: {list(self._docs)}")

    def _evict_quarantined_document(self) -> None:
        """Drop the quarantined entry before a fresh document is registered.

        Pre-registry, ``drawing_new`` *replaced* the only document, and the
        quarantine relies on that: the runaway keeps the object it captured
        and its writes land on an orphan nobody reads. With a registry the
        quarantined entry would otherwise survive, and ``document_activate``
        could hand back a drawing another thread is still mutating.
        """
        if self._quarantine is None or self._active_key is None:
            return
        state = self._docs.pop(self._active_key)
        state.discard_snapshots()
        self._active_key = self._most_recent_key()

    def _document_row(self, key: str, state: _DocState) -> dict:
        return {
            "name": state.label,
            "path": state.path,
            "key": key,
            "active": key == self._active_key,
            "saved": not state.dirty,
            "entity_count": len(state.doc.modelspace()),
        }

    def _close_document_sync(self, key: str, *, save: bool, discard: bool) -> dict:
        """Close one registry entry. Runs on the worker thread under the lock."""
        state = self._docs[key]
        closing_active = key == self._active_key
        quarantined = closing_active and self._quarantine is not None
        label = state.label
        saved = False
        if state.dirty:
            if save:
                if quarantined:
                    raise ValueError(
                        f"document_close: {label!r} was quarantined after a call was "
                        "abandoned mid-write and cannot be saved; pass discard=True to drop "
                        "its changes"
                    )
                if not state.path:
                    raise ValueError(
                        f"document_close: {label!r} has never been saved, so save=True has "
                        "nowhere to write; drawing_save_as(path) first, or pass discard=True"
                    )
                state.doc.saveas(state.path)
                state.dirty = False
                saved = True
            elif not discard:
                where = f" to {state.path}" if state.path else " (after drawing_save_as)"
                raise ValueError(
                    f"document_close: {label!r} has unsaved changes; pass save=True to write "
                    f"them{where}, or discard=True to drop them"
                )
        discarded = state.dirty
        state.discard_snapshots()
        del self._docs[key]
        result = {"ok": True, "closed": key, "saved": saved, "discarded_changes": discarded}
        if closing_active:
            released = self._clear_quarantine("document_close")
            self._active_key = self._most_recent_key()
            if released is not None:
                result["quarantine_cleared"] = released.to_dict()
        result["active"] = self._active_key
        result["open_documents"] = len(self._docs)
        return result

Rewrite drawing_new, drawing_open and drawing_close:

    async def drawing_new(self, template: str | None = None) -> dict:
        def _sync():
            if template and Path(template).exists():
                doc = ezdxf.readfile(template)
                _normalise_dimstyle_rounding(doc)
            else:
                doc = ezdxf.new(dxfversion="R2010")
                _apply_iso_dimstyle(doc)
            # R32: registering a fresh Drawing is the release — the runaway keeps
            # the object it captured. The quarantined entry is evicted first so
            # it can never be re-activated. Clear before _reset_history_baseline,
            # which snapshots the new doc.
            self._evict_quarantined_document()
            released = self._clear_quarantine("drawing_new")
            key, _ = self._register_document(doc, None)
            self._reset_document_state()
            self._reset_history_baseline()
            result = {
                "ok": True,
                "name": "untitled.dxf",
                "document": key,
                "open_documents": len(self._docs),
            }
            if released is not None:
                result["quarantine_cleared"] = released.to_dict()
            return result

        return await self._async(_sync, quarantine_exit=True)

    async def drawing_open(self, path: str) -> dict:
        def _sync():
            max_bytes = config.settings.max_dxf_bytes
            if max_bytes > 0:
                try:
                    size = os.path.getsize(path)
                except OSError as exc:
                    raise RuntimeError(f"Cannot stat DXF file: {exc}") from exc
                if size > max_bytes:
                    raise RuntimeError(
                        f"DXF file exceeds MAX_DXF_BYTES limit "
                        f"({size} > {max_bytes}). Set MAX_DXF_BYTES env var to override."
                    )
            doc = ezdxf.readfile(path)
            _normalise_dimstyle_rounding(doc)
            # R32: only reached if the read succeeded. Reopening the very path a
            # runaway doc.saveas is writing either fails loudly here (acceptable)
            # or hands back a truncated document (not) — _clear_quarantine
            # compares the two paths and says so.
            self._evict_quarantined_document()
            released = self._clear_quarantine("drawing_open", path)
            key, reloaded = self._register_document(doc, path)
            try:
                self._current_layer = doc.header.get("$CLAYER", "0")
            except Exception as exc:
                log.debug("reading $CLAYER from header: %s", exc)
                self._current_layer = "0"
            self._reset_document_state()
            self._reset_history_baseline()
            result = {
                "ok": True,
                "name": Path(path).name,
                "path": path,
                "document": key,
                "open_documents": len(self._docs),
            }
            if reloaded:
                result["reloaded"] = True
            if released is not None:
                result["quarantine_cleared"] = released.to_dict()
            return result

        return await self._async(_sync, quarantine_exit=True)
    async def drawing_close(self, save: bool = True) -> dict:
        """The active-document case of ``document_close``, with the 1.4 contract kept.

        ``save=True`` saves when the document has a path and is not quarantined.
        An untitled or quarantined document is closed *without* saving and the
        result says so (``saved: False``, ``discarded_changes``, ``warning``) —
        the pre-registry behaviour two shipped tests pin. ``document_close`` is
        the strict tool: it refuses instead of warning.
        """

        def _sync():
            if self._active_key is None:
                return {"ok": True, "closed": None, "saved": False, "active": None, "open_documents": 0}
            key = self._active_key
            state = self._docs[key]
            quarantined = self._quarantine is not None
            dirty = state.dirty
            can_save = save and dirty and bool(state.path) and not quarantined
            result = self._close_document_sync(key, save=can_save, discard=not can_save)
            if save and dirty and not can_save:
                result["warning"] = (
                    "the document was quarantined after a call was abandoned mid-write, "
                    "so unsaved changes were discarded rather than serialised out of a "
                    "drawing another thread may still have been mutating"
                    if quarantined
                    else f"{key} had never been saved, so save=True had nowhere to write; "
                    "its changes were discarded. drawing_save_as(path) before closing, or "
                    "document_close(discard=True) to say so explicitly."
                )
            return result

        return await self._async(_sync, quarantine_exit=True)

system_status — add two keys after "document_path":

            "open_documents": len(self._docs),
            "active_document": self._active_key,

Capability map (ezdxf) — add after "lisp" (the only shared anchor that exists in this worktree; merged order at that spot is S's "mleaderstyle", then C's "registry_sysvar" / "dwgprops", then these — the skeleton's Merge notes):

                "documents": FeatureCapability(True, "registry"),
                # Track E keys. `@capability` registers its key at import, and
                # tests/test_capability_contract.py checks both maps from that
                # moment on — so every key the contract module uses is declared
                # here in the task that creates the module, not when the COM
                # implementation lands (Task 23).
                "live_application": FeatureCapability(
                    False, reason="headless_engine_has_no_application_to_launch_or_attach"
                ),
                "preferences": FeatureCapability(
                    False, reason="preferences_live_in_the_running_application_not_in_a_file"
                ),
                "interactive_prompt": FeatureCapability(
                    False, reason="no_operator_to_prompt_headlessly"
                ),
                # Byte-identical to group C's Task 16 entry; the merge agent keeps one.
                "dwgprops": FeatureCapability(
                    False,
                    reason="summary_fields_need_live_autocad;custom_properties_supported",
                ),

Then the new block. Place it immediately after the # ── layouts / paper space ── block, i.e. directly before # ── viewports ── (after layout_copy). This is group V's reserved anchor: S's block sits before # ── corner ops, C's at the very end of the class, P's before # ── 3D solids — four non-adjacent positions, so no two groups edit the same lines (skeleton, Merge notes). Tasks 22 and 23 append inside this block.

    # ---------------------------------------------------------------------------
    # ── environment (track E) ───────────────────────────────────────────────────
    # ---------------------------------------------------------------------------

    async def document_list(self) -> list[dict]:
        def _sync():
            return [self._document_row(key, state) for key, state in self._docs.items()]

        return await self._async(_sync)

    async def document_activate(self, name_or_path: str) -> dict:
        if not isinstance(name_or_path, str):
            raise TypeError(
                f"document_activate: name_or_path must be a string, got {type(name_or_path).__name__}"
            )

        def _sync():
            key = self._resolve_document_key(name_or_path)
            previous = self._active_key
            self._activate_key(key)
            state = self._docs[key]
            return {
                "ok": True,
                "active": key,
                "previous": previous,
                "name": state.label,
                "path": state.path,
            }

        # Not a quarantine exit: a quarantined backend refuses to switch, because
        # the runaway thread holds the one worker and the one orphaned lock.
        return await self._async(_sync)

    async def document_close(
        self, name_or_path: str | None = None, save: bool = False, discard: bool = False
    ) -> dict:
        if save and discard:
            raise ValueError("document_close: save and discard are mutually exclusive")
        # Resolve once outside the lock to decide whether this is the active
        # document (then it is a quarantine exit, like drawing_close); the
        # closure resolves again under the lock before touching the registry.
        target = self._resolve_document_key(name_or_path)
        closing_active = target == self._active_key

        def _sync():
            key = self._resolve_document_key(name_or_path)
            return self._close_document_sync(key, save=save, discard=discard)

        return await self._async(_sync, quarantine_exit=closing_active)

Run: uv run --frozen pytest tests/test_documents.py -k "not com_" tests/test_ezdxf_quarantine.py tests/test_undo_redo.py tests/test_sprint4_ezdxf.py tests/test_preflight.py tests/test_ezdxf_honesty.py tests/test_layout_lifecycle.py tests/test_paper_space.py -v Expected: all PASS — the registry is invisible to every pre-existing test.

  • Step 5: COM — document_* over Documents, drawing_close folded in

In backends/com_backend.py, replace drawing_close:

    async def drawing_close(self, save: bool = True) -> dict:
        """The active-document case of ``document_close``.

        ``Close(True)`` on a drawing that has never been saved opens AutoCAD's
        Save dialog and blocks the single STA thread until ``COM_CALL_TIMEOUT``;
        that is refused here by name rather than waited out.
        """
        return await self.document_close(None, save=save, discard=not save)

Add the capability-map entries after "lisp" (same rule as the ezdxf map: merged order S → C → V at that spot):

                "documents": FeatureCapability(True, "native"),
                "live_application": FeatureCapability(True, "native"),
                "preferences": FeatureCapability(True, "native", reason="whitelisted_keys_only"),
                "interactive_prompt": FeatureCapability(
                    True, "native", reason="cancel_returns_cancelled_true;timeout_returns_timed_out"
                ),
                # Byte-identical to group C's Task 16 entry; the merge agent keeps one.
                "dwgprops": FeatureCapability(True, "native"),

Add the new block immediately after the # ── layouts / paper space ── block, i.e. directly before # ── selection filters (M8 / F1) ── (after layout_copy) — V's reserved anchor on the COM side too; P's page-setup block goes before # ── 3D solids, C's at the end of the class, S's before # ── corner ops:

    # ---------------------------------------------------------------------------
    # ── environment (track E) ───────────────────────────────────────────────────
    # ---------------------------------------------------------------------------

    @staticmethod
    def _com_documents(app) -> list:
        return [app.Documents.Item(i) for i in range(int(app.Documents.Count))]

    @staticmethod
    def _com_document_row(doc, active) -> dict:
        full_name = str(doc.FullName)
        return {
            "name": str(doc.Name),
            "path": full_name or None,
            "active": active is not None
            and (str(doc.Name), full_name) == (str(active.Name), str(active.FullName)),
            "saved": bool(doc.Saved),
            "entity_count": int(doc.ModelSpace.Count),
        }

    def _com_find_document(self, app, name_or_path: str):
        """Match a key the way ``_resolve_document_key`` does: full path, then name."""
        if not isinstance(name_or_path, str):
            raise TypeError(
                f"name_or_path must be a string, got {type(name_or_path).__name__}"
            )
        wanted = name_or_path.strip()
        if not wanted:
            raise ValueError("name_or_path must not be empty")
        docs = self._com_documents(app)
        try:
            as_path = str(Path(wanted).resolve()).lower()
        except (OSError, ValueError):
            as_path = wanted.lower()
        by_path = [
            d
            for d in docs
            if str(d.FullName) and str(Path(str(d.FullName)).resolve()).lower() == as_path
        ]
        by_name = [d for d in docs if str(d.Name).lower() == wanted.lower()]
        hits = by_path or by_name
        if len(hits) == 1:
            return hits[0]
        names = [str(d.Name) for d in docs]
        if len(hits) > 1:
            raise ValueError(f"document {wanted!r} is ambiguous: {names}; pass the full path")
        raise ValueError(f"no open document named {wanted!r}; open documents: {names}")

    async def document_list(self) -> list[dict]:
        def _sync():
            app = _acad_app()
            active = app.ActiveDocument if int(app.Documents.Count) else None
            return [self._com_document_row(doc, active) for doc in self._com_documents(app)]

        return await self._run(_sync)

    async def document_activate(self, name_or_path: str) -> dict:
        def _sync():
            app = _acad_app()
            previous = str(app.ActiveDocument.Name) if int(app.Documents.Count) else None
            doc = self._com_find_document(app, name_or_path)
            doc.Activate()
            return {
                "ok": True,
                "active": str(doc.Name),
                "previous": previous,
                "name": str(doc.Name),
                "path": str(doc.FullName) or None,
            }

        result = await self._run(_sync)
        await self._ensure_document_state()
        return result

    async def document_close(
        self, name_or_path: str | None = None, save: bool = False, discard: bool = False
    ) -> dict:
        if save and discard:
            raise ValueError("document_close: save and discard are mutually exclusive")

        def _sync():
            app = _acad_app()
            doc = _acad_doc() if name_or_path is None else self._com_find_document(app, name_or_path)
            name = str(doc.Name)
            path = str(doc.FullName) or None
            dirty = not bool(doc.Saved)
            if dirty:
                if save:
                    if not path:
                        raise ValueError(
                            f"document_close: {name!r} has never been saved; Close(True) would "
                            "open AutoCAD's Save dialog and block the COM thread. "
                            "drawing_save_as(path) first, or pass discard=True"
                        )
                elif not discard:
                    where = f" to {path}" if path else " (after drawing_save_as)"
                    raise ValueError(
                        f"document_close: {name!r} has unsaved changes; pass save=True to "
                        f"write them{where}, or discard=True to drop them"
                    )
            # A clean or untitled document is never asked to save: Close(True)
            # on a clean file is a no-op write, on an untitled one a dialog.
            doc.Close(bool(save and dirty and path))
            count = int(app.Documents.Count)
            return {
                "ok": True,
                "closed": name,
                "path": path,
                "saved": bool(save and dirty and path),
                "discarded_changes": bool(dirty and not save),
                "active": str(app.ActiveDocument.Name) if count else None,
                "open_documents": count,
                "backend": "com",
            }

        result = await self._run(_sync)
        await self._ensure_document_state()
        return result

Run: uv run --frozen pytest tests/test_documents.py tests/test_capability_contract.py tests/test_com_backend.py -v Expected: all PASS (the capability gate proves both backends instantiate and that dwgprops — the temporary key — is declared in both maps).

  • Step 6: Register the three tools, alias records, README, inventory, registry snapshot

In server.py, before the # ── RESOURCES block:

# ---------------------------------------------------------------------------
# ── SECTION 20: Environment (3 tools) ───────────────────────────────────────
# ---------------------------------------------------------------------------


@cad_tool(
    summary="List the open documents: which is active, which have unsaved changes.",
    cost="read",
)
@mcp.tool(
    annotations={"title": "List Documents", "readOnlyHint": True},
    tags={"drawing"},
)
async def document_list(ctx: Context = None) -> dict:
    """Every open document with `name`, `path`, `active`, `saved` and
    `entity_count`.

    Headless, the server keeps its own registry: `drawing_new` / `drawing_open`
    add an entry (`untitled-N` or the file path) instead of replacing the only
    one, and `document_activate` chooses which one every later tool targets.
    Live, this is AutoCAD's `Documents` collection. No refusals: an empty
    backend answers with an empty list. Pack: settings · lean: no.
    """
    rows = await _backend(ctx).document_list()
    return {
        "documents": rows,
        "count": len(rows),
        "active": next((r["name"] for r in rows if r["active"]), None),
    }


@cad_tool(summary="Switch which open document every later tool call targets.", cost="safe")
@mcp.tool(
    annotations={"title": "Activate Document", "readOnlyHint": False, "destructiveHint": False},
    tags={"drawing"},
)
async def document_activate(
    name_or_path: Annotated[
        str, "A name from document_list (`gear.dxf`, `untitled-2`) or the full path"
    ],
    ctx: Context = None,
) -> dict:
    """Make one open document the active one; reports `previous`.

    Matches the full path first, then the file name; an ambiguous name is
    refused with the candidates, an unknown one with the list of open
    documents. Headless, a document quarantined after an abandoned call cannot
    be switched away from — `drawing_new`, `drawing_open` or closing it are
    the ways out. Pack: settings · lean: no.
    """
    await ctx.info(f"Activating document {name_or_path!r}")
    return await _backend(ctx).document_activate(name_or_path)


@cad_tool(
    summary="Close one open document by name; never drops unsaved work unless told to.",
    cost="destructive",
)
@mcp.tool(
    annotations={"title": "Close Document", "destructiveHint": True},
    tags={"drawing"},
)
async def document_close(
    name_or_path: Annotated[
        str | None, "A name from document_list or the full path; omit for the active document"
    ] = None,
    save: Annotated[bool, "Write unsaved changes to the document's own path first"] = False,
    discard: Annotated[bool, "Close even with unsaved changes, dropping them"] = False,
    ctx: Context = None,
) -> dict:
    """Close a document and report `saved`, `discarded_changes` and the new `active`.

    Refusals, all before anything is closed: unsaved changes with neither
    `save` nor `discard`; `save` on a document that has never been saved (no
    path to write — live, `Close(True)` would open AutoCAD's Save dialog and
    block the COM thread); `save` together with `discard`. Closing the last
    document leaves the backend with none open, which every tool already
    reports. `drawing_close` is the same operation for the active document
    with its 1.4 contract kept. Pack: settings · lean: no.
    """
    await ctx.info(f"Closing document {name_or_path or '(active)'} (save={save}, discard={discard})")
    return await _backend(ctx).document_close(name_or_path, save, discard)

Update the existing drawing_close docstring in SECTION 1 to name the fold:

    """Close the active document. If save is True (default), the drawing is
    saved to its current path before closing; an untitled document is closed
    and `warning` says its changes were discarded (headless) or the call is
    refused (live, where the alternative is a modal Save dialog). The
    active-document case of `document_close`; after the last document is
    closed, call drawing_new or drawing_open before any other tool."""

In discovery/aliases.py, add "CLOSE" to SHARED_ACAD_COMMANDS with a comment line in the block above it:

#   CLOSE -- the active document (drawing_close) or one named document
#       (document_close); a drafter typing it could mean either.

and add a new alias block after the P&ID block, alphabetical within it:

    # ── Environment (track E, group V) ──────────────────────────────────────
    "document_activate": ToolAliases(
        acad=(),
        synonyms=(
            "switch drawing",
            "switch to the other drawing",
            "make this drawing active",
            "go to the other open file",
            "change the current document",
        ),
    ),
    "document_close": ToolAliases(
        acad=("CLOSE",),
        synonyms=(
            "close a drawing by name",
            "close the other drawing",
            "close without saving",
            "discard changes and close",
            "close all but this one",
        ),
    ),
    "document_list": ToolAliases(
        acad=(),
        synonyms=(
            "which drawings are open",
            "open drawings",
            "list documents",
            "what files are open",
            "which one is active",
        ),
    ),

README.md: snapshot line 166 tools → 169 tools; add a row to the "What you get" table after Paper space:

| Environment | `document_list/activate/close` — several drawings open at once on both engines, the active one is what every tool targets |

tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged: "drawing": 11 → 14, the total 166 → 169, and one sentence in the docstring: "drawing moved 11 -> 14 when v1.6's document_list / document_activate / document_close (multi-document on both engines) opened SECTION 20."

Then: uv run --frozen python scripts/generate_tool_inventory.py.

  • Step 7: Run the gates

Run: uv run --frozen pytest tests/test_documents.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_tool_packs.py -v Expected: all PASS. (test_document_numbers_match_the_tree failing only on the README collected tests figure is the pre-existing drift the roadmap memory records — leave it for the final task.)

  • Step 8: Commit
git add backends/contracts/environment.py backends/contracts/__init__.py backends/base.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_documents.py
git commit -m "feat(environment): multi-document on both engines — ezdxf document registry, document_list/activate/close, drawing_close folded in

EnvironmentContract carries every member of the track E interface; the two
drawing_properties_* members are @capability('dwgprops') in this worktree only
and become @abstractmethod when group C's implementations merge.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 22: Layer states, named views and UCS on both engines

Files:

  • Create: engineering/environment/__init__.py, engineering/environment/names.py, engineering/environment/layer_states.py, engineering/environment/views.py, engineering/environment/ucs.py
  • Modify: backends/contracts/environment.py (append the ten abstract members at the marked insertion point)
  • Modify: backends/ezdxf_backend.py (environment block: layer_state_*, view_named_*, ucs_*; capability map)
  • Modify: backends/com_backend.py (environment block: same ten + module-level _avar; capability map)
  • Modify: server.py (SECTION 20 header (3 tools) → (13 tools); ten tools), discovery/aliases.py (ten records; LAYERSTATE, VIEW, UCS in SHARED_ACAD_COMMANDS), README.md (169 tools → 179 tools; Environment row), docs/tool-inventory.json, tests/test_tool_registry.py (layers 14 → 18, view 4 → 10, total 179)
  • Test: tests/test_layer_states.py, tests/test_views_ucs.py

Interfaces:

  • Produces (engineering/environment/layer_states.py): DICT_NAME = "ACADMCP_LAYERSTATES", PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current"), snapshot_from_layers(layers: list[LayerInfo], current_layer: str, *, plot: dict[str, bool] | None = None, description: str | None = None) -> dict, encode_state(state) -> list[str] (JSON, ASCII-escaped, chunks ≤ 255 characters), decode_state(chunks) -> dict (ValueError on anything that is not a version-1 state), diff_snapshot(state, layers) -> (missing, new), validate_properties(properties) -> tuple[str, ...], validate_state_name(name) -> str. The plan's signature for snapshot_from_layers is kept; plot is a keyword extension because LayerInfo carries no plot flag and the engines read it themselves (layer.dxf.plot / Plottable).
  • Produces (engineering/environment/names.py): validate_name(name, *, what: str, max_len: int = 255) -> str — non-empty string, stripped, no control characters, ValueError naming what.
  • Produces (engineering/environment/views.py): resolve_view_args(center, height, width) -> dict ({"center": (x, y) | None, "height": float | None, "width": float | None}; height/width ≤ 0 or non-finite → ValueError).
  • Produces (engineering/environment/ucs.py): WORLD = "world", resolve_ucs_axes(origin, x_axis, y_axis) -> dict ({"origin", "x_axis", "y_axis", "z_axis", "angle_deg"}, unit vectors; non-orthogonal → ValueError carrying the measured angle; a zero vector → ValueError).
  • Backend methods: the ten EnvironmentContract members, shapes per the Interfaces block. view_named_restore on ezdxf returns "applied": "header_only" (the plan's literal) plus "store": "vport_active" — see the measured note. ucs_set refuses before any write on both engines.
  • EzdxfBackend._active_vport(doc) is group C's helper (Task 14, # ── settings (track E, group C) ── block; grid and snap live on the same *Active VPORT entry). C's definition ships. This task carries a byte-equivalent copy marked # TEMPORARY (track-e/environment worktree only) so the worktree runs without C's code; the merge agent deletes V's copy (two definitions in one class are ruff F811 and the later one silently wins). V's call sites (self._active_vport(doc)) are unchanged by the deletion.

Measured before writing (2026-09-16, ezdxf 1.4.4): doc.header["$VIEWCTR"] = ... raises DXFKeyError: Invalid header variable $VIEWCTR — ezdxf's header table has no $VIEWCTR/$VIEWSIZE (in DXF they are VPORT properties, not header variables). The headless store for "the view the file opens on" is the *Active entry of doc.viewports (center, height, aspect_ratio; defaults (0, 0), 1000, 1.34; doc.viewports.get("*Active") returns a list, doc.viewports.new("*Active") adds a second entry, so create only when the list is empty), and it survives save/reopen. doc.views.add(name, dxfattribs={center, height, width}) replaces an existing name silently (one entry, a new handle), lookup is case-insensitive, entries survive reopen. doc.ucs.add(name, dxfattribs={origin, xaxis, yaxis}) plus $UCSNAME / $UCSORG / $UCSXDIR / $UCSYDIR (all valid header variables; defaults '', (0,0,0), (1,0,0), (0,1,0)) survive reopen. doc.rootdict.get_required_dict(name) creates or returns the dictionary; add_xrecord(key) + extend([(1000, chunk), ...]) round-trips through save/reopen; remove(key) destroys the entry; ezdxf accepts a 300-character 1000-tag and writes it back unchanged, so the ≤ 255 rule is ours (AutoCAD's SetXRecordData limit), not the library's. Layer.color = n preserves the on/off sign (dxf.color stays negative while off); Layer.dxf.plot is 1 by default; Layer.freeze()/thaw()/lock()/unlock()/on()/off() exist. Dictionary keys are case-sensitive in ezdxf ('state_a' in d is False after adding State_A), so lookups scan for a case-insensitive match (AutoCAD's dictionaries are case-insensitive).

  • Step 1: Write the failing tests
# tests/test_layer_states.py
"""Portable layer states: save → change → restore equals the snapshot.

These are the server's own layer states — JSON chunks in an XRECORD under
``ACADMCP_LAYERSTATES`` in the drawing's named object dictionary. They live
in the file and travel with it; they do NOT appear in AutoCAD's Layer States
Manager. The codec is engine-neutral, so the ezdxf tests prove the format and
the COM tests prove the ActiveX call shapes.
"""

from __future__ import annotations

import json
import types

import pytest

from backends.base import LayerInfo
from engineering.environment.layer_states import (
    DICT_NAME,
    PROPERTIES,
    decode_state,
    diff_snapshot,
    encode_state,
    snapshot_from_layers,
    validate_properties,
)

pytestmark = pytest.mark.asyncio


def _layer(name, **overrides) -> LayerInfo:
    base = dict(
        name=name,
        color=7,
        linetype="Continuous",
        lineweight=-3,
        is_on=True,
        is_frozen=False,
        is_locked=False,
        is_current=False,
    )
    base.update(overrides)
    return LayerInfo(**base)


# ── codec ───────────────────────────────────────────────────────────────────


def test_snapshot_records_every_property_and_the_current_layer():
    state = snapshot_from_layers(
        [_layer("0"), _layer("HIDDEN", color=8, linetype="HIDDEN", lineweight=35, is_frozen=True)],
        "HIDDEN",
        plot={"HIDDEN": False},
        description="before plotting",
    )
    assert state == {
        "version": 1,
        "description": "before plotting",
        "current_layer": "HIDDEN",
        "layers": {
            "0": {
                "on": True, "frozen": False, "locked": False, "color": 7,
                "linetype": "Continuous", "lineweight": -3, "plot": True,
            },
            "HIDDEN": {
                "on": True, "frozen": True, "locked": False, "color": 8,
                "linetype": "HIDDEN", "lineweight": 35, "plot": False,
            },
        },
    }


def test_encode_chunks_are_at_most_255_ascii_characters_and_round_trip():
    layers = [_layer(f"LAYER-{i:03d}-{'x' * 40}") for i in range(60)]
    state = snapshot_from_layers(layers, "0")
    chunks = encode_state(state)
    assert len(chunks) > 5
    assert all(len(c) <= 255 and c.isascii() for c in chunks)
    assert decode_state(chunks) == state


def test_unicode_layer_names_survive_the_ascii_chunking():
    state = snapshot_from_layers([_layer("KÖRPER-Ø20")], "KÖRPER-Ø20")
    chunks = encode_state(state)
    assert all(c.isascii() for c in chunks), "escaped so a chunk boundary never splits a code point"
    assert decode_state(chunks)["current_layer"] == "KÖRPER-Ø20"


@pytest.mark.parametrize(
    "chunks, fragment",
    [
        (["not json"], "not valid JSON"),
        ([json.dumps({"version": 2, "layers": {}})], "version"),
        ([json.dumps({"version": 1, "layers": {"A": {"on": True}}})], "lacks"),
        ([json.dumps([1, 2])], "version"),
    ],
)
def test_decode_refuses_anything_that_is_not_a_state(chunks, fragment):
    with pytest.raises(ValueError, match=fragment):
        decode_state(chunks)


def test_diff_reports_missing_and_new_layers_sorted():
    state = snapshot_from_layers([_layer("A"), _layer("B"), _layer("C")], "0")
    missing, new = diff_snapshot(state, [_layer("C"), _layer("Z"), _layer("A"), _layer("Y")])
    assert missing == ["B"] and new == ["Y", "Z"]


def test_validate_properties_defaults_to_all_and_names_the_bad_one():
    assert validate_properties(None) == PROPERTIES
    assert validate_properties(["color", "on", "color"]) == ("color", "on")
    with pytest.raises(ValueError, match=r"properties\[1\]: 'colour'"):
        validate_properties(["on", "colour"])
    with pytest.raises(TypeError, match="list"):
        validate_properties("on")
    with pytest.raises(ValueError, match="at least one"):
        validate_properties([])


# ── headless engine ─────────────────────────────────────────────────────────


async def _seed(backend):
    await backend.layer_create("GEOMETRY", color=7, linetype="Continuous", lineweight=50)
    await backend.layer_create("HIDDEN", color=8, linetype="HIDDEN", lineweight=35)
    await backend.layer_create("CENTER", color=1, linetype="CENTER", lineweight=25)
    await backend.layer_set_current("GEOMETRY")
    await backend.layer_freeze("CENTER")
    await backend.layer_hide("HIDDEN")


async def _table(backend) -> dict:
    return {
        lyr.name: (
            lyr.color, lyr.linetype, lyr.lineweight,
            lyr.is_on, lyr.is_frozen, lyr.is_locked, lyr.is_current,
        )
        for lyr in await backend.layer_list()
    }


async def test_save_change_restore_equals_the_snapshot(backend):
    await _seed(backend)
    before = await _table(backend)
    saved = await backend.layer_state_save("PLOT", description="plot set")
    # measured: ezdxf.new() already has "0" and "Defpoints", so the seeded
    # table is five layers and serialises to three <= 255-character chunks
    assert saved == {
        "ok": True, "name": "PLOT", "layer_count": 5, "replaced": False, "chunks": 3, "backend": "ezdxf",
    }

    await backend.layer_thaw("CENTER")
    await backend.layer_show("HIDDEN")
    await backend.layer_lock("GEOMETRY")
    await backend.layer_modify("HIDDEN", color=3, linetype="Continuous", lineweight=13)
    await backend.layer_set_current("0")
    assert await _table(backend) != before

    restored = await backend.layer_state_restore("PLOT")
    assert restored["applied"] == {
        "layers": 5, "properties": list(PROPERTIES), "current_layer": "GEOMETRY",
    }
    assert restored["missing_layers"] == [] and restored["new_layers"] == []
    assert await _table(backend) == before


async def test_restore_applies_only_the_requested_properties(backend):
    await _seed(backend)
    await backend.layer_state_save("S")
    await backend.layer_thaw("CENTER")
    await backend.layer_modify("CENTER", color=5)
    result = await backend.layer_state_restore("S", properties=["color"])
    assert result["applied"]["properties"] == ["color"]
    center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
    assert center.color == 1 and center.is_frozen is False, "frozen was not requested"


async def test_restore_reports_missing_and_new_layers_and_never_creates(backend):
    await _seed(backend)
    await backend.layer_state_save("S")
    await backend.layer_delete("CENTER")
    await backend.layer_create("NEW-ONE")
    result = await backend.layer_state_restore("S")
    assert result["missing_layers"] == ["CENTER"] and result["new_layers"] == ["NEW-ONE"]
    assert "CENTER" not in [lyr.name for lyr in await backend.layer_list()]


async def test_plot_flag_and_current_layer_round_trip(backend):
    await _seed(backend)
    backend._doc.layers.get("HIDDEN").dxf.plot = 0
    await backend.layer_state_save("S")
    backend._doc.layers.get("HIDDEN").dxf.plot = 1
    await backend.layer_set_current("0")
    await backend.layer_state_restore("S", properties=["plot", "current"])
    assert backend._doc.layers.get("HIDDEN").dxf.plot == 0
    assert backend._current_layer == "GEOMETRY"
    assert backend._doc.header["$CLAYER"] == "GEOMETRY"


async def test_state_survives_save_and_reopen(backend, tmp_path):
    await _seed(backend)
    await backend.layer_state_save("PLOT", description="d")
    path = tmp_path / "states.dxf"
    await backend.drawing_save(str(path))
    await backend.document_close(None)
    await backend.drawing_open(str(path))
    assert await backend.layer_state_list() == [
        {"name": "PLOT", "description": "d", "layer_count": 5}
    ]
    await backend.layer_thaw("CENTER")
    await backend.layer_state_restore("PLOT")
    center = next(lyr for lyr in await backend.layer_list() if lyr.name == "CENTER")
    assert center.is_frozen is True


async def test_xrecord_chunks_stay_under_256_characters(backend):
    for i in range(80):
        await backend.layer_create(f"LONG-LAYER-NAME-{i:03d}-{'x' * 30}")
    saved = await backend.layer_state_save("BIG")
    assert saved["chunks"] > 10
    xrecord = backend._doc.rootdict.get(DICT_NAME).get("BIG")
    tags = [t for t in xrecord.tags if t.code == 1000]
    assert len(tags) == saved["chunks"]
    assert all(len(t.value) <= 255 for t in tags)


async def test_replace_delete_and_missing_names(backend):
    await backend.layer_state_save("S")
    again = await backend.layer_state_save("s")
    assert again["replaced"] is True, "AutoCAD dictionaries are case-insensitive"
    assert [row["name"] for row in await backend.layer_state_list()] == ["s"]
    assert await backend.layer_state_delete("S") == {"ok": True, "deleted": "s", "backend": "ezdxf"}
    assert await backend.layer_state_list() == []
    with pytest.raises(ValueError, match="no layer state named 'S'"):
        await backend.layer_state_restore("S")
    with pytest.raises(ValueError, match="no layer state named 'S'"):
        await backend.layer_state_delete("S")


async def test_refusals_happen_before_any_write(backend):
    with pytest.raises(ValueError, match="layer state name"):
        await backend.layer_state_save("   ")
    with pytest.raises(TypeError, match="description"):
        await backend.layer_state_save("S", description=3)
    assert DICT_NAME not in backend._doc.rootdict, "a refused save creates no dictionary"
    await backend.layer_state_save("S")
    with pytest.raises(ValueError, match=r"properties\[0\]: 'plottable'"):
        await backend.layer_state_restore("S", properties=["plottable"])
    assert len(list(backend._doc.rootdict.get(DICT_NAME).keys())) == 1


# ── live engine, against a fake ActiveX surface ──────────────────────────────


class _FakeXRecord:
    def __init__(self, name):
        self.name = name
        self.types = None
        self.values = None
        self.deleted = False

    def SetXRecordData(self, types_, values):
        self.types, self.values = list(types_), list(values)

    def GetXRecordData(self):
        return self.types, self.values

    def Delete(self):
        self.deleted = True


class _FakeDict:
    def __init__(self, name):
        self.Name = name
        self.entries: list[_FakeXRecord] = []
        self.calls: list[tuple[str, tuple]] = []

    @property
    def Count(self):
        return len(self.entries)

    def Item(self, index):
        return self.entries[index]

    def GetName(self, obj):
        return obj.name

    def AddXRecord(self, keyword):
        self.calls.append(("AddXRecord", (keyword,)))
        rec = _FakeXRecord(keyword)
        self.entries.append(rec)
        return rec

    def Remove(self, name):
        self.calls.append(("Remove", (name,)))
        rec = next(e for e in self.entries if e.name == name)
        self.entries.remove(rec)
        return rec


class _FakeDictionaries:
    def __init__(self):
        self.dicts: dict[str, _FakeDict] = {}
        self.calls: list[tuple[str, tuple]] = []

    def Item(self, name):
        self.calls.append(("Item", (name,)))
        if name not in self.dicts:
            raise RuntimeError(f"no dictionary {name}")
        return self.dicts[name]

    def Add(self, name):
        self.calls.append(("Add", (name,)))
        self.dicts[name] = _FakeDict(name)
        return self.dicts[name]


class _FakeLayer:
    def __init__(self, name, **props):
        self.Name = name
        self.Color = props.get("color", 7)
        self.Linetype = props.get("linetype", "Continuous")
        self.LineWeight = props.get("lineweight", -3)
        self.LayerOn = props.get("on", True)
        self.Freeze = props.get("frozen", False)
        self.Lock = props.get("locked", False)
        self.Plottable = props.get("plot", True)


class _FakeLayers:
    def __init__(self, layers):
        self._layers = layers

    @property
    def Count(self):
        return len(self._layers)

    def Item(self, key):
        if isinstance(key, int):
            return self._layers[key]
        for lyr in self._layers:
            if lyr.Name == key:
                return lyr
        raise RuntimeError(f"no layer {key}")


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    layers = [
        _FakeLayer("0"),
        _FakeLayer("GEOMETRY", lineweight=50),
        _FakeLayer("HIDDEN", color=8, linetype="HIDDEN", frozen=True, plot=False),
    ]
    document = types.SimpleNamespace(
        Dictionaries=_FakeDictionaries(),
        Layers=_FakeLayers(layers),
        ActiveLayer=layers[1],
    )
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    monkeypatch.setattr(module, "_ensure_linetype_loaded", lambda name: None)
    monkeypatch.setattr(module, "_regen", lambda: None)
    monkeypatch.setattr(module, "_ai", lambda values: list(values))
    monkeypatch.setattr(module, "_avar", lambda values: list(values))
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document


async def test_com_save_creates_the_dictionary_and_one_xrecord_of_1000_chunks(com_backend):
    backend, document = com_backend
    result = await backend.layer_state_save("PLOT", description="d")
    assert result["ok"] and result["backend"] == "com" and result["replaced"] is False
    assert document.Dictionaries.calls == [("Item", (DICT_NAME,)), ("Add", (DICT_NAME,))]
    states = document.Dictionaries.dicts[DICT_NAME]
    assert states.calls == [("AddXRecord", ("PLOT",))]
    (rec,) = states.entries
    assert rec.types == [1000] * len(rec.values)
    assert all(len(v) <= 255 for v in rec.values)
    state = decode_state(rec.values)
    assert state["current_layer"] == "GEOMETRY"
    assert state["layers"]["HIDDEN"]["plot"] is False
    again = await backend.layer_state_save("plot")
    assert again["replaced"] is True and again["name"] == "PLOT"
    assert len(states.entries) == 1, "SetXRecordData on the existing record, no second AddXRecord"


async def test_com_restore_reads_the_xrecord_and_writes_layer_properties(com_backend):
    backend, document = com_backend
    await backend.layer_state_save("S")
    hidden = document.Layers.Item("HIDDEN")
    hidden.Freeze = False
    hidden.Color = 3
    hidden.Plottable = True
    document.ActiveLayer = document.Layers.Item("0")
    result = await backend.layer_state_restore("S")
    assert result["applied"]["layers"] == 3
    assert result["applied"]["current_layer"] == "GEOMETRY"
    assert hidden.Freeze is True and hidden.Color == 8 and hidden.Plottable is False
    assert document.ActiveLayer.Name == "GEOMETRY"
    assert result["missing_layers"] == [] and result["new_layers"] == []


async def test_com_list_and_delete(com_backend):
    backend, document = com_backend
    await backend.layer_state_save("A", description="one")
    await backend.layer_state_save("B")
    assert await backend.layer_state_list() == [
        {"name": "A", "description": "one", "layer_count": 3},
        {"name": "B", "description": None, "layer_count": 3},
    ]
    assert (await backend.layer_state_delete("a"))["deleted"] == "A"
    states = document.Dictionaries.dicts[DICT_NAME]
    assert states.calls[-1] == ("Remove", ("A",))
    with pytest.raises(ValueError, match="no layer state named 'A'"):
        await backend.layer_state_delete("A")
# tests/test_views_ucs.py
"""Named views and UCS on both engines.

Headless, a named view is a VIEW table entry and "restore" writes the
``*Active`` VPORT (the view the file opens on) — ezdxf's header has no
``$VIEWCTR``; measured. A UCS is a UCS table entry plus the ``$UCS*`` header
variables. Tool coordinates stay WCS on both engines: a UCS is stored and
made current for the operator, and no tool interprets its inputs in it.
"""

from __future__ import annotations

import types

import pytest

from engineering.environment.ucs import resolve_ucs_axes
from engineering.environment.views import resolve_view_args

pytestmark = pytest.mark.asyncio


# ── pure helpers ────────────────────────────────────────────────────────────


def test_view_args_validate_before_any_write():
    assert resolve_view_args([10, 20], 50, None) == {
        "center": (10.0, 20.0), "height": 50.0, "width": None,
    }
    assert resolve_view_args(None, None, None) == {"center": None, "height": None, "width": None}
    with pytest.raises(ValueError, match="height must be > 0"):
        resolve_view_args(None, 0, None)
    with pytest.raises(ValueError, match="width must be > 0"):
        resolve_view_args(None, 10, -1)
    with pytest.raises(ValueError, match="center"):
        resolve_view_args([1], None, None)
    with pytest.raises(ValueError, match="finite"):
        resolve_view_args([float("nan"), 0], None, None)


def test_ucs_axes_are_normalised_and_orthogonality_is_measured():
    axes = resolve_ucs_axes([10, 10, 0], [0, 2, 0], [-3, 0, 0])
    assert axes["origin"] == (10.0, 10.0, 0.0)
    assert axes["x_axis"] == (0.0, 1.0, 0.0) and axes["y_axis"] == (-1.0, 0.0, 0.0)
    assert axes["z_axis"] == (0.0, 0.0, 1.0)
    assert axes["angle_deg"] == pytest.approx(90.0)
    with pytest.raises(ValueError, match=r"measured 45\.00"):
        resolve_ucs_axes([0, 0, 0], [1, 0, 0], [1, 1, 0])
    with pytest.raises(ValueError, match="x_axis.*zero"):
        resolve_ucs_axes([0, 0, 0], [0, 0, 0], [0, 1, 0])
    with pytest.raises(ValueError, match="origin"):
        resolve_ucs_axes([0, 0], [1, 0, 0], [0, 1, 0])


# ── headless: named views ───────────────────────────────────────────────────


async def test_view_defaults_to_the_drawing_extents(backend):
    await backend.entity_create_line(0, 0, 100, 50)
    result = await backend.view_named_save("OVERALL")
    assert result["ok"] and result["replaced"] is False
    assert result["center"] == [50.0, 25.0]
    # extents 100 x 50 at the *Active VPORT's default aspect 1.34: the width
    # is the binding side, so height = 100 / 1.34
    assert result["height"] == pytest.approx(100.0 / 1.34)
    assert result["width"] == pytest.approx(100.0)
    view = backend._doc.views.get("OVERALL")
    assert tuple(view.dxf.center)[:2] == (50.0, 25.0)


async def test_view_on_an_empty_drawing_needs_an_explicit_window(backend):
    with pytest.raises(ValueError, match="no extents"):
        await backend.view_named_save("EMPTY")
    assert "EMPTY" not in backend._doc.views
    result = await backend.view_named_save("EMPTY", center=[0, 0], height=10)
    assert result["center"] == [0.0, 0.0] and result["height"] == 10.0
    assert result["width"] == pytest.approx(13.4)


async def test_view_replace_restore_and_list(backend):
    await backend.view_named_save("V", center=[1, 2], height=10, width=15)
    again = await backend.view_named_save("v", center=[5, 5], height=20)
    assert again["replaced"] is True and again["name"] == "V"
    assert len(list(backend._doc.views)) == 1
    restored = await backend.view_named_restore("v")
    assert restored == {
        "ok": True,
        "name": "V",
        "center": [5.0, 5.0],
        "height": 20.0,
        "width": pytest.approx(26.8),
        "applied": "header_only",
        "store": "vport_active",
        "backend": "ezdxf",
    }
    (vport,) = backend._doc.viewports.get("*Active")
    assert tuple(vport.dxf.center)[:2] == (5.0, 5.0) and vport.dxf.height == 20.0
    assert await backend.view_named_list() == [
        {"name": "V", "center": [5.0, 5.0], "height": 20.0, "width": pytest.approx(26.8)}
    ]
    with pytest.raises(ValueError, match="no named view 'NOPE'"):
        await backend.view_named_restore("NOPE")
    with pytest.raises(ValueError, match="height must be > 0"):
        await backend.view_named_save("BAD", center=[0, 0], height=0)
    assert "BAD" not in backend._doc.views


# ── headless: UCS ───────────────────────────────────────────────────────────


async def test_ucs_set_stores_the_entry_and_makes_it_current(backend):
    result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
    assert result == {
        "ok": True,
        "name": "FRONT",
        "origin": [10.0, 10.0, 0.0],
        "x_axis": [0.0, 1.0, 0.0],
        "y_axis": [-1.0, 0.0, 0.0],
        "replaced": False,
        "current": True,
        "backend": "ezdxf",
    }
    header = backend._doc.header
    assert header["$UCSNAME"] == "FRONT" and tuple(header["$UCSORG"]) == (10.0, 10.0, 0.0)
    assert tuple(header["$UCSXDIR"]) == (0.0, 1.0, 0.0)
    rows = await backend.ucs_list()
    assert rows[0] == {
        "name": "world",
        "origin": [0.0, 0.0, 0.0],
        "x_axis": [1.0, 0.0, 0.0],
        "y_axis": [0.0, 1.0, 0.0],
        "current": False,
    }
    assert rows[1]["name"] == "FRONT" and rows[1]["current"] is True


async def test_ucs_non_orthogonal_is_refused_before_any_write(backend):
    with pytest.raises(ValueError, match="measured 60"):
        await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [0.5, 0.8660254, 0])
    assert "SKEW" not in backend._doc.ucs and backend._doc.header["$UCSNAME"] == ""


async def test_ucs_restore_world_and_named(backend):
    await backend.ucs_set("FRONT", [10, 10, 0], [0, 1, 0], [-1, 0, 0])
    world = await backend.ucs_restore("world")
    assert world["name"] == "world" and backend._doc.header["$UCSNAME"] == ""
    assert tuple(backend._doc.header["$UCSXDIR"]) == (1.0, 0.0, 0.0)
    assert (await backend.ucs_list())[0]["current"] is True
    named = await backend.ucs_restore("front")
    assert named["name"] == "FRONT" and backend._doc.header["$UCSNAME"] == "FRONT"
    with pytest.raises(ValueError, match="no UCS named 'NOPE'"):
        await backend.ucs_restore("NOPE")
    with pytest.raises(ValueError, match="reserved"):
        await backend.ucs_set("world", [0, 0, 0], [1, 0, 0], [0, 1, 0])


async def test_ucs_replace_keeps_one_entry(backend):
    await backend.ucs_set("A", [0, 0, 0], [1, 0, 0], [0, 1, 0])
    again = await backend.ucs_set("a", [1, 1, 0], [1, 0, 0], [0, 1, 0])
    assert again["replaced"] is True and again["name"] == "A"
    assert len(list(backend._doc.ucs)) == 1


# ── live engine, against a fake ActiveX surface ──────────────────────────────


class _FakeView:
    def __init__(self, name):
        self.Name = name
        self.Center = (0.0, 0.0)
        self.Height = 100.0
        self.Width = 150.0


class _FakeViews:
    def __init__(self):
        self.views: list[_FakeView] = []
        self.calls: list[tuple[str, tuple]] = []

    @property
    def Count(self):
        return len(self.views)

    def Item(self, key):
        if isinstance(key, int):
            return self.views[key]
        for v in self.views:
            if v.Name.lower() == key.lower():
                return v
        raise RuntimeError(f"no view {key}")

    def Add(self, name):
        self.calls.append(("Add", (name,)))
        v = _FakeView(name)
        self.views.append(v)
        return v


class _FakeUcs:
    def __init__(self, name, origin, xv, yv):
        self.Name = name
        self.Origin = origin
        self.XVector = xv
        self.YVector = yv


class _FakeUcsCollection:
    def __init__(self):
        self.items: list[_FakeUcs] = []
        self.calls: list[tuple[str, tuple]] = []

    @property
    def Count(self):
        return len(self.items)

    def Item(self, key):
        if isinstance(key, int):
            return self.items[key]
        for u in self.items:
            if u.Name.lower() == key.lower():
                return u
        raise RuntimeError(f"no ucs {key}")

    def Add(self, origin, x_point, y_point, name):
        self.calls.append(("Add", (origin, x_point, y_point, name)))
        # ActiveX stores unit vectors derived from the two points.
        xv = tuple(p - o for p, o in zip(x_point, origin))
        yv = tuple(p - o for p, o in zip(y_point, origin))
        u = _FakeUcs(name, origin, xv, yv)
        self.items.append(u)
        return u


class _FakeDocument:
    def __init__(self):
        self.Views = _FakeViews()
        self.UserCoordinateSystems = _FakeUcsCollection()
        self._viewport = types.SimpleNamespace(Center=(30.0, 40.0), Height=100.0, Width=150.0)
        self.viewport_sets = 0
        self.ActiveUCS = None
        self.commands: list[str] = []

    @property
    def ActiveViewport(self):
        return self._viewport

    @ActiveViewport.setter
    def ActiveViewport(self, value):
        self._viewport = value
        self.viewport_sets += 1

    def SendCommand(self, macro):
        self.commands.append(macro)


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    document = _FakeDocument()
    variables = {"CMDACTIVE": 0, "UCSNAME": ""}
    app = types.SimpleNamespace(GetVariable=lambda name: variables[name])
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    monkeypatch.setattr(module, "_regen", lambda: None)
    monkeypatch.setattr(module, "_apoint", lambda x, y, z=0.0: (float(x), float(y), float(z)))
    monkeypatch.setattr(module, "_av", lambda values: tuple(float(v) for v in values))
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, document, variables


async def test_com_view_save_defaults_to_the_active_viewport(com_backend):
    backend, document, _ = com_backend
    result = await backend.view_named_save("HOME")
    assert document.Views.calls == [("Add", ("HOME",))]
    (view,) = document.Views.views
    assert view.Center == (30.0, 40.0) and view.Height == 100.0 and view.Width == 150.0
    assert result["center"] == [30.0, 40.0] and result["replaced"] is False
    again = await backend.view_named_save("home", center=[1, 2], height=10)
    assert again["replaced"] is True and len(document.Views.views) == 1
    assert view.Center == (1.0, 2.0) and view.Height == 10.0 and view.Width == 15.0


async def test_com_view_restore_sets_the_active_viewport(com_backend):
    backend, document, _ = com_backend
    await backend.view_named_save("HOME", center=[5, 6], height=20, width=30)
    result = await backend.view_named_restore("HOME")
    assert result["applied"] == "active_viewport"
    assert document.ActiveViewport.Center == (5.0, 6.0)
    assert document.ActiveViewport.Height == 20.0 and document.ActiveViewport.Width == 30.0
    assert document.viewport_sets == 1, "ActiveX applies a viewport change only on re-assignment"
    assert await backend.view_named_list() == [
        {"name": "HOME", "center": [5.0, 6.0], "height": 20.0, "width": 30.0}
    ]


async def test_com_ucs_add_uses_points_on_the_axes_not_vectors(com_backend):
    backend, document, variables = com_backend
    result = await backend.ucs_set("FRONT", [10, 10, 0], [0, 5, 0], [-1, 0, 0])
    assert document.UserCoordinateSystems.calls == [
        ("Add", ((10.0, 10.0, 0.0), (10.0, 11.0, 0.0), (9.0, 10.0, 0.0), "FRONT"))
    ]
    assert document.ActiveUCS is document.UserCoordinateSystems.items[0]
    assert result["x_axis"] == [0.0, 1.0, 0.0] and result["current"] is True
    variables["UCSNAME"] = "FRONT"
    rows = await backend.ucs_list()
    assert rows[0]["name"] == "world" and rows[0]["current"] is False
    assert rows[1] == {
        "name": "FRONT",
        "origin": [10.0, 10.0, 0.0],
        "x_axis": [0.0, 1.0, 0.0],
        "y_axis": [-1.0, 0.0, 0.0],
        "current": True,
    }


async def test_com_ucs_restore_world_sends_the_ucs_command_when_idle(com_backend):
    backend, document, variables = com_backend
    result = await backend.ucs_restore("world")
    assert document.commands == ["_.UCS _W\n"] and result["name"] == "world"
    variables["CMDACTIVE"] = 1
    with pytest.raises(RuntimeError, match="CMDACTIVE"):
        await backend.ucs_restore("world")
    assert len(document.commands) == 1


async def test_com_ucs_refuses_before_activex(com_backend):
    backend, document, _ = com_backend
    with pytest.raises(ValueError, match="measured"):
        await backend.ucs_set("SKEW", [0, 0, 0], [1, 0, 0], [1, 1, 0])
    assert document.UserCoordinateSystems.calls == []
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -v Expected: FAIL at collection with ModuleNotFoundError: No module named 'engineering.environment'.

  • Step 3: The pure modules
# engineering/environment/__init__.py
"""Environment helpers (v1.6 track E): portable layer states, view and UCS
argument resolution. Pure data and validation — no ezdxf, no COM."""

from .layer_states import (  # noqa: F401
    DICT_NAME,
    PROPERTIES,
    decode_state,
    diff_snapshot,
    encode_state,
    snapshot_from_layers,
    validate_properties,
    validate_state_name,
)
from .names import validate_name  # noqa: F401
from .ucs import WORLD, resolve_ucs_axes  # noqa: F401
from .views import resolve_view_args  # noqa: F401
# engineering/environment/names.py
"""One rule for the names this package stores in the drawing."""

from __future__ import annotations


def validate_name(name, *, what: str, max_len: int = 255) -> str:
    """A non-empty, stripped string without control characters, else ``ValueError``."""
    if not isinstance(name, str):
        raise ValueError(f"{what} must be a string, got {type(name).__name__}")
    clean = name.strip()
    if not clean:
        raise ValueError(f"{what} must not be empty")
    if len(clean) > max_len:
        raise ValueError(f"{what} is longer than {max_len} characters")
    if any(ord(ch) < 32 for ch in clean):
        raise ValueError(f"{what} must not contain control characters")
    return clean
# engineering/environment/layer_states.py
"""Portable layer states: a layer-table snapshot <-> XRECORD string chunks.

These are the server's own layer states, not AutoCAD's. AutoCAD keeps its
Layer States Manager entries under the layer table's extension dictionary in
a layout neither engine authors; this codec stores a JSON snapshot as
1000-code string chunks in an XRECORD under the drawing's named object
dictionary ``ACADMCP_LAYERSTATES``. They live in the file and travel with it,
and they do **not** appear in AutoCAD's Layer States Manager (LAYERSTATE).
That sentence is repeated in the tool docstrings, in ``system_capabilities``
(feature ``layer_states``) and in the README.

Chunks are ASCII (``ensure_ascii``), so a 255-character boundary can never
split a code point, and 255 is AutoCAD's ``SetXRecordData`` string limit —
ezdxf itself would accept longer tags (measured: a 300-character tag
round-trips through save/reopen unchanged).
"""

from __future__ import annotations

import json
from typing import TYPE_CHECKING

from .names import validate_name

if TYPE_CHECKING:
    from backends.base import LayerInfo

DICT_NAME = "ACADMCP_LAYERSTATES"
PROPERTIES = ("on", "frozen", "locked", "color", "linetype", "lineweight", "plot", "current")
LAYER_PROPERTIES = PROPERTIES[:-1]
CHUNK_SIZE = 255
FORMAT_VERSION = 1


def validate_state_name(name) -> str:
    return validate_name(name, what="layer state name")


def validate_properties(properties) -> tuple[str, ...]:
    """The subset of ``PROPERTIES`` to apply; ``None`` means all of them."""
    if properties is None:
        return PROPERTIES
    if isinstance(properties, str) or not isinstance(properties, (list, tuple)):
        raise TypeError("properties must be a list of property names")
    out: list[str] = []
    for index, prop in enumerate(properties):
        if prop not in PROPERTIES:
            raise ValueError(f"properties[{index}]: {prop!r} is not one of {PROPERTIES}")
        if prop not in out:
            out.append(prop)
    if not out:
        raise ValueError(f"properties must name at least one of {PROPERTIES}")
    return tuple(out)


def snapshot_from_layers(
    layers: list[LayerInfo],
    current_layer: str,
    *,
    plot: dict[str, bool] | None = None,
    description: str | None = None,
) -> dict:
    """The state dict for a layer table.

    ``plot`` is per-layer plottability (``LayerInfo`` has no such field; each
    engine reads it and passes it in), defaulting to plottable.
    """
    plot = plot or {}
    return {
        "version": FORMAT_VERSION,
        "description": description,
        "current_layer": str(current_layer),
        "layers": {
            layer.name: {
                "on": bool(layer.is_on),
                "frozen": bool(layer.is_frozen),
                "locked": bool(layer.is_locked),
                "color": int(layer.color),
                "linetype": str(layer.linetype),
                "lineweight": int(round(float(layer.lineweight))),
                "plot": bool(plot.get(layer.name, True)),
            }
            for layer in layers
        },
    }


def encode_state(state: dict) -> list[str]:
    text = json.dumps(state, separators=(",", ":"), sort_keys=True, ensure_ascii=True)
    return [text[i : i + CHUNK_SIZE] for i in range(0, len(text), CHUNK_SIZE)] or [""]


def decode_state(chunks) -> dict:
    text = "".join(str(chunk) for chunk in chunks)
    try:
        state = json.loads(text)
    except ValueError as exc:
        raise ValueError(f"layer state payload is not valid JSON: {exc}") from exc
    if not isinstance(state, dict) or state.get("version") != FORMAT_VERSION:
        found = state.get("version") if isinstance(state, dict) else type(state).__name__
        raise ValueError(
            f"layer state payload is not a version {FORMAT_VERSION} state (got {found!r})"
        )
    layers = state.get("layers")
    if not isinstance(layers, dict):
        raise ValueError("layer state payload has no 'layers' mapping")
    for name, props in layers.items():
        if not isinstance(props, dict):
            raise ValueError(f"layer state entry {name!r} is not a mapping")
        missing = [prop for prop in LAYER_PROPERTIES if prop not in props]
        if missing:
            raise ValueError(f"layer state entry {name!r} lacks {missing}")
    state.setdefault("description", None)
    state.setdefault("current_layer", "0")
    return state


def diff_snapshot(state: dict, layers: list[LayerInfo]) -> tuple[list[str], list[str]]:
    """``(missing, new)``: in the state but not the drawing / in the drawing but not the state."""
    in_state = set(state["layers"])
    in_drawing = {layer.name for layer in layers}
    return sorted(in_state - in_drawing), sorted(in_drawing - in_state)
# engineering/environment/views.py
"""Named-view arguments, validated before either engine writes."""

from __future__ import annotations

import math


def _finite(value, where: str) -> float:
    if isinstance(value, bool) or not isinstance(value, (int, float)):
        raise ValueError(f"{where} must be a number, got {value!r}")
    number = float(value)
    if not math.isfinite(number):
        raise ValueError(f"{where} must be finite")
    return number


def resolve_view_args(center, height, width) -> dict:
    """``{"center": (x, y) | None, "height": float | None, "width": float | None}``."""
    out: dict = {"center": None, "height": None, "width": None}
    if center is not None:
        if not isinstance(center, (list, tuple)) or len(center) != 2:
            raise ValueError("center must be an [x, y] pair")
        out["center"] = (_finite(center[0], "center.x"), _finite(center[1], "center.y"))
    if height is not None:
        out["height"] = _finite(height, "height")
        if out["height"] <= 0:
            raise ValueError(f"height must be > 0, got {out['height']}")
    if width is not None:
        out["width"] = _finite(width, "width")
        if out["width"] <= 0:
            raise ValueError(f"width must be > 0, got {out['width']}")
    return out
# engineering/environment/ucs.py
"""UCS axes, validated and normalised before either engine writes.

Tool coordinates stay WCS on both engines (the repository rule): a UCS is
stored and made current for the operator's benefit, and nothing here starts
interpreting inputs in it.
"""

from __future__ import annotations

import math

WORLD = "world"
ORTHO_TOLERANCE_DEG = 1e-3


def _vec3(value, where: str) -> tuple[float, float, float]:
    if not isinstance(value, (list, tuple)) or len(value) != 3:
        raise ValueError(f"{where} must be an [x, y, z] triple")
    out = []
    for axis, item in zip("xyz", value):
        if isinstance(item, bool) or not isinstance(item, (int, float)):
            raise ValueError(f"{where}.{axis} must be a number, got {item!r}")
        number = float(item)
        if not math.isfinite(number):
            raise ValueError(f"{where}.{axis} must be finite")
        out.append(number)
    return out[0], out[1], out[2]


def _unit(vector, where: str) -> tuple[float, float, float]:
    length = math.sqrt(sum(c * c for c in vector))
    if length < 1e-12:
        raise ValueError(f"{where} must not be a zero vector")
    return vector[0] / length, vector[1] / length, vector[2] / length


def resolve_ucs_axes(origin, x_axis, y_axis) -> dict:
    """Unit axes plus the measured angle; non-orthogonal axes are refused by that angle."""
    o = _vec3(origin, "origin")
    x = _unit(_vec3(x_axis, "x_axis"), "x_axis")
    y = _unit(_vec3(y_axis, "y_axis"), "y_axis")
    dot = max(-1.0, min(1.0, sum(a * b for a, b in zip(x, y))))
    angle = math.degrees(math.acos(dot))
    if abs(angle - 90.0) > ORTHO_TOLERANCE_DEG:
        raise ValueError(
            f"ucs_set: x_axis and y_axis must be perpendicular; measured {angle:.2f}° "
            f"between them (tolerance {ORTHO_TOLERANCE_DEG}°)"
        )
    z = (
        x[1] * y[2] - x[2] * y[1],
        x[2] * y[0] - x[0] * y[2],
        x[0] * y[1] - x[1] * y[0],
    )
    return {"origin": o, "x_axis": x, "y_axis": y, "z_axis": z, "angle_deg": angle}

Run: uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -k "not backend and not com_" -v Expected: the pure tests PASS; the engine tests still fail with AttributeError: 'EzdxfBackend' object has no attribute 'layer_state_save'.

  • Step 4: Append the ten abstract members to the contract

In backends/contracts/environment.py, replace the marker comment line that starts with # ── layer states, named views, UCS: appended by Task 22 with:

    # ── layer states (portable ACADMCP_LAYERSTATES XRECORDs, both engines) ──

    @abstractmethod
    async def layer_state_save(self, name: str, description: str | None = None) -> dict:
        """→ ``{"ok", "name", "layer_count", "replaced"}``."""
        ...

    @abstractmethod
    async def layer_state_restore(
        self, name: str, properties: list[str] | None = None
    ) -> dict:
        """``properties`` ⊆ {"on","frozen","locked","color","linetype","lineweight","plot","current"};
        → ``{"ok", "name", "applied", "missing_layers", "new_layers"}``."""
        ...

    @abstractmethod
    async def layer_state_list(self) -> list[dict]:
        """→ ``[{"name", "description", "layer_count"}]``."""
        ...

    @abstractmethod
    async def layer_state_delete(self, name: str) -> dict: ...

    # ── named views ─────────────────────────────────────────────────────────

    @abstractmethod
    async def view_named_save(
        self, name: str, center=None, height: float | None = None, width: float | None = None
    ) -> dict:
        """→ ``{"ok", "name", "center": [x, y], "height", "width", "replaced"}``."""
        ...

    @abstractmethod
    async def view_named_restore(self, name: str) -> dict:
        """ezdxf: sets the ``*Active`` VPORT, ``"applied": "header_only"``."""
        ...

    @abstractmethod
    async def view_named_list(self) -> list[dict]: ...

    # ── UCS (stored and made current; tool coordinates stay WCS) ────────────

    @abstractmethod
    async def ucs_list(self) -> list[dict]:
        """→ ``[{"name", "origin", "x_axis", "y_axis", "current": bool}]`` + the implicit "world"."""
        ...

    @abstractmethod
    async def ucs_set(self, name: str, origin, x_axis, y_axis) -> dict:
        """non-orthogonal → ``ValueError`` with the measured angle."""
        ...

    @abstractmethod
    async def ucs_restore(self, name: str) -> dict:
        """``"world"`` resets."""
        ...
  • Step 5: ezdxf implementations

Append to the # ── environment (track E) ── block in backends/ezdxf_backend.py (after document_close):

    # ── layer states ──────────────────────────────────────────────────────────

    @staticmethod
    def _layer_state_entry(states, name: str):
        """Case-insensitive lookup → ``(stored_key, xrecord)`` or ``(None, None)``."""
        if states is None:
            return None, None
        if name in states:
            return name, states.get(name)
        for key in states.keys():
            if key.lower() == name.lower():
                return key, states.get(key)
        return None, None

    @staticmethod
    def _layer_state_chunks(xrecord) -> list[str]:
        return [str(tag.value) for tag in xrecord.tags if tag.code == 1000]

    @staticmethod
    def _layer_states(doc):
        from engineering.environment.layer_states import DICT_NAME

        return doc.rootdict.get(DICT_NAME) if DICT_NAME in doc.rootdict else None

    @staticmethod
    def _layer_state_names(states) -> list[str]:
        return list(states.keys()) if states is not None else []

    def _layer_state_snapshot(self, doc, description):
        from engineering.environment.layer_states import snapshot_from_layers

        layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers]
        plot = {lyr.dxf.name: bool(lyr.dxf.get("plot", 1)) for lyr in doc.layers}
        return snapshot_from_layers(
            layers, self._current_layer, plot=plot, description=description
        )

    async def layer_state_save(self, name, description=None) -> dict:
        """Snapshot the layer table into ``ACADMCP_LAYERSTATES/<name>``.

        Portable and file-resident; not an AutoCAD Layer States Manager entry.
        """
        from engineering.environment.layer_states import (
            DICT_NAME,
            encode_state,
            validate_state_name,
        )

        clean = validate_state_name(name)
        if description is not None and not isinstance(description, str):
            raise TypeError("layer_state_save: description must be a string")

        def _sync():
            doc = self._require_doc()
            state = self._layer_state_snapshot(doc, description)
            chunks = encode_state(state)
            states = doc.rootdict.get_required_dict(DICT_NAME)
            existing_key, _ = self._layer_state_entry(states, clean)
            replaced = existing_key is not None
            if replaced:
                states.remove(existing_key)
            xrecord = states.add_xrecord(clean)
            xrecord.extend([(1000, chunk) for chunk in chunks])
            self._mark_dirty()
            return {
                "ok": True,
                "name": clean,
                "layer_count": len(state["layers"]),
                "replaced": replaced,
                "chunks": len(chunks),
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def layer_state_restore(self, name, properties=None) -> dict:
        from engineering.environment.layer_states import (
            decode_state,
            diff_snapshot,
            validate_properties,
            validate_state_name,
        )

        clean = validate_state_name(name)
        props = validate_properties(properties)

        def _sync():
            doc = self._require_doc()
            states = self._layer_states(doc)
            key, xrecord = self._layer_state_entry(states, clean)
            if xrecord is None:
                raise ValueError(
                    f"layer_state_restore: no layer state named {clean!r}; "
                    f"saved states: {self._layer_state_names(states)}"
                )
            state = decode_state(self._layer_state_chunks(xrecord))
            layers = [_layer_info_dxf(lyr, self._current_layer) for lyr in doc.layers]
            missing, new = diff_snapshot(state, layers)
            applied = 0
            for layer_name, snap in state["layers"].items():
                if layer_name not in doc.layers:
                    continue  # missing: reported, never created
                lyr = doc.layers.get(layer_name)
                # Colour first: `Layer.color` keeps the on/off sign, and the
                # on/off call below must see the final colour.
                if "color" in props:
                    lyr.color = int(snap["color"])
                if "linetype" in props:
                    _ensure_linetype_loaded(doc, snap["linetype"])
                    lyr.dxf.linetype = snap["linetype"]
                if "lineweight" in props:
                    lyr.dxf.lineweight = int(snap["lineweight"])
                if "plot" in props:
                    lyr.dxf.plot = 1 if snap["plot"] else 0
                if "on" in props:
                    if snap["on"]:
                        lyr.on()
                    else:
                        lyr.off()
                if "frozen" in props:
                    if snap["frozen"]:
                        lyr.freeze()
                    else:
                        lyr.thaw()
                if "locked" in props:
                    if snap["locked"]:
                        lyr.lock()
                    else:
                        lyr.unlock()
                applied += 1
            current = None
            if "current" in props and state["current_layer"] in doc.layers:
                current = state["current_layer"]
                self._current_layer = current
                doc.header["$CLAYER"] = current
            self._mark_dirty()
            return {
                "ok": True,
                "name": key,
                "applied": {
                    "layers": applied,
                    "properties": list(props),
                    "current_layer": current,
                },
                "missing_layers": missing,
                "new_layers": new,
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def layer_state_list(self) -> list[dict]:
        from engineering.environment.layer_states import decode_state

        def _sync():
            doc = self._require_doc()
            states = self._layer_states(doc)
            rows = []
            for key in self._layer_state_names(states):
                state = decode_state(self._layer_state_chunks(states.get(key)))
                rows.append(
                    {
                        "name": key,
                        "description": state.get("description"),
                        "layer_count": len(state["layers"]),
                    }
                )
            return rows

        return await self._async(_sync)

    async def layer_state_delete(self, name) -> dict:
        from engineering.environment.layer_states import validate_state_name

        clean = validate_state_name(name)

        def _sync():
            doc = self._require_doc()
            states = self._layer_states(doc)
            key, xrecord = self._layer_state_entry(states, clean)
            if xrecord is None:
                raise ValueError(
                    f"layer_state_delete: no layer state named {clean!r}; "
                    f"saved states: {self._layer_state_names(states)}"
                )
            states.remove(key)
            self._mark_dirty()
            return {"ok": True, "deleted": key, "backend": "ezdxf"}

        return await self._async(_sync)

    # ── named views ───────────────────────────────────────────────────────────

    # TEMPORARY (track-e/environment worktree only): group C's Task 14 defines
    # the same `_active_vport` in its `# ── settings (track E, group C) ──`
    # block, and C's is the one that ships. This copy exists so this worktree
    # runs without C's code; the merge agent deletes THIS definition (two in
    # one class are ruff F811, and the later one would silently win).
    @staticmethod
    def _active_vport(doc):
        """The ``*Active`` VPORT entry — the view the file opens on. Created if absent."""
        entries = doc.viewports.get("*Active")
        if entries:
            return entries[0]
        return doc.viewports.new("*Active")

    @staticmethod
    def _view_entry(doc, name: str):
        if name in doc.views:  # case-insensitive in ezdxf's table
            return doc.views.get(name)
        return None

    async def view_named_save(self, name, center=None, height=None, width=None) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.views import resolve_view_args

        clean = validate_name(name, what="view name")
        args = resolve_view_args(center, height, width)

        def _sync():
            doc = self._require_doc()
            vport = self._active_vport(doc)
            aspect = float(vport.dxf.get("aspect_ratio", 1.34)) or 1.34
            if args["center"] is None or args["height"] is None:
                # Headless there is no display to read a "current view" from;
                # the drawing's extents are the honest default.
                bb = ezdxf_bbox.extents(doc.modelspace()) if _BBOX_OK else None
                if bb is None or not bb.has_data:
                    raise ValueError(
                        f"view_named_save: {clean!r} — an empty drawing has no extents; "
                        "pass center and height"
                    )
                ext_center = (
                    (bb.extmin.x + bb.extmax.x) / 2.0,
                    (bb.extmin.y + bb.extmax.y) / 2.0,
                )
                # The window must hold the extents at the viewport's aspect:
                # whichever side binds decides the height.
                ext_height = max(bb.extmax.y - bb.extmin.y, (bb.extmax.x - bb.extmin.x) / aspect)
                if ext_height <= 0:
                    ext_height = 1.0
            else:
                ext_center, ext_height = args["center"], args["height"]
            cx, cy = args["center"] or ext_center
            h = args["height"] or ext_height
            w = args["width"] or h * aspect
            existing = self._view_entry(doc, clean)
            replaced = existing is not None
            stored = existing.dxf.name if replaced else clean
            if replaced:
                doc.views.remove(stored)
            doc.views.add(stored, dxfattribs={"center": (cx, cy, 0.0), "height": h, "width": w})
            self._mark_dirty()
            return {
                "ok": True,
                "name": stored,
                "center": [float(cx), float(cy)],
                "height": float(h),
                "width": float(w),
                "replaced": replaced,
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def view_named_restore(self, name) -> dict:
        from engineering.environment.names import validate_name

        clean = validate_name(name, what="view name")

        def _sync():
            doc = self._require_doc()
            view = self._view_entry(doc, clean)
            if view is None:
                raise ValueError(
                    f"view_named_restore: no named view {clean!r}; "
                    f"saved views: {[v.dxf.name for v in doc.views]}"
                )
            cx, cy = float(view.dxf.center.x), float(view.dxf.center.y)
            h, w = float(view.dxf.height), float(view.dxf.width)
            vport = self._active_vport(doc)
            vport.dxf.center = (cx, cy)
            vport.dxf.height = h
            if h > 0:
                vport.dxf.aspect_ratio = w / h
            self._mark_dirty()
            return {
                "ok": True,
                "name": view.dxf.name,
                "center": [cx, cy],
                "height": h,
                "width": w,
                # The plan's literal. Measured: ezdxf's header refuses $VIEWCTR,
                # so the store is the *Active VPORT — what AutoCAD reads as the
                # initial view when it opens the file. No live display moves.
                "applied": "header_only",
                "store": "vport_active",
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def view_named_list(self) -> list[dict]:
        def _sync():
            doc = self._require_doc()
            return [
                {
                    "name": view.dxf.name,
                    "center": [float(view.dxf.center.x), float(view.dxf.center.y)],
                    "height": float(view.dxf.height),
                    "width": float(view.dxf.width),
                }
                for view in doc.views
            ]

        return await self._async(_sync)

    # ── UCS ───────────────────────────────────────────────────────────────────

    @staticmethod
    def _ucs_entry(doc, name: str):
        for ucs in doc.ucs:
            if ucs.dxf.name.lower() == name.lower():
                return ucs
        return None

    @staticmethod
    def _set_ucs_header(doc, name: str, origin, x_axis, y_axis) -> None:
        doc.header["$UCSNAME"] = name
        doc.header["$UCSORG"] = tuple(origin)
        doc.header["$UCSXDIR"] = tuple(x_axis)
        doc.header["$UCSYDIR"] = tuple(y_axis)

    @staticmethod
    def _ucs_row(name, origin, x_axis, y_axis, current: bool) -> dict:
        return {
            "name": name,
            "origin": [float(c) for c in origin],
            "x_axis": [float(c) for c in x_axis],
            "y_axis": [float(c) for c in y_axis],
            "current": current,
        }

    async def ucs_list(self) -> list[dict]:
        from engineering.environment.ucs import WORLD

        def _sync():
            doc = self._require_doc()
            current = str(doc.header.get("$UCSNAME", "") or "")
            rows = [self._ucs_row(WORLD, (0, 0, 0), (1, 0, 0), (0, 1, 0), current == "")]
            for ucs in doc.ucs:
                rows.append(
                    self._ucs_row(
                        ucs.dxf.name,
                        ucs.dxf.origin,
                        ucs.dxf.xaxis,
                        ucs.dxf.yaxis,
                        current != "" and ucs.dxf.name.lower() == current.lower(),
                    )
                )
            return rows

        return await self._async(_sync)

    async def ucs_set(self, name, origin, x_axis, y_axis) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.ucs import WORLD, resolve_ucs_axes

        clean = validate_name(name, what="UCS name")
        if clean.lower() == WORLD:
            raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS")
        axes = resolve_ucs_axes(origin, x_axis, y_axis)  # refuses before any write

        def _sync():
            doc = self._require_doc()
            existing = self._ucs_entry(doc, clean)
            replaced = existing is not None
            stored = existing.dxf.name if replaced else clean
            if replaced:
                doc.ucs.remove(stored)
            doc.ucs.add(
                stored,
                dxfattribs={
                    "origin": axes["origin"],
                    "xaxis": axes["x_axis"],
                    "yaxis": axes["y_axis"],
                },
            )
            self._set_ucs_header(doc, stored, axes["origin"], axes["x_axis"], axes["y_axis"])
            self._mark_dirty()
            return {
                "ok": True,
                "name": stored,
                "origin": list(axes["origin"]),
                "x_axis": list(axes["x_axis"]),
                "y_axis": list(axes["y_axis"]),
                "replaced": replaced,
                "current": True,
                "backend": "ezdxf",
            }

        return await self._async(_sync)

    async def ucs_restore(self, name) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.ucs import WORLD

        clean = validate_name(name, what="UCS name")

        def _sync():
            doc = self._require_doc()
            if clean.lower() == WORLD:
                self._set_ucs_header(doc, "", (0, 0, 0), (1, 0, 0), (0, 1, 0))
                self._mark_dirty()
                return {"ok": True, "name": WORLD, "current": True, "backend": "ezdxf"}
            ucs = self._ucs_entry(doc, clean)
            if ucs is None:
                raise ValueError(
                    f"ucs_restore: no UCS named {clean!r}; "
                    f"saved: {[u.dxf.name for u in doc.ucs]} (or 'world')"
                )
            self._set_ucs_header(doc, ucs.dxf.name, ucs.dxf.origin, ucs.dxf.xaxis, ucs.dxf.yaxis)
            self._mark_dirty()
            return {"ok": True, "name": ucs.dxf.name, "current": True, "backend": "ezdxf"}

        return await self._async(_sync)

Capability map (ezdxf), after "documents":

                # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads
                # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim.
                "layer_states": FeatureCapability(
                    True,
                    "xrecord",
                    reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
                ),
                "named_views": FeatureCapability(
                    True,
                    "vport_active",
                    reason="restore_sets_the_view_the_file_opens_on;no_live_display",
                ),
                "ucs": FeatureCapability(
                    True, "stored_not_interpreted", reason="tool_coordinates_stay_wcs"
                ),

Run: uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py -k "not com_" -v Expected: the headless tests PASS.

  • Step 6: COM implementations

Add next to _ai at module level in backends/com_backend.py:

def _avar(values):
    """Create a VARIANT array of VARIANTs (what SetXRecordData takes for its values)."""
    return win32com.client.VARIANT(
        pythoncom.VT_ARRAY | pythoncom.VT_VARIANT,
        list(values),
    )

Append to the # ── environment (track E) ── block:

    # ── layer states ──────────────────────────────────────────────────────────

    @staticmethod
    def _com_layer_states(doc, *, create: bool):
        from engineering.environment.layer_states import DICT_NAME

        try:
            return doc.Dictionaries.Item(DICT_NAME)
        except Exception as exc:  # absent
            log.debug("Dictionaries.Item(%s): %s", DICT_NAME, exc)
        if not create:
            return None
        return doc.Dictionaries.Add(DICT_NAME)

    @staticmethod
    def _com_dict_entries(states) -> list[tuple[str, Any]]:
        if states is None:
            return []
        out = []
        for index in range(int(states.Count)):
            obj = states.Item(index)
            out.append((str(states.GetName(obj)), obj))
        return out

    def _com_layer_state_entry(self, states, name: str):
        for key, obj in self._com_dict_entries(states):
            if key.lower() == name.lower():
                return key, obj
        return None, None

    @staticmethod
    def _com_xrecord_chunks(xrecord) -> list[str]:
        # pywin32 returns the two [out] parameters as a tuple, exactly as
        # GetBoundingBox() / GetXData() do elsewhere in this file.
        codes, values = xrecord.GetXRecordData()
        return [
            str(value)
            for code, value in zip(list(codes or []), list(values or []))
            if int(code) == 1000
        ]

    @staticmethod
    def _com_layer_snapshot(doc, description):
        from engineering.environment.layer_states import snapshot_from_layers

        current = str(doc.ActiveLayer.Name)
        layers, plot = [], {}
        for index in range(int(doc.Layers.Count)):
            lyr = doc.Layers.Item(index)
            layers.append(_layer_info(lyr, current))
            plot[str(lyr.Name)] = bool(lyr.Plottable)
        return snapshot_from_layers(layers, current, plot=plot, description=description)

    async def layer_state_save(self, name, description=None) -> dict:
        from engineering.environment.layer_states import encode_state, validate_state_name

        clean = validate_state_name(name)
        if description is not None and not isinstance(description, str):
            raise TypeError("layer_state_save: description must be a string")

        def _sync():
            doc = _acad_doc()
            state = self._com_layer_snapshot(doc, description)
            chunks = encode_state(state)
            states = self._com_layer_states(doc, create=True)
            key, xrecord = self._com_layer_state_entry(states, clean)
            replaced = xrecord is not None
            if xrecord is None:
                xrecord = states.AddXRecord(clean)
                key = clean
            xrecord.SetXRecordData(_ai([1000] * len(chunks)), _avar(chunks))
            return {
                "ok": True,
                "name": key,
                "layer_count": len(state["layers"]),
                "replaced": replaced,
                "chunks": len(chunks),
                "backend": "com",
            }

        return await self._run(_sync)

    async def layer_state_restore(self, name, properties=None) -> dict:
        from engineering.environment.layer_states import (
            decode_state,
            diff_snapshot,
            validate_properties,
            validate_state_name,
        )

        clean = validate_state_name(name)
        props = validate_properties(properties)

        def _sync():
            doc = _acad_doc()
            states = self._com_layer_states(doc, create=False)
            key, xrecord = self._com_layer_state_entry(states, clean)
            if xrecord is None:
                raise ValueError(
                    f"layer_state_restore: no layer state named {clean!r}; "
                    f"saved states: {[k for k, _ in self._com_dict_entries(states)]}"
                )
            state = decode_state(self._com_xrecord_chunks(xrecord))
            current_name = str(doc.ActiveLayer.Name)
            layers = [
                _layer_info(doc.Layers.Item(i), current_name)
                for i in range(int(doc.Layers.Count))
            ]
            missing, new = diff_snapshot(state, layers)
            current = None
            warnings: list[str] = []
            # Current first: AutoCAD refuses to freeze the active layer, so the
            # active one must already be the state's before the loop freezes.
            if "current" in props and state["current_layer"] not in missing:
                doc.ActiveLayer = doc.Layers.Item(state["current_layer"])
                current = state["current_layer"]
            applied = 0
            for layer_name, snap in state["layers"].items():
                if layer_name in missing:
                    continue
                lyr = doc.Layers.Item(layer_name)
                try:
                    if "color" in props:
                        lyr.Color = int(snap["color"])
                    if "linetype" in props:
                        _ensure_linetype_loaded(snap["linetype"])
                        lyr.Linetype = snap["linetype"]
                    if "lineweight" in props:
                        lyr.LineWeight = int(snap["lineweight"])
                    if "plot" in props:
                        lyr.Plottable = bool(snap["plot"])
                    if "on" in props:
                        lyr.LayerOn = bool(snap["on"])
                    if "frozen" in props:
                        lyr.Freeze = bool(snap["frozen"])
                    if "locked" in props:
                        lyr.Lock = bool(snap["locked"])
                except Exception as exc:  # e.g. freezing the active layer
                    warnings.append(f"{layer_name}: {exc}")
                    continue
                applied += 1
            _regen()
            result = {
                "ok": True,
                "name": key,
                "applied": {
                    "layers": applied,
                    "properties": list(props),
                    "current_layer": current,
                },
                "missing_layers": missing,
                "new_layers": new,
                "backend": "com",
            }
            if warnings:
                result["warnings"] = warnings
            return result

        return await self._run(_sync)

    async def layer_state_list(self) -> list[dict]:
        from engineering.environment.layer_states import decode_state

        def _sync():
            doc = _acad_doc()
            states = self._com_layer_states(doc, create=False)
            rows = []
            for key, xrecord in self._com_dict_entries(states):
                state = decode_state(self._com_xrecord_chunks(xrecord))
                rows.append(
                    {
                        "name": key,
                        "description": state.get("description"),
                        "layer_count": len(state["layers"]),
                    }
                )
            return rows

        return await self._run(_sync)

    async def layer_state_delete(self, name) -> dict:
        from engineering.environment.layer_states import validate_state_name

        clean = validate_state_name(name)

        def _sync():
            doc = _acad_doc()
            states = self._com_layer_states(doc, create=False)
            key, xrecord = self._com_layer_state_entry(states, clean)
            if xrecord is None:
                raise ValueError(
                    f"layer_state_delete: no layer state named {clean!r}; "
                    f"saved states: {[k for k, _ in self._com_dict_entries(states)]}"
                )
            removed = states.Remove(key)
            try:
                removed.Delete()
            except Exception as exc:  # Remove already detached it
                log.debug("XRecord.Delete after Remove: %s", exc)
            return {"ok": True, "deleted": key, "backend": "com"}

        return await self._run(_sync)

    # ── named views ───────────────────────────────────────────────────────────

    @staticmethod
    def _com_view(doc, name: str):
        try:
            return doc.Views.Item(name)
        except Exception:
            return None

    @staticmethod
    def _com_view_row(view) -> dict:
        center = view.Center
        return {
            "name": str(view.Name),
            "center": [float(center[0]), float(center[1])],
            "height": float(view.Height),
            "width": float(view.Width),
        }

    async def view_named_save(self, name, center=None, height=None, width=None) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.views import resolve_view_args

        clean = validate_name(name, what="view name")
        args = resolve_view_args(center, height, width)

        def _sync():
            doc = _acad_doc()
            vport = doc.ActiveViewport
            vp_center = vport.Center
            vp_height = float(vport.Height) or 1.0
            aspect = float(vport.Width) / vp_height if vp_height else 1.5
            cx, cy = args["center"] or (float(vp_center[0]), float(vp_center[1]))
            h = args["height"] or vp_height
            w = args["width"] or h * aspect
            view = self._com_view(doc, clean)
            replaced = view is not None
            if view is None:
                view = doc.Views.Add(clean)
            view.Center = _av([cx, cy])
            view.Height = float(h)
            view.Width = float(w)
            return {
                "ok": True,
                "name": str(view.Name),
                "center": [float(cx), float(cy)],
                "height": float(h),
                "width": float(w),
                "replaced": replaced,
                "backend": "com",
            }

        return await self._run(_sync)

    async def view_named_restore(self, name) -> dict:
        from engineering.environment.names import validate_name

        clean = validate_name(name, what="view name")

        def _sync():
            doc = _acad_doc()
            view = self._com_view(doc, clean)
            if view is None:
                names = [str(doc.Views.Item(i).Name) for i in range(int(doc.Views.Count))]
                raise ValueError(
                    f"view_named_restore: no named view {clean!r}; saved views: {names}"
                )
            row = self._com_view_row(view)
            vport = doc.ActiveViewport
            vport.Center = _av(row["center"])
            vport.Height = row["height"]
            vport.Width = row["width"]
            # ActiveX applies viewport changes only when the object is assigned back.
            doc.ActiveViewport = vport
            _regen()
            return {"ok": True, **row, "applied": "active_viewport", "backend": "com"}

        return await self._run(_sync)

    async def view_named_list(self) -> list[dict]:
        def _sync():
            doc = _acad_doc()
            return [self._com_view_row(doc.Views.Item(i)) for i in range(int(doc.Views.Count))]

        return await self._run(_sync)

    # ── UCS ───────────────────────────────────────────────────────────────────

    @staticmethod
    def _com_ucs(doc, name: str):
        try:
            return doc.UserCoordinateSystems.Item(name)
        except Exception:
            return None

    @staticmethod
    def _com_ucs_row(ucs, current: str) -> dict:
        return {
            "name": str(ucs.Name),
            "origin": [float(c) for c in ucs.Origin],
            "x_axis": [float(c) for c in ucs.XVector],
            "y_axis": [float(c) for c in ucs.YVector],
            "current": current != "" and str(ucs.Name).lower() == current.lower(),
        }

    async def ucs_list(self) -> list[dict]:
        from engineering.environment.ucs import WORLD

        def _sync():
            app = _acad_app()
            doc = _acad_doc()
            current = str(app.GetVariable("UCSNAME") or "")
            rows = [
                {
                    "name": WORLD,
                    "origin": [0.0, 0.0, 0.0],
                    "x_axis": [1.0, 0.0, 0.0],
                    "y_axis": [0.0, 1.0, 0.0],
                    "current": current == "",
                }
            ]
            collection = doc.UserCoordinateSystems
            for index in range(int(collection.Count)):
                rows.append(self._com_ucs_row(collection.Item(index), current))
            return rows

        return await self._run(_sync)

    async def ucs_set(self, name, origin, x_axis, y_axis) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.ucs import WORLD, resolve_ucs_axes

        clean = validate_name(name, what="UCS name")
        if clean.lower() == WORLD:
            raise ValueError("ucs_set: 'world' is reserved; ucs_restore('world') resets to WCS")
        axes = resolve_ucs_axes(origin, x_axis, y_axis)  # refuses before any ActiveX call

        def _sync():
            doc = _acad_doc()
            o, x, y = axes["origin"], axes["x_axis"], axes["y_axis"]
            ucs = self._com_ucs(doc, clean)
            replaced = ucs is not None
            if ucs is None:
                # ActiveX takes POINTS on the axes, not direction vectors.
                ucs = doc.UserCoordinateSystems.Add(
                    _apoint(*o),
                    _apoint(o[0] + x[0], o[1] + x[1], o[2] + x[2]),
                    _apoint(o[0] + y[0], o[1] + y[1], o[2] + y[2]),
                    clean,
                )
            else:
                ucs.Origin = _apoint(*o)
                ucs.XVector = _apoint(*x)
                ucs.YVector = _apoint(*y)
            doc.ActiveUCS = ucs
            return {
                "ok": True,
                "name": str(ucs.Name),
                "origin": list(o),
                "x_axis": list(x),
                "y_axis": list(y),
                "replaced": replaced,
                "current": True,
                "backend": "com",
            }

        return await self._run(_sync)

    async def ucs_restore(self, name) -> dict:
        from engineering.environment.names import validate_name
        from engineering.environment.ucs import WORLD

        clean = validate_name(name, what="UCS name")

        def _sync():
            app = _acad_app()
            doc = _acad_doc()
            if clean.lower() == WORLD:
                # No ActiveX member selects WCS; the command does. Same guard as
                # system_run_command: never send into an active prompt.
                try:
                    cmd_active = int(app.GetVariable("CMDACTIVE"))
                except Exception as exc:
                    log.debug("CMDACTIVE read failed, proceeding: %s", exc)
                    cmd_active = 0
                if cmd_active:
                    raise RuntimeError(
                        "AutoCAD has an active command or prompt (CMDACTIVE="
                        f"{cmd_active}). Press ESC in AutoCAD to cancel, then retry."
                    )
                doc.SendCommand("_.UCS _W\n")
                return {"ok": True, "name": WORLD, "current": True, "backend": "com"}
            ucs = self._com_ucs(doc, clean)
            if ucs is None:
                names = [
                    str(doc.UserCoordinateSystems.Item(i).Name)
                    for i in range(int(doc.UserCoordinateSystems.Count))
                ]
                raise ValueError(
                    f"ucs_restore: no UCS named {clean!r}; saved: {names} (or 'world')"
                )
            doc.ActiveUCS = ucs
            return {"ok": True, "name": str(ucs.Name), "current": True, "backend": "com"}

        return await self._run(_sync)

Capability map (COM), after "documents":

                # Spelling pinned by F Task 24's test_layer_states_are_declared_as_ours_not_autocads
                # and quoted by the README and CLAUDE.md: mode "xrecord", this reason verbatim.
                "layer_states": FeatureCapability(
                    True,
                    "xrecord",
                    reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
                ),
                "named_views": FeatureCapability(True, "native"),
                "ucs": FeatureCapability(
                    True, "native", reason="world_restore_via_ucs_command;tool_coordinates_stay_wcs"
                ),

Run: uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py tests/test_capability_contract.py -v Expected: all PASS.

  • Step 7: Register the ten tools, aliases, README, inventory, registry snapshot

server.py SECTION 20 header → (13 tools); append after document_close:

@cad_tool(
    summary="Save the layer table (on/frozen/locked/colour/linetype/lineweight/plot + current) under a name, inside the file.",
    cost="safe",
)
@mcp.tool(
    annotations={"title": "Save Layer State", "readOnlyHint": False, "destructiveHint": False},
    tags={"layer"},
)
async def layer_state_save(
    name: Annotated[str, "State name, e.g. PLOT-SET or DESIGN"],
    description: Annotated[str | None, "Free text stored with the state"] = None,
    ctx: Context = None,
) -> dict:
    """Snapshot every layer's on/frozen/locked/color/linetype/lineweight/plot
    flags plus the current layer as a named state stored in the drawing.

    These are the server's own portable layer states: JSON chunks in an XRECORD
    under the `ACADMCP_LAYERSTATES` dictionary. They live in the file and
    travel with it, work on both engines, and do **not** appear in AutoCAD's
    Layer States Manager (LAYERSTATE). Same name replaces (`replaced: true`).
    Refusals: an empty or control-character name, a non-string description.
    Pack: settings · lean: no.
    """
    await ctx.info(f"Saving layer state {name!r}")
    return await _backend(ctx).layer_state_save(name, description)


@cad_tool(summary="Restore a saved layer state, all properties or a chosen subset.", cost="safe")
@mcp.tool(
    annotations={"title": "Restore Layer State", "readOnlyHint": False, "destructiveHint": False},
    tags={"layer"},
)
async def layer_state_restore(
    name: Annotated[str, "A name from layer_state_list"],
    properties: Annotated[
        list[str] | None,
        "Subset of on, frozen, locked, color, linetype, lineweight, plot, current; omit for all",
    ] = None,
    ctx: Context = None,
) -> dict:
    """Apply a saved state to the layers that still exist.

    `missing_layers` (in the state, not in the drawing) are skipped, never
    created; `new_layers` (in the drawing, not in the state) are untouched;
    `applied` says how many layers and which properties moved. Refusals: an
    unknown state name (lists the saved ones), a property outside the eight
    (names the index). Live, a layer AutoCAD refuses to change (freezing the
    active layer) lands in `warnings` instead of failing the call. Portable
    server states, not Layer States Manager entries. Pack: settings · lean: no.
    """
    await ctx.info(f"Restoring layer state {name!r}")
    return await _backend(ctx).layer_state_restore(name, properties)


@cad_tool(summary="List the layer states saved in this drawing.", cost="read")
@mcp.tool(
    annotations={"title": "List Layer States", "readOnlyHint": True},
    tags={"layer"},
)
async def layer_state_list(ctx: Context = None) -> dict:
    """Every state under `ACADMCP_LAYERSTATES` with its description and layer
    count. These are the server's portable states, not AutoCAD Layer States
    Manager entries. No refusals. Pack: settings · lean: no."""
    rows = await _backend(ctx).layer_state_list()
    return {"states": rows, "count": len(rows)}


@cad_tool(summary="Delete one saved layer state from the drawing.", cost="destructive")
@mcp.tool(
    annotations={"title": "Delete Layer State", "destructiveHint": True},
    tags={"layer"},
)
async def layer_state_delete(
    name: Annotated[str, "A name from layer_state_list"],
    ctx: Context = None,
) -> dict:
    """Remove the named state's XRECORD. Layers themselves are untouched.
    Refusal: an unknown name (lists the saved ones). Pack: settings · lean: no."""
    await ctx.info(f"Deleting layer state {name!r}")
    return await _backend(ctx).layer_state_delete(name)


@cad_tool(
    summary="Save a named view: a centre and height (and width) to come back to.", cost="safe"
)
@mcp.tool(
    annotations={"title": "Save Named View", "readOnlyHint": False, "destructiveHint": False},
    tags={"view"},
)
async def view_named_save(
    name: Annotated[str, "View name, e.g. DETAIL-A"],
    center: Annotated[
        list[float] | None,
        "[x, y] in WCS; default: the current view (live) or the drawing extents (headless)",
    ] = None,
    height: Annotated[float | None, "View height in drawing units; default as for center"] = None,
    width: Annotated[
        float | None, "View width; default: height × the current viewport aspect"
    ] = None,
    ctx: Context = None,
) -> dict:
    """Store a VIEW table entry (AutoCAD's VIEW command). Same name replaces
    (`replaced: true`). Refusals: `height`/`width` ≤ 0 or non-finite, a
    malformed `center`, and headless an empty drawing with no `center`/`height`
    (there are no extents to default to). Pack: settings · lean: no."""
    await ctx.info(f"Saving named view {name!r}")
    return await _backend(ctx).view_named_save(name, center, height, width)


@cad_tool(summary="Restore a named view.", cost="safe")
@mcp.tool(
    annotations={"title": "Restore Named View", "readOnlyHint": False, "destructiveHint": False},
    tags={"view"},
)
async def view_named_restore(
    name: Annotated[str, "A name from view_named_list"],
    ctx: Context = None,
) -> dict:
    """Live: sets the active viewport to the saved centre/height/width
    (`applied: "active_viewport"`). Headless there is no display: the saved
    window is written to the `*Active` VPORT — the view AutoCAD opens the file
    on — and the result says `applied: "header_only"`, the same reported-no-op
    rule as `view_zoom_extents`. Refusal: an unknown name (lists the saved
    views). Pack: settings · lean: no."""
    return await _backend(ctx).view_named_restore(name)


@cad_tool(summary="List the named views saved in this drawing.", cost="read")
@mcp.tool(
    annotations={"title": "List Named Views", "readOnlyHint": True},
    tags={"view"},
)
async def view_named_list(ctx: Context = None) -> dict:
    """Every VIEW table entry with centre, height and width. No refusals.
    Pack: settings · lean: no."""
    rows = await _backend(ctx).view_named_list()
    return {"views": rows, "count": len(rows)}


@cad_tool(summary="List the user coordinate systems, with the implicit world one.", cost="read")
@mcp.tool(
    annotations={"title": "List UCS", "readOnlyHint": True},
    tags={"view"},
)
async def ucs_list(ctx: Context = None) -> dict:
    """The `world` row first, then every UCS table entry with origin and unit
    axes, `current` on the active one. Tool coordinates stay WCS on both
    engines whatever is current. No refusals. Pack: settings · lean: no."""
    rows = await _backend(ctx).ucs_list()
    return {
        "ucs": rows,
        "count": len(rows),
        "current": next((r["name"] for r in rows if r["current"]), None),
    }


@cad_tool(
    summary="Define (or replace) a named UCS from an origin and two perpendicular axes, and make it current.",
    cost="safe",
)
@mcp.tool(
    annotations={"title": "Set UCS", "readOnlyHint": False, "destructiveHint": False},
    tags={"view"},
)
async def ucs_set(
    name: Annotated[str, "UCS name"],
    origin: Annotated[list[float], "[x, y, z] in WCS"],
    x_axis: Annotated[list[float], "Direction of the new X axis (any length)"],
    y_axis: Annotated[list[float], "Direction of the new Y axis; must be perpendicular to x_axis"],
    ctx: Context = None,
) -> dict:
    """Store a UCS table entry and make it current (AutoCAD's UCS command).

    Axes are normalised to unit vectors. **Every tool keeps taking and
    returning WCS coordinates** — the repository rule; a UCS is for the
    operator's own drafting, and no tool starts interpreting inputs in it.
    Refusals, before any write: a name of `world` (reserved), a zero-length
    axis, and non-perpendicular axes — the message carries the measured angle
    (tolerance 0.001°). Pack: settings · lean: no.
    """
    await ctx.info(f"Setting UCS {name!r}")
    return await _backend(ctx).ucs_set(name, origin, x_axis, y_axis)


@cad_tool(summary="Make a saved UCS current, or `world` to reset to WCS.", cost="safe")
@mcp.tool(
    annotations={"title": "Restore UCS", "readOnlyHint": False, "destructiveHint": False},
    tags={"view"},
)
async def ucs_restore(
    name: Annotated[str, "A name from ucs_list, or `world`"],
    ctx: Context = None,
) -> dict:
    """Live: `ActiveUCS` for a named entry; `world` runs `UCS World`, which is
    refused while AutoCAD has an active command (CMDACTIVE — press ESC).
    Headless: the `$UCSNAME/$UCSORG/$UCSXDIR/$UCSYDIR` header variables.
    Refusal: an unknown name (lists the saved ones). Tool coordinates stay
    WCS. Pack: settings · lean: no."""
    return await _backend(ctx).ucs_restore(name)

discovery/aliases.py: add "LAYERSTATE", "UCS", "VIEW" to SHARED_ACAD_COMMANDS with the comment

#   LAYERSTATE, VIEW, UCS -- umbrella dialog/option commands (save, restore,
#       list, delete in one) that this server splits into discrete tools.

and these records in the Environment block (alphabetical):

    "layer_state_delete": ToolAliases(
        acad=("LAYERSTATE",),
        synonyms=("delete a layer state", "remove a saved layer setup", "drop the layer snapshot"),
    ),
    "layer_state_list": ToolAliases(
        acad=("LAYERSTATE",),
        synonyms=("saved layer states", "which layer setups are saved", "list layer snapshots"),
    ),
    "layer_state_restore": ToolAliases(
        acad=("LAYERSTATE",),
        synonyms=(
            "restore the layer state",
            "put the layers back",
            "go back to the plot layer setup",
            "reapply saved layer settings",
            "layer snapshot restore",
        ),
    ),
    "layer_state_save": ToolAliases(
        acad=("LAYERSTATE",),
        synonyms=(
            "save the layer state",
            "remember the layer setup",
            "snapshot the layers",
            "layer configuration for plotting",
            "save which layers are frozen",
        ),
    ),
    "ucs_list": ToolAliases(
        acad=("UCS", "UCSMAN"),
        synonyms=("which ucs is current", "list coordinate systems", "named ucs", "ucs manager"),
    ),
    "ucs_restore": ToolAliases(
        acad=("UCS",),
        synonyms=(
            "back to world coordinates",
            "reset the ucs",
            "ucs world",
            "switch to a saved ucs",
            "make that coordinate system current",
        ),
    ),
    "ucs_set": ToolAliases(
        acad=("UCS",),
        synonyms=(
            "user coordinate system",
            "define a ucs",
            "new coordinate system at this origin",
            "rotate the ucs",
            "ucs origin",
        ),
    ),
    "view_named_list": ToolAliases(
        acad=("VIEW",),
        synonyms=("saved views", "which named views exist", "list the views"),
    ),
    "view_named_restore": ToolAliases(
        acad=("VIEW",),
        synonyms=(
            "go to the saved view",
            "restore a named view",
            "jump to the detail view",
            "recall the view",
        ),
    ),
    "view_named_save": ToolAliases(
        acad=("VIEW",),
        synonyms=(
            "save this view",
            "named view",
            "remember where i am looking",
            "bookmark the view",
            "save a detail view",
        ),
    ),

README.md: snapshot 169 tools → 179 tools; replace the Environment row with:

| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)* |

tests/test_tool_registry.py: "layers": 14 → 18, "view": 4 → 10, total 169 → 179; docstring sentence: "layers moved 14 -> 18 with the four layer_state_* tools and view 4 -> 10 with view_named_* and ucs_* (UCS files under view, where AutoCAD's own ribbon keeps it) when v1.6's SECTION 20 grew."

Then: uv run --frozen python scripts/generate_tool_inventory.py.

  • Step 8: Run the gates

Run: uv run --frozen pytest tests/test_layer_states.py tests/test_views_ucs.py tests/test_documents.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_layer_block.py -v Expected: all PASS (test_layer_block.py proves the untouched layer tools still behave).

  • Step 9: Commit
git add engineering/environment/ backends/contracts/environment.py backends/ezdxf_backend.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_layer_states.py tests/test_views_ucs.py
git commit -m "feat(environment): portable layer states, named views and UCS on both engines

Layer states are ACADMCP_LAYERSTATES XRECORD chunks (<= 255 ASCII chars each),
in the file and not in AutoCAD's Layer States Manager. Headless view restore
writes the *Active VPORT (ezdxf's header has no \$VIEWCTR; measured). UCS is
stored and made current; tool coordinates stay WCS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Task 23: system_launch, preferences, operator prompts — COM implementations, headless refusals

Files:

  • Create: engineering/environment/preferences.py
  • Modify: engineering/environment/__init__.py (re-export)
  • Modify: backends/com_backend.py (environment block: _interactive, _com_cancel_reason, _prompt_text, system_launch, preferences_get, preferences_set, user_pick_point, user_select, system_prompt_message)
  • Modify: server.py (SECTION 20 header (13 tools) → (19 tools); six tools), discovery/aliases.py (six records; OPTIONS in SHARED_ACAD_COMMANDS), README.md (179 tools → 185 tools; Environment row), docs/tool-inventory.json, tests/test_tool_registry.py (system 7 → 13, total 185)
  • Test: tests/test_environment_live.py

Interfaces:

  • Produces (engineering/environment/preferences.py): PREFERENCE_KEYS and READ_ONLY_KEYS exactly as the plan's Interfaces block (the OpenSave.SaveAsType enum is SAVE_AS_TYPES, AutoCAD's AcSaveAsType values: ac2000_dwg 12 … ac2018_template 66), split_key(key) -> (section, member), validate_preference(key, value) -> value (read-only → ValueError; unknown → ValueError listing the keys; bool/int/str/enum typed, ranges enforced, enum names ↔ values; nothing is written by it), decode_value(key, raw) (enum value → name), describe_preferences() -> list[dict].
  • Backend (COM only; ezdxf inherits the @capability refusals declared in Task 21): system_launch(visible=True, open_path=None) -> {"launched", "attached", "version", "document", "visible", "progid"}, preferences_get(keys=None) -> {"values", "read_only"}, preferences_set(key, value) -> {"ok", "key", "old", "new", "changed"}, user_pick_point(prompt) -> {"cancelled": False, "x", "y", "z"} | {"cancelled": True, "reason"} | {"timed_out": True, "timeout_s", "error"}, user_select(prompt, mode) -> {"cancelled", "handles", ...}, system_prompt_message(text) -> {"ok", "text"}.
  • The three capability keys live_application, preferences, interactive_prompt were declared in both maps in Task 21 (the decorator registers at import, so the gate needed them from the moment the contract module existed); this task's test proves the ezdxf default refuses with those keys and that COM no longer inherits the default.

Pinned before writing: pywin32 exposes pythoncom.Missing (the "omit this optional argument" marker the dynamic-dispatch layer uses as its own default for optional parameters; probed hasattr(pythoncom, "Missing") is True) and pythoncom.Empty. [out] parameters come back as a tuple and are not passed in — the file already relies on this for GetBoundingBox() and GetXData(), so GetEntity(prompt) returns (object, picked_point). An ESC during GetPoint/GetEntity/SelectOnScreen surfaces as com_error with HRESULT -2147352567 (DISP_E_EXCEPTION) and the description AutoCAD supplies ("User input is a keyword" / "Function cancelled"); ComBackend._run would rewrap that as a RuntimeError, so the _sync bodies catch _COM_ERROR themselves and answer {cancelled: true}. A deadline overrun in _run raises RuntimeError("AutoCAD did not respond within …") after rebuilding the STA executor; _interactive recognises that message and answers {timed_out: true} (the executor rebuild still happens — the next call re-attaches lazily as before). scripts/smoke_settings_com.py (Task 24) executes GetPoint live only with --interactive.

  • Step 1: Write the failing tests
# tests/test_environment_live.py
"""The live-only environment trio: launch, preferences, operator prompts.

Headless there is no application to launch, no registry of preferences and
no operator to prompt, so ``EzdxfBackend`` inherits the ``@capability``
defaults and refuses with a key ``system_capabilities`` declares. Live, the
COM paths are exercised against fake ActiveX objects asserting the call
shapes; ``scripts/smoke_settings_com.py`` runs them once for real.

A cancelled pick is an answer (``cancelled: true``), not an error; a
wall-clock timeout is ``timed_out: true``. Preference writes are refused by
name before any ActiveX call: read-only keys, unknown keys, values outside
the authored range.
"""

from __future__ import annotations

import types

import pytest
from fastmcp import Client

import config
import server
from backends.base import UnsupportedCapabilityError
from backends.capability import is_capability_default
from backends.ezdxf_backend import EzdxfBackend
from engineering.environment.preferences import (
    PREFERENCE_KEYS,
    READ_ONLY_KEYS,
    SAVE_AS_TYPES,
    decode_value,
    describe_preferences,
    split_key,
    validate_preference,
)

pytestmark = pytest.mark.asyncio


# ── the whitelist, pure ─────────────────────────────────────────────────────


def test_whitelist_is_the_spec_list():
    assert set(PREFERENCE_KEYS) == {
        "OpenSave.SaveAsType",
        "OpenSave.AutoSaveInterval",
        "OpenSave.CreateBackup",
        "OpenSave.IncrementalSavePercent",
        "Display.CursorSize",
        "Drafting.AutoSnapMarkerSize",
        "Drafting.AutoSnapTooltip",
        "Selection.PickBoxSize",
        "Output.DefaultPlotStyleTable",
        "Output.DefaultOutputDevice",
    }
    assert READ_ONLY_KEYS == (
        "Files.SupportPath",
        "Files.TemplateDwgPath",
        "Files.PrintStyleSheetPath",
        "Files.PrinterConfigPath",
    )
    assert split_key("OpenSave.AutoSaveInterval") == ("OpenSave", "AutoSaveInterval")
    assert SAVE_AS_TYPES["ac2018_dwg"] == 64 and SAVE_AS_TYPES["ac2000_dwg"] == 12


def test_validate_preference_coerces_and_refuses_by_name():
    assert validate_preference("OpenSave.AutoSaveInterval", 10) == 10
    assert validate_preference("OpenSave.AutoSaveInterval", 10.0) == 10
    assert validate_preference("OpenSave.CreateBackup", False) is False
    assert validate_preference("OpenSave.SaveAsType", "AC2018_DWG") == 64
    assert validate_preference("OpenSave.SaveAsType", 48) == 48
    assert validate_preference("Output.DefaultPlotStyleTable", " monochrome.ctb ") == "monochrome.ctb"
    with pytest.raises(ValueError, match="'Files.SupportPath' is read-only"):
        validate_preference("Files.SupportPath", "C:/x")
    with pytest.raises(ValueError, match="unknown preference 'Display.Theme'"):
        validate_preference("Display.Theme", 1)
    with pytest.raises(ValueError, match="between 0 and 600, got 601"):
        validate_preference("OpenSave.AutoSaveInterval", 601)
    with pytest.raises(TypeError, match="takes an int"):
        validate_preference("Selection.PickBoxSize", 2.5)
    with pytest.raises(TypeError, match="takes a bool"):
        validate_preference("OpenSave.CreateBackup", 1)
    with pytest.raises(ValueError, match="must be one of"):
        validate_preference("OpenSave.SaveAsType", "ac1999_dwg")
    with pytest.raises(TypeError, match="takes a non-empty string"):
        validate_preference("Output.DefaultOutputDevice", "")


def test_decode_value_maps_enum_values_back_to_names():
    assert decode_value("OpenSave.SaveAsType", 64) == "ac2018_dwg"
    assert decode_value("OpenSave.SaveAsType", 999) == 999
    assert decode_value("OpenSave.CreateBackup", 1) is True
    assert decode_value("Files.SupportPath", "C:/a;C:/b") == "C:/a;C:/b"


def test_describe_preferences_covers_every_key():
    rows = {row["key"]: row for row in describe_preferences()}
    assert set(rows) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
    assert rows["OpenSave.AutoSaveInterval"] == {
        "key": "OpenSave.AutoSaveInterval", "kind": "int", "range": [0, 600], "enum": None, "writable": True,
    }
    assert rows["Files.SupportPath"]["writable"] is False


# ── headless: declared refusals ─────────────────────────────────────────────


@pytest.mark.parametrize(
    "method, args, key",
    [
        ("system_launch", (), "live_application"),
        ("preferences_get", (), "preferences"),
        ("preferences_set", ("Display.CursorSize", 5), "preferences"),
        ("user_pick_point", ("Pick",), "interactive_prompt"),
        ("user_select", ("Select",), "interactive_prompt"),
        ("system_prompt_message", ("hi",), "interactive_prompt"),
    ],
)
async def test_headless_refuses_with_the_declared_key(backend, method, args, key):
    assert is_capability_default(EzdxfBackend, method)
    with pytest.raises(UnsupportedCapabilityError) as excinfo:
        await getattr(backend, method)(*args)
    assert excinfo.value.capability == key
    assert "ezdxf" in str(excinfo.value)
    feature = backend.capabilities().features[key]
    assert feature.supported is False and feature.reason


async def test_refusal_reaches_the_client_with_the_key(monkeypatch):
    monkeypatch.setenv("AUTOCAD_MCP_BACKEND", "ezdxf")
    async with Client(server.mcp) as client:
        await client.call_tool("drawing_new", {"bootstrap": False})
        result = await client.call_tool(
            "user_pick_point", {"prompt": "Pick the base point"}, raise_on_error=False
        )
        assert result.is_error is True
        payload = result.structured_content
        assert payload["ok"] is False and payload["capability"] == "interactive_prompt"
        caps = (await client.call_tool("system_capabilities", {})).structured_content
        assert caps["features"]["interactive_prompt"]["supported"] is False


# ── live engine, against a fake ActiveX surface ──────────────────────────────


def _com_error(description: str):
    pywintypes = pytest.importorskip("pywintypes")
    return pywintypes.com_error(
        -2147352567, "Exception occurred.", (0, None, description, None, 0, -2145320928), None
    )


class _FakeUtility:
    def __init__(self):
        self.calls: list[tuple[str, tuple]] = []
        self.point = (12.5, 7.25, 0.0)
        self.raise_on_pick = None
        self.entity = types.SimpleNamespace(Handle="1F3")

    def GetPoint(self, base, prompt):
        self.calls.append(("GetPoint", (base, prompt)))
        if self.raise_on_pick is not None:
            raise self.raise_on_pick
        return self.point

    def GetEntity(self, prompt):
        self.calls.append(("GetEntity", (prompt,)))
        if self.raise_on_pick is not None:
            raise self.raise_on_pick
        return self.entity, (3.0, 4.0, 0.0)

    def Prompt(self, text):
        self.calls.append(("Prompt", (text,)))


class _FakeSelectionSet:
    def __init__(self, name, items):
        self.Name = name
        self._items = items
        self.calls: list[str] = []

    @property
    def Count(self):
        return len(self._items)

    def Item(self, index):
        return self._items[index]

    def SelectOnScreen(self):
        self.calls.append("SelectOnScreen")

    def Delete(self):
        self.calls.append("Delete")


class _FakeSelectionSets:
    def __init__(self, items):
        self.items = items
        self.sets: list[_FakeSelectionSet] = []

    def Add(self, name):
        ss = _FakeSelectionSet(name, self.items)
        self.sets.append(ss)
        return ss


@pytest.fixture
def com_backend(monkeypatch):
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends import com_backend as module

    utility = _FakeUtility()
    selection = [types.SimpleNamespace(Handle="A1"), types.SimpleNamespace(Handle="B2")]
    document = types.SimpleNamespace(
        Name="Part.dwg",
        FullName="C:/work/Part.dwg",
        Utility=utility,
        SelectionSets=_FakeSelectionSets(selection),
    )
    preferences = types.SimpleNamespace(
        OpenSave=types.SimpleNamespace(SaveAsType=64, AutoSaveInterval=10, CreateBackup=True, IncrementalSavePercent=50),
        Display=types.SimpleNamespace(CursorSize=5),
        Drafting=types.SimpleNamespace(AutoSnapMarkerSize=5, AutoSnapTooltip=True),
        Selection=types.SimpleNamespace(PickBoxSize=3),
        Output=types.SimpleNamespace(DefaultPlotStyleTable="monochrome.ctb", DefaultOutputDevice="DWG To PDF.pc3"),
        Files=types.SimpleNamespace(
            SupportPath="C:/support", TemplateDwgPath="C:/t", PrintStyleSheetPath="C:/p", PrinterConfigPath="C:/pc3"
        ),
    )
    app = types.SimpleNamespace(
        Preferences=preferences,
        Version="25.0",
        Documents=types.SimpleNamespace(Count=1),
        ActiveDocument=document,
    )
    monkeypatch.setattr(module, "_acad_app", lambda: app)
    monkeypatch.setattr(module, "_acad_doc", lambda: document)
    backend = module.ComBackend()

    async def _run_inline(func, *args, **kwargs):
        return func(*args, **kwargs)

    monkeypatch.setattr(backend, "_run", _run_inline)
    return backend, app, document


async def test_com_no_longer_inherits_the_defaults():
    pytest.importorskip("win32com.client", reason="pywin32 not installed")
    from backends.com_backend import ComBackend

    for method in (
        "system_launch", "preferences_get", "preferences_set",
        "user_pick_point", "user_select", "system_prompt_message",
    ):
        assert not is_capability_default(ComBackend, method), method


async def test_com_pick_point_returns_the_operator_point(com_backend):
    backend, app, document = com_backend
    import pythoncom

    result = await backend.user_pick_point("Pick the base point")
    assert result == {"cancelled": False, "x": 12.5, "y": 7.25, "z": 0.0, "backend": "com"}
    (call,) = document.Utility.calls
    assert call[0] == "GetPoint" and call[1][0] is pythoncom.Missing
    assert call[1][1] == "\nPick the base point"


async def test_com_pick_point_cancel_is_an_answer_not_an_error(com_backend):
    backend, app, document = com_backend
    document.Utility.raise_on_pick = _com_error("User input is a keyword")
    result = await backend.user_pick_point("Pick")
    assert result == {"cancelled": True, "reason": "User input is a keyword", "backend": "com"}


async def test_com_pick_point_other_com_errors_still_raise(com_backend):
    backend, app, document = com_backend
    pywintypes = pytest.importorskip("pywintypes")
    document.Utility.raise_on_pick = pywintypes.com_error(-2147418111, "Call was rejected", None, None)
    with pytest.raises(pywintypes.com_error):
        await backend.user_pick_point("Pick")


async def test_com_pick_point_timeout_reports_timed_out(com_backend, monkeypatch):
    backend, app, document = com_backend
    monkeypatch.setattr(config.settings, "com_call_timeout", 7.0)

    async def _timed_out(func, *args, **kwargs):
        raise RuntimeError("AutoCAD did not respond within 7s. The application may be showing a modal dialog")

    monkeypatch.setattr(backend, "_run", _timed_out)
    result = await backend.user_pick_point("Pick")
    assert result["timed_out"] is True and result["timeout_s"] == 7.0
    assert "did not respond" in result["error"]
    result = await backend.user_select("Pick", "multiple")
    assert result["timed_out"] is True


async def test_com_prompt_validation_happens_before_activex(com_backend):
    backend, app, document = com_backend
    with pytest.raises(ValueError, match="prompt must not be empty"):
        await backend.user_pick_point("   ")
    with pytest.raises(ValueError, match="mode must be 'single' or 'multiple'"):
        await backend.user_select("Pick", "many")
    assert document.Utility.calls == [] and document.SelectionSets.sets == []


async def test_com_select_single_uses_getentity(com_backend):
    backend, app, document = com_backend
    result = await backend.user_select("Pick the flange")
    assert result == {
        "cancelled": False, "mode": "single", "handles": ["1F3"], "picked": [3.0, 4.0], "backend": "com",
    }
    assert document.Utility.calls == [("GetEntity", ("\nPick the flange",))]
    document.Utility.raise_on_pick = _com_error("Function cancelled")
    assert (await backend.user_select("Pick"))["cancelled"] is True


async def test_com_select_multiple_uses_a_selection_set_and_deletes_it(com_backend):
    backend, app, document = com_backend
    result = await backend.user_select("Select the pipes", mode="multiple")
    assert result == {
        "cancelled": False, "mode": "multiple", "handles": ["A1", "B2"], "count": 2, "backend": "com",
    }
    assert document.Utility.calls == [("Prompt", ("\nSelect the pipes\n",))]
    (ss,) = document.SelectionSets.sets
    assert ss.Name.startswith("_PICK_") and ss.calls == ["SelectOnScreen", "Delete"]


async def test_com_prompt_message_writes_to_the_command_line(com_backend):
    backend, app, document = com_backend
    result = await backend.system_prompt_message("Drawing saved by the assistant")
    assert result == {"ok": True, "text": "Drawing saved by the assistant", "backend": "com"}
    assert document.Utility.calls == [("Prompt", ("\nDrawing saved by the assistant\n",))]


async def test_com_preferences_get_reads_the_whitelist(com_backend):
    backend, app, document = com_backend
    result = await backend.preferences_get()
    assert result["read_only"] == list(READ_ONLY_KEYS)
    assert result["values"]["OpenSave.SaveAsType"] == "ac2018_dwg"
    assert result["values"]["OpenSave.AutoSaveInterval"] == 10
    assert result["values"]["Files.SupportPath"] == "C:/support"
    assert set(result["values"]) == set(PREFERENCE_KEYS) | set(READ_ONLY_KEYS)
    subset = await backend.preferences_get(["Display.CursorSize"])
    assert subset == {"values": {"Display.CursorSize": 5}, "read_only": [], "backend": "com"}
    with pytest.raises(ValueError, match="unknown preference keys \\['Display.Theme'\\]"):
        await backend.preferences_get(["Display.Theme"])


async def test_com_preferences_set_reports_old_and_new(com_backend):
    backend, app, document = com_backend
    result = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
    assert result == {
        "ok": True, "key": "OpenSave.AutoSaveInterval", "old": 10, "new": 15, "changed": True, "backend": "com",
    }
    assert app.Preferences.OpenSave.AutoSaveInterval == 15
    same = await backend.preferences_set("OpenSave.AutoSaveInterval", 15)
    assert same["changed"] is False
    enum = await backend.preferences_set("OpenSave.SaveAsType", "ac2013_dwg")
    assert enum["old"] == "ac2018_dwg" and enum["new"] == "ac2013_dwg"
    assert app.Preferences.OpenSave.SaveAsType == 60


async def test_com_preferences_set_refuses_before_any_write(com_backend):
    backend, app, document = com_backend
    with pytest.raises(ValueError, match="read-only"):
        await backend.preferences_set("Files.SupportPath", "C:/evil")
    with pytest.raises(ValueError, match="between 1 and 100"):
        await backend.preferences_set("Display.CursorSize", 0)
    with pytest.raises(ValueError, match="unknown preference"):
        await backend.preferences_set("Display.Theme", 1)
    assert app.Preferences.Files.SupportPath == "C:/support"
    assert app.Preferences.Display.CursorSize == 5


async def test_com_launch_attaches_first_and_dispatches_otherwise(com_backend, monkeypatch):
    backend, app, document = com_backend
    from backends import com_backend as module

    monkeypatch.setattr(module, "_COM_STATE", {})
    calls: list[str] = []

    def _get_active(progid):
        calls.append(f"GetActiveObject:{progid}")
        raise RuntimeError("not running")

    def _dispatch(progid):
        calls.append(f"Dispatch:{progid}")
        app.Visible = False
        return app

    monkeypatch.setattr(module.win32com.client, "GetActiveObject", _get_active)
    monkeypatch.setattr(module.win32com.client, "Dispatch", _dispatch)
    result = await backend.system_launch(visible=True)
    assert result == {
        "launched": True, "attached": False, "version": "25.0", "document": "Part.dwg",
        "visible": True, "progid": config.settings.cad_progid, "backend": "com",
    }
    assert calls == [
        f"GetActiveObject:{config.settings.cad_progid}",
        f"Dispatch:{config.settings.cad_progid}",
    ]
    assert app.Visible is True and module._COM_STATE["app"] is app

    again = await backend.system_launch()
    assert again["launched"] is False and again["attached"] is True
    assert len(calls) == 2, "an app already cached is attached, not re-dispatched"


async def test_com_launch_opens_a_validated_path(com_backend, monkeypatch, tmp_path):
    backend, app, document = com_backend
    from backends import com_backend as module

    opened: list[str] = []
    app.Documents = types.SimpleNamespace(
        Count=1, Open=lambda path: opened.append(path) or types.SimpleNamespace(Name="gear.dwg")
    )
    monkeypatch.setattr(module, "_COM_STATE", {"app": app})
    target = tmp_path / "gear.dwg"
    target.write_bytes(b"")
    result = await backend.system_launch(open_path=str(target))
    assert result["document"] == "gear.dwg" and opened == [str(target.resolve())]
    from fastmcp.exceptions import ToolError

    with pytest.raises(ToolError):
        await backend.system_launch(open_path="../../etc/passwd")
  • Step 2: Run them to verify they fail

Run: uv run --frozen pytest tests/test_environment_live.py -v Expected: FAIL at collection with ModuleNotFoundError: No module named 'engineering.environment.preferences'.

  • Step 3: The preference whitelist
# engineering/environment/preferences.py
"""The AutoCAD preferences a model may read and write, as data.

``Preferences.*`` lives in the running application (registry-backed), not in
a drawing, so the headless engine refuses with ``capability: "preferences"``.
The whitelist is deliberately short: every key has a type and a range so a
write is refused *by name* before any ActiveX call, and the four ``Files.*``
paths are read-only because rewriting a support path is how an installation
gets broken. ``OpenSave.SaveAsType`` speaks AutoCAD's ``AcSaveAsType`` enum
by name in both directions.
"""

from __future__ import annotations

#: AcSaveAsType: each release adds 12; dwg, dxf (+1), template (+2).
SAVE_AS_TYPES: dict[str, int] = {
    "ac2000_dwg": 12,
    "ac2000_dxf": 13,
    "ac2000_template": 14,
    "ac2004_dwg": 24,
    "ac2004_dxf": 25,
    "ac2004_template": 26,
    "ac2007_dwg": 36,
    "ac2007_dxf": 37,
    "ac2007_template": 38,
    "ac2010_dwg": 48,
    "ac2010_dxf": 49,
    "ac2010_template": 50,
    "ac2013_dwg": 60,
    "ac2013_dxf": 61,
    "ac2013_template": 62,
    "ac2018_dwg": 64,
    "ac2018_dxf": 65,
    "ac2018_template": 66,
}

#: key -> (kind, spec). kind: "bool" | "int" (spec = (lo, hi)) | "str" | "enum" (spec = name -> value)
PREFERENCE_KEYS: dict[str, tuple[str, object]] = {
    "OpenSave.SaveAsType": ("enum", SAVE_AS_TYPES),
    "OpenSave.AutoSaveInterval": ("int", (0, 600)),
    "OpenSave.CreateBackup": ("bool", None),
    "OpenSave.IncrementalSavePercent": ("int", (0, 100)),
    "Display.CursorSize": ("int", (1, 100)),
    "Drafting.AutoSnapMarkerSize": ("int", (1, 20)),
    "Drafting.AutoSnapTooltip": ("bool", None),
    "Selection.PickBoxSize": ("int", (0, 50)),
    "Output.DefaultPlotStyleTable": ("str", None),
    "Output.DefaultOutputDevice": ("str", None),
}

READ_ONLY_KEYS: tuple[str, ...] = (
    "Files.SupportPath",
    "Files.TemplateDwgPath",
    "Files.PrintStyleSheetPath",
    "Files.PrinterConfigPath",
)


def split_key(key: str) -> tuple[str, str]:
    """``"OpenSave.AutoSaveInterval"`` → ``("OpenSave", "AutoSaveInterval")``."""
    section, _, member = key.partition(".")
    return section, member


def known_keys() -> list[str]:
    return list(PREFERENCE_KEYS) + list(READ_ONLY_KEYS)


def validate_preference(key: str, value):
    """The value to write, coerced to the key's type — or a refusal naming the key.

    Writes nothing; both engines call it before touching anything.
    """
    if key in READ_ONLY_KEYS:
        raise ValueError(f"preference {key!r} is read-only")
    if key not in PREFERENCE_KEYS:
        raise ValueError(
            f"unknown preference {key!r}; writable keys: {list(PREFERENCE_KEYS)}; "
            f"read-only keys: {list(READ_ONLY_KEYS)}"
        )
    kind, spec = PREFERENCE_KEYS[key]
    if kind == "bool":
        if not isinstance(value, bool):
            raise TypeError(f"preference {key!r} takes a bool, got {value!r}")
        return value
    if kind == "int":
        whole = isinstance(value, int) and not isinstance(value, bool)
        whole = whole or (isinstance(value, float) and value.is_integer())
        if not whole:
            raise TypeError(f"preference {key!r} takes an int, got {value!r}")
        number = int(value)
        lo, hi = spec
        if not lo <= number <= hi:
            raise ValueError(f"preference {key!r} must be between {lo} and {hi}, got {number}")
        return number
    if kind == "str":
        if not isinstance(value, str) or not value.strip():
            raise TypeError(f"preference {key!r} takes a non-empty string, got {value!r}")
        return value.strip()
    # enum
    if isinstance(value, str):
        name = value.strip().lower()
        if name in spec:
            return spec[name]
        raise ValueError(f"preference {key!r} must be one of {sorted(spec)}, got {value!r}")
    if isinstance(value, bool) or not isinstance(value, int) or value not in spec.values():
        raise ValueError(
            f"preference {key!r} must be one of {sorted(spec)} (or their values), got {value!r}"
        )
    return value


def decode_value(key: str, raw):
    """What the application returned, in the whitelist's vocabulary (enum names, real bools)."""
    kind, spec = PREFERENCE_KEYS.get(key, (None, None))
    if kind == "enum":
        for name, code in spec.items():
            if code == raw:
                return name
        return raw
    if kind == "bool":
        return bool(raw)
    if kind == "int":
        return int(raw)
    return raw


def describe_preferences() -> list[dict]:
    rows = []
    for key, (kind, spec) in PREFERENCE_KEYS.items():
        rows.append(
            {
                "key": key,
                "kind": kind,
                "range": list(spec) if kind == "int" else None,
                "enum": sorted(spec) if kind == "enum" else None,
                "writable": True,
            }
        )
    for key in READ_ONLY_KEYS:
        rows.append({"key": key, "kind": "str", "range": None, "enum": None, "writable": False})
    return rows

Add to engineering/environment/__init__.py:

from .preferences import (  # noqa: F401
    PREFERENCE_KEYS,
    READ_ONLY_KEYS,
    describe_preferences,
    validate_preference,
)

Run: uv run --frozen pytest tests/test_environment_live.py -k "whitelist or validate_preference or decode_value or describe or headless or reaches_the_client" -v Expected: PASS — the pure tests and the headless refusals (declared in Task 21) already hold; the com_ tests fail with is_capability_default(ComBackend, ...) being True / AttributeError.

  • Step 4: COM implementations

Append to the # ── environment (track E) ── block in backends/com_backend.py:

    # ── application, preferences, operator prompts ────────────────────────────

    async def system_launch(self, visible: bool = True, open_path: str | None = None) -> dict:
        """Attach to the running application, or start it; optionally open a file.

        Honours ``CAD_PROGID`` on both paths, like ``_acad_app``: ``Dispatch``
        launches the application, so falling back to AutoCAD would start the
        very product the operator said they were not using. The path is
        validated before any COM call.
        """
        path = str(validate_path(open_path, allow_write=False)) if open_path else None

        def _sync():
            progid = config.settings.cad_progid
            app = _COM_STATE.get("app")
            launched = False
            if app is None:
                try:
                    app = win32com.client.GetActiveObject(progid)
                except Exception as exc:
                    log.debug("GetActiveObject(%r) failed, dispatching: %s", progid, exc)
                    app = win32com.client.Dispatch(progid)
                    launched = True
                _COM_STATE["app"] = app
            try:
                app.Visible = bool(visible)
            except Exception as exc:  # some hosts refuse to hide
                log.debug("Visible=%s refused: %s", visible, exc)
            document = None
            if path:
                document = str(app.Documents.Open(path).Name)
            elif int(app.Documents.Count):
                document = str(app.ActiveDocument.Name)
            return {
                "launched": launched,
                "attached": not launched,
                "version": str(app.Version),
                "document": document,
                "visible": bool(visible),
                "progid": progid,
                "backend": "com",
            }

        result = await self._run(_sync)
        await self._ensure_document_state()
        return result

    async def preferences_get(self, keys: list[str] | None = None) -> dict:
        from engineering.environment.preferences import (
            PREFERENCE_KEYS,
            READ_ONLY_KEYS,
            decode_value,
            known_keys,
            split_key,
        )

        if keys is None:
            wanted = known_keys()
        else:
            if isinstance(keys, str) or not isinstance(keys, (list, tuple)):
                raise TypeError("preferences_get: keys must be a list of preference names")
            unknown = [k for k in keys if k not in PREFERENCE_KEYS and k not in READ_ONLY_KEYS]
            if unknown:
                raise ValueError(
                    f"preferences_get: unknown preference keys {unknown}; known: {known_keys()}"
                )
            wanted = list(keys)

        def _sync():
            prefs = _acad_app().Preferences
            values = {}
            for key in wanted:
                section, member = split_key(key)
                values[key] = decode_value(key, getattr(getattr(prefs, section), member))
            return {
                "values": values,
                "read_only": [k for k in wanted if k in READ_ONLY_KEYS],
                "backend": "com",
            }

        return await self._run(_sync)

    async def preferences_set(self, key: str, value) -> dict:
        from engineering.environment.preferences import (
            decode_value,
            split_key,
            validate_preference,
        )

        coerced = validate_preference(key, value)  # read-only / unknown / range: refused here
        section, member = split_key(key)

        def _sync():
            target = getattr(_acad_app().Preferences, section)
            old = getattr(target, member)
            setattr(target, member, coerced)
            new = getattr(target, member)
            return {
                "ok": True,
                "key": key,
                "old": decode_value(key, old),
                "new": decode_value(key, new),
                "changed": old != new,
                "backend": "com",
            }

        return await self._run(_sync)

    @staticmethod
    def _prompt_text(prompt, where: str, max_len: int = 255) -> str:
        from engineering.environment.names import validate_name

        return validate_name(prompt, what=f"{where}: prompt", max_len=max_len)

    @staticmethod
    def _com_cancel_reason(exc) -> str | None:
        """The operator's cancel as ActiveX reports it, or None for any other COM error.

        ESC in GetPoint / GetEntity / SelectOnScreen raises DISP_E_EXCEPTION
        (-2147352567) with AutoCAD's description ("User input is a keyword",
        "Function cancelled"). Anything else is a real failure and propagates.
        """
        args = getattr(exc, "args", ())
        if not args or args[0] != -2147352567:
            return None
        detail = args[2] if len(args) > 2 and isinstance(args[2], tuple) else ()
        description = detail[2] if len(detail) > 2 and detail[2] else "cancelled"
        return str(description)

    async def _interactive(self, func):
        """Run an operator prompt; a wall-clock timeout is an answer, not a crash.

        ``_run`` has already rebuilt the STA executor by the time its
        "did not respond" error surfaces (the worker is still blocked inside
        the prompt); the next call re-attaches lazily, exactly as after any
        other timeout. ``COM_CALL_TIMEOUT`` is the budget the operator has.
        """
        try:
            return await self._run(func)
        except RuntimeError as exc:
            if str(exc).startswith("AutoCAD did not respond"):
                return {
                    "timed_out": True,
                    "timeout_s": config.settings.com_call_timeout,
                    "error": str(exc),
                }
            raise

    async def user_pick_point(self, prompt: str) -> dict:
        text = self._prompt_text(prompt, "user_pick_point")

        def _sync():
            doc = _acad_doc()
            try:
                point = doc.Utility.GetPoint(pythoncom.Missing, f"\n{text}")
            except _COM_ERROR as exc:
                reason = self._com_cancel_reason(exc)
                if reason is None:
                    raise
                return {"cancelled": True, "reason": reason, "backend": "com"}
            return {
                "cancelled": False,
                "x": float(point[0]),
                "y": float(point[1]),
                "z": float(point[2]) if len(point) > 2 else 0.0,
                "backend": "com",
            }

        return await self._interactive(_sync)

    async def user_select(self, prompt: str, mode: str = "single") -> dict:
        if mode not in ("single", "multiple"):
            raise ValueError(f"user_select: mode must be 'single' or 'multiple', got {mode!r}")
        text = self._prompt_text(prompt, "user_select")

        def _sync():
            doc = _acad_doc()
            if mode == "single":
                try:
                    obj, picked = doc.Utility.GetEntity(f"\n{text}")
                except _COM_ERROR as exc:
                    reason = self._com_cancel_reason(exc)
                    if reason is None:
                        raise
                    return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
                return {
                    "cancelled": False,
                    "mode": "single",
                    "handles": [str(obj.Handle)],
                    "picked": [float(picked[0]), float(picked[1])],
                    "backend": "com",
                }
            doc.Utility.Prompt(f"\n{text}\n")
            ss = doc.SelectionSets.Add(f"_PICK_{uuid.uuid4().hex[:8]}")
            try:
                try:
                    ss.SelectOnScreen()
                except _COM_ERROR as exc:
                    reason = self._com_cancel_reason(exc)
                    if reason is None:
                        raise
                    return {"cancelled": True, "reason": reason, "handles": [], "backend": "com"}
                handles = [str(ss.Item(i).Handle) for i in range(int(ss.Count))]
                # Enter with nothing selected is an empty answer, not a cancel.
                return {
                    "cancelled": False,
                    "mode": "multiple",
                    "handles": handles,
                    "count": len(handles),
                    "backend": "com",
                }
            finally:
                try:
                    ss.Delete()
                except Exception as exc:
                    log.debug("SelectionSet cleanup failed: %s", exc)

        return await self._interactive(_sync)

    async def system_prompt_message(self, text: str) -> dict:
        clean = self._prompt_text(text, "system_prompt_message", max_len=2000)

        def _sync():
            _acad_doc().Utility.Prompt(f"\n{clean}\n")
            return {"ok": True, "text": clean, "backend": "com"}

        return await self._run(_sync)

Run: uv run --frozen pytest tests/test_environment_live.py tests/test_capability_contract.py tests/test_com_backend.py -v Expected: all PASS.

  • Step 5: Register the six tools, aliases, README, inventory, registry snapshot

server.py SECTION 20 header → (19 tools); append after ucs_restore:

@cad_tool(
    summary="Attach to the running AutoCAD or start it, optionally opening a file (live only).",
    cost="safe",
)
@mcp.tool(
    annotations={"title": "Launch / Attach AutoCAD", "readOnlyHint": False, "destructiveHint": False},
    tags={"system"},
)
async def system_launch(
    visible: Annotated[bool, "Show the application window"] = True,
    open_path: Annotated[str | None, "A .dwg/.dxf to open after attaching"] = None,
    ctx: Context = None,
) -> dict:
    """Connect to the application named by `CAD_PROGID` (attach if running,
    launch otherwise) and report `launched`, `attached`, `version` and the
    active `document`. Refusals: headless, `capability: "live_application"`
    (there is no application to launch); an `open_path` that fails path
    validation. Pack: settings · lean: no.
    """
    if open_path is not None:
        open_path = str(validate_path(open_path, allow_write=False))
    await ctx.info(f"Launching or attaching to {config.settings.cad_progid}")
    return await _backend(ctx).system_launch(visible, open_path)


@cad_tool(summary="Read AutoCAD preferences from the whitelist (OPTIONS dialog values; live only).", cost="read")
@mcp.tool(
    annotations={"title": "Get Preferences", "readOnlyHint": True},
    tags={"system"},
)
async def system_preferences_get(
    keys: Annotated[
        list[str] | None,
        "Subset of OpenSave.SaveAsType, OpenSave.AutoSaveInterval, OpenSave.CreateBackup, "
        "OpenSave.IncrementalSavePercent, Display.CursorSize, Drafting.AutoSnapMarkerSize, "
        "Drafting.AutoSnapTooltip, Selection.PickBoxSize, Output.DefaultPlotStyleTable, "
        "Output.DefaultOutputDevice, Files.SupportPath, Files.TemplateDwgPath, "
        "Files.PrintStyleSheetPath, Files.PrinterConfigPath; omit for all",
    ] = None,
    ctx: Context = None,
) -> dict:
    """The whitelisted `Preferences.*` values, enum values as names
    (`SaveAsType: "ac2018_dwg"`), with `read_only` naming the four `Files.*`
    paths. Refusals: headless, `capability: "preferences"` (they live in the
    running application, not in a file); a key outside the whitelist.
    Pack: settings · lean: no.
    """
    return await _backend(ctx).preferences_get(keys)


@cad_tool(summary="Change one whitelisted AutoCAD preference; reports old and new (live only).", cost="safe")
@mcp.tool(
    annotations={"title": "Set Preference", "readOnlyHint": False, "destructiveHint": False},
    tags={"system"},
)
async def system_preferences_set(
    key: Annotated[str, "A writable key from system_preferences_get"],
    value: Annotated[Any, "New value: bool, int within the key's range, string, or an enum name"],
    ctx: Context = None,
) -> dict:
    """Write one preference and report `old`, `new` and `changed`.

    Refusals, all before any write: a read-only key (`Files.*`), an unknown
    key, a value of the wrong type, an int outside the authored range
    (`AutoSaveInterval` 0–600 min, `CursorSize` 1–100, `PickBoxSize` 0–50, …),
    an unknown `SaveAsType` name; headless, `capability: "preferences"`.
    Pack: settings · lean: no.
    """
    await ctx.info(f"Setting preference {key} = {value!r}")
    return await _backend(ctx).preferences_set(key, value)


@cad_tool(summary="Ask the operator to pick a point on screen (live only; blocks until they do).", cost="read")
@mcp.tool(
    annotations={"title": "Ask Operator: Pick Point", "readOnlyHint": True},
    tags={"system"},
)
async def user_pick_point(
    prompt: Annotated[str, "Shown on the command line, e.g. 'Pick the base point'"],
    ctx: Context = None,
) -> dict:
    """`Utility.GetPoint`: returns the WCS `x`, `y` (`z`) the operator clicks.

    ESC is an answer, not an error: `cancelled: true` with AutoCAD's reason.
    Waiting longer than `COM_CALL_TIMEOUT` returns `timed_out: true` (the
    prompt is abandoned on AutoCAD's side; press ESC there). Refusals: an
    empty prompt; headless, `capability: "interactive_prompt"` (no operator).
    Pack: settings · lean: no.
    """
    await ctx.info(f"Asking the operator to pick a point: {prompt}")
    return await _backend(ctx).user_pick_point(prompt)


@cad_tool(summary="Ask the operator to select one entity or a set on screen (live only).", cost="read")
@mcp.tool(
    annotations={"title": "Ask Operator: Select", "readOnlyHint": True},
    tags={"system"},
)
async def user_select(
    prompt: Annotated[str, "Shown on the command line"],
    mode: Annotated[str, "single (GetEntity) | multiple (SelectOnScreen)"] = "single",
    ctx: Context = None,
) -> dict:
    """Returns `handles` the operator picked — one with `mode="single"` (plus
    the `picked` point), any number with `mode="multiple"` (Enter with nothing
    selected is `count: 0`, not a cancel). ESC → `cancelled: true`; a wait
    longer than `COM_CALL_TIMEOUT` → `timed_out: true`. Refusals: a mode
    outside the two, an empty prompt; headless, `capability:
    "interactive_prompt"`. Pack: settings · lean: no.
    """
    await ctx.info(f"Asking the operator to select ({mode}): {prompt}")
    return await _backend(ctx).user_select(prompt, mode)


@cad_tool(summary="Print a message on AutoCAD's command line (live only).", cost="safe")
@mcp.tool(
    annotations={"title": "Command-Line Message", "readOnlyHint": False, "destructiveHint": False},
    tags={"system"},
)
async def system_prompt_message(
    text: Annotated[str, "The message; one line is best"],
    ctx: Context = None,
) -> dict:
    """`Utility.Prompt`: tell the operator something where they are looking.
    Nothing in the drawing changes. Refusals: an empty message; headless,
    `capability: "interactive_prompt"`. Pack: settings · lean: no."""
    return await _backend(ctx).system_prompt_message(text)

discovery/aliases.py: add "OPTIONS" to SHARED_ACAD_COMMANDS with the comment

#   OPTIONS -- one dialog both reads and writes a preference; the server
#       splits read (system_preferences_get) and write (system_preferences_set),
#       the SETVAR precedent.

and these records in the Environment block (alphabetical):

    "system_launch": ToolAliases(
        acad=(),
        synonyms=(
            "start autocad",
            "launch autocad",
            "attach to the running autocad",
            "is autocad running",
            "open autocad with this file",
        ),
    ),
    "system_preferences_get": ToolAliases(
        acad=("OPTIONS",),
        synonyms=(
            "read a preference",
            "autosave interval",
            "support file search path",
            "what is the pickbox size",
            "options dialog value",
        ),
    ),
    "system_preferences_set": ToolAliases(
        acad=("OPTIONS",),
        synonyms=(
            "change a preference",
            "set the autosave interval",
            "cursor size",
            "pickbox size",
            "default plot style table",
        ),
    ),
    "system_prompt_message": ToolAliases(
        acad=(),
        synonyms=(
            "message on the command line",
            "tell the operator",
            "print to the command line",
            "command line note",
            "say something in autocad",
        ),
    ),
    "user_pick_point": ToolAliases(
        acad=("ID",),
        synonyms=(
            "pick a point",
            "click a point on screen",
            "ask the operator for a point",
            "where should this go",
            "let me click",
        ),
    ),
    "user_select": ToolAliases(
        acad=("SELECT",),
        synonyms=(
            "let me select",
            "pick objects on screen",
            "ask the operator to select",
            "select on screen",
            "hand me the selection",
        ),
    ),

README.md: snapshot 179 tools → 185 tools; replace the Environment row with:

| Environment | `document_list/activate/close` — several drawings open at once on both engines; portable layer states (`ACADMCP_LAYERSTATES` XRECORDs — in the file, not in AutoCAD's Layer States Manager); named views; UCS stored and made current *(tool coordinates stay WCS)*; live only: launch/attach, a preference whitelist (`OPTIONS`), and operator prompts — pick a point, select on screen, a command-line message — where ESC is `cancelled`, not an error |

tests/test_tool_registry.py: "system": 7 → 13, total 179 → 185; docstring sentence: "system moved 7 -> 13 with v1.6's live-only environment tools (system_launch, system_preferences_get/set, user_pick_point, user_select, system_prompt_message), which refuse headlessly with declared capability keys."

Then: uv run --frozen python scripts/generate_tool_inventory.py.

  • Step 6: Run the gates

Run: uv run --frozen pytest tests/test_environment_live.py tests/test_documents.py tests/test_layer_states.py tests/test_views_ucs.py tests/test_discovery_aliases.py tests/test_tool_inventory.py tests/test_release_consistency.py tests/test_tool_registry.py tests/test_capability_contract.py tests/test_capability_refusal.py tests/test_capabilities.py tests/test_tool_packs.py tests/test_tool_profiles.py -v Expected: all PASS. Then the whole suite once: uv run --frozen pytest -x -q → PASS (the README collected tests drift, if it is the only failure in test_document_numbers_match_the_tree, is the pre-existing one left to the final task).

  • Step 7: Commit
git add engineering/environment/preferences.py engineering/environment/__init__.py backends/com_backend.py server.py discovery/aliases.py README.md docs/tool-inventory.json tests/test_tool_registry.py tests/test_environment_live.py
git commit -m "feat(environment): system_launch, preference whitelist and operator prompts on COM; declared refusals headlessly

user_pick_point / user_select answer {cancelled: true} on ESC (DISP_E_EXCEPTION)
and {timed_out: true} past COM_CALL_TIMEOUT; preferences_set refuses read-only,
unknown and out-of-range keys before any ActiveX call.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FKFStjEFzpGyLq5bMpWxTd"

Hand-off to the merge agent (group V)

  • backends/contracts/environment.py: replace the two @capability("dwgprops", …) decorators on drawing_properties_get/set with @abstractmethod (and delete the # TEMPORARY comment block) once group C's implementations are present in both backends; the dwgprops entries V wrote in both capability maps are byte-identical to C's, so keep exactly one per map (C's) and drop V's.
  • backends/ezdxf_backend.py: delete V's # TEMPORARY copy of _active_vport (Task 22, named-views section of the environment block); C's Task 14 definition in the settings block is the one that ships. Two definitions are ruff F811.
  • Block anchors are distinct per group (skeleton, Merge notes): V's # ── environment (track E) ── block sits directly after the layouts block in both backends; S's before # ── corner ops, C's at the end of the class, P's before # ── 3D solids. The capability-map hunk after "lisp" is the one place S, C and V all insert — merged order S → C → V by key name.
  • server.py SECTION 20 header: V ends at (19 tools); with C's system_variable_describe, drawing_properties_get, drawing_properties_set the merged header is (22 tools).
  • Count files: README snapshot (V ends at 185 tools), docs/tool-inventory.json, tests/test_tool_registry.py group sizes — re-derive after the merge.
  • Final task wiring: PACK_TOOL_NAMES["settings"] = the 22 SECTION 20 tools (all 19 of V's plus C's 3); none of V's tools joins LEAN_TOOL_NAMES; the discovery golden file gains queries such as "which drawings are open", "restore the layer state", "pick a point", "ucs world".
  • scripts/smoke_settings_com.py (Task 24) must execute, live: document_list / document_activate / document_close(discard=True) on a scratch drawing, layer_state_save → change → layer_state_restore, view_named_save / view_named_restore, ucs_set / ucs_restore("world") (verifies the UCS World command route and the GetEntity out-parameter tuple), preferences_get and one reversible preferences_set (OpenSave.AutoSaveInterval old → old), system_prompt_message; user_pick_point / user_select only with --interactive.

Task 24: Merge sanity — packs, lean, group tags, section headers, count files, golden queries

Group: Final (worktree main, after the merge agent has merged track-e/hardening, track-e/styles, track-e/settings, track-e/pagesetup, track-e/environment). This task owns every file it touches; nothing here is edited by another group any more.

Files:

  • Modify: server.py (TOOL_PACK_NAMES, PACK_TOOL_NAMES["settings"], LEAN_TOOL_NAMES + 2 — S Task 13 already added its three, _GROUP_TAG_PRIORITY / _GROUP_TAG_LABELS + 2 with S's style entry moved up next to pid, SECTION 18/19/20 header counts, section tags on the 38 tools where a group used a different one), backends/base.py (composition of StylesContract, PageSetupContract, EnvironmentContract — a no-op if every group's first task already added its own), backends/contracts/__init__.py (exports), backends/ezdxf_backend.py + backends/com_backend.py (layer_states feature entry confirmed, see Step 3.6), config.py + .env.example (pack list text), tests/test_tool_packs.py, tests/test_capability_contract.py, tests/data/discovery_golden.py, tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged, docs/tool-inventory.json (regenerated), README.md (release-snapshot line + <sub> group count + config-table pack list), discovery/aliases.py (only where Step 4's routing probe shows a golden query cannot reach its tool)
  • Test: tests/test_tool_packs.py, tests/test_capability_contract.py, tests/test_discovery_transform.py (golden set), tests/test_tool_registry.py, tests/test_release_consistency.py, tests/test_tool_inventory.py, tests/test_discovery_aliases.py

Interfaces:

  • Produces: server.TOOL_PACK_NAMES == ("core", "pid", "settings"); server.PACK_TOOL_NAMES["settings"] = the 22 SECTION 20 tools below; len(server.LEAN_TOOL_NAMES) == 55; server._GROUP_TAG_LABELS["style"] == "styles", ["pagesetup"] == "page_setup", ["environment"] == "environment"; _tool_groups() reports styles: 10, page_setup: 6, environment: 22, 23 groups, 204 tools.
  • Consumes: the merged SECTION 18 (10 tools), SECTION 19 (6), SECTION 20 (22) registrations; the three contracts; the six capability keys of spec §8.1 declared by their groups.

The 38 tool names this task pins (from the spec; a group that registered a different spelling is corrected here, on main, because the spec is the contract every group built to):

SECTION 18 (styles, pack core):      dimstyle_list dimstyle_create dimstyle_modify dimstyle_set_current
                                     textstyle_list textstyle_create textstyle_set_current
                                     mleaderstyle_list mleaderstyle_create drawing_apply_standard
SECTION 19 (page_setup, pack core):  page_setup_list page_setup_apply plot_style_list batch_plot
                                     drawing_template_list drawing_template_save
SECTION 20 (environment, pack settings):
                                     document_list document_activate document_close
                                     layer_state_save layer_state_restore layer_state_list layer_state_delete
                                     view_named_save view_named_restore view_named_list
                                     ucs_list ucs_set ucs_restore
                                     system_launch system_preferences_get system_preferences_set
                                     drawing_properties_get drawing_properties_set system_variable_describe
                                     user_pick_point user_select system_prompt_message

Decision recorded here because the skeleton left it open: group C's stub header ends at SECTION 20: Environment (3 tools) — opened at (1 tool) by Task 15 with system_variable_describe, grown to (3 tools) by Task 16's drawing_properties_get/set — and V's ends at (19 tools), so the merged header is 22 and system_variable_describe is tagged environment and belongs to the settings pack — pack, group and section are the same 22 names, which is the only arrangement where PACK_TOOL_NAMES["settings"], _tool_groups()["environment"] and the header count can all be checked against one list. Step 3.3 moves all three of C's registration blocks under V's header; if system_variable_describe somehow ended up under SECTION 11 instead, the same step moves it from there.

Fill-in convention for Tasks 24–26: <collected> (Step 3.8 and the Task 26 CHANGELOG), <tokens> (the five rows of the Task 26 README token table) and <the printed report> (the Task 25 CHANGELOG smoke record) are deliberate placeholders — the executor replaces each with the value the named command prints on this machine at that step (never a figure carried over from this plan or an earlier run), and no placeholder may survive into a committed file.

  • Step 1: Write the failing tests

Replace tests/test_tool_packs.py in full:

# tests/test_tool_packs.py
"""TOOL_PACKS: advertise only the vertical packs a client uses; core is always on.

v1.6 track E adds the `settings` pack: the 22 SECTION 20 environment tools.
Styles (SECTION 18) and page setup / templates (SECTION 19) are drafting
essentials and stay in `core`, so `TOOL_PACKS=core` hides the environment
surface and nothing else.
"""

from __future__ import annotations

import logging

import pytest
import pytest_asyncio

import config
import server

pytestmark = pytest.mark.asyncio

PID_TOOLS = {
    "pid_symbol_list",
    "pid_symbol_insert",
    "pid_line_draw",
    "pid_tag_parse",
    "pid_graph",
    "pid_instrument_index",
    "pid_line_list",
    "pid_equipment_list",
    "pid_from_spec",
}

STYLE_TOOLS = {
    "dimstyle_list",
    "dimstyle_create",
    "dimstyle_modify",
    "dimstyle_set_current",
    "textstyle_list",
    "textstyle_create",
    "textstyle_set_current",
    "mleaderstyle_list",
    "mleaderstyle_create",
    "drawing_apply_standard",
}

PAGE_SETUP_TOOLS = {
    "page_setup_list",
    "page_setup_apply",
    "plot_style_list",
    "batch_plot",
    "drawing_template_list",
    "drawing_template_save",
}

SETTINGS_TOOLS = {
    "document_list",
    "document_activate",
    "document_close",
    "layer_state_save",
    "layer_state_restore",
    "layer_state_list",
    "layer_state_delete",
    "view_named_save",
    "view_named_restore",
    "view_named_list",
    "ucs_list",
    "ucs_set",
    "ucs_restore",
    "system_launch",
    "system_preferences_get",
    "system_preferences_set",
    "drawing_properties_get",
    "drawing_properties_set",
    "system_variable_describe",
    "user_pick_point",
    "user_select",
    "system_prompt_message",
}

LEAN_SETTINGS_ESSENTIALS = {
    "drawing_apply_standard",
    "page_setup_apply",
    "batch_plot",
    "dimstyle_set_current",
    "textstyle_set_current",
}

ALL_PACKS = ["core", "pid", "settings"]


@pytest_asyncio.fixture(autouse=True)
async def _restore(monkeypatch):
    yield
    monkeypatch.setattr(config.settings, "tool_packs", "all")
    await server._apply_tool_profile("full")


def test_pack_registry_names_real_tools_and_only_them():
    assert server.TOOL_PACK_NAMES == ("core", "pid", "settings")
    assert server.PACK_TOOL_NAMES["pid"] == frozenset(PID_TOOLS)
    assert server.PACK_TOOL_NAMES["settings"] == frozenset(SETTINGS_TOOLS)
    assert len(SETTINGS_TOOLS) == 22
    assert not (server.PACK_TOOL_NAMES["settings"] & server.PACK_TOOL_NAMES["pid"])


async def test_every_pack_member_is_a_registered_tool():
    registered = {tool.name for tool in await server._registered_tools()}
    for pack, names in server.PACK_TOOL_NAMES.items():
        missing = names - registered
        assert not missing, f"pack {pack!r} names unregistered tools: {sorted(missing)}"


async def test_core_only_hides_pid_and_settings_but_keeps_styles_and_page_setup(monkeypatch):
    monkeypatch.setattr(config.settings, "tool_packs", "core")
    info = await server._apply_tool_profile("full")
    disabled = set(info["disabled_tools"])
    assert PID_TOOLS <= disabled
    assert SETTINGS_TOOLS <= disabled
    assert not (STYLE_TOOLS & disabled), "styles are core drafting essentials"
    assert not (PAGE_SETUP_TOOLS & disabled), "page setup and templates are core"
    assert "block_define" not in disabled and "entity_set_xdata" not in disabled
    assert info["tool_packs"] == {"enabled": ["core"], "available": ALL_PACKS, "ignored": []}


async def test_settings_pack_alone_keeps_pid_hidden(monkeypatch):
    monkeypatch.setattr(config.settings, "tool_packs", "settings")
    info = await server._apply_tool_profile("full")
    disabled = set(info["disabled_tools"])
    assert PID_TOOLS <= disabled
    assert not (SETTINGS_TOOLS & disabled)
    assert info["tool_packs"]["enabled"] == ["core", "settings"]


async def test_all_is_the_default_and_enables_every_pack(monkeypatch):
    monkeypatch.setattr(config.settings, "tool_packs", "all")
    info = await server._apply_tool_profile("full")
    disabled = set(info["disabled_tools"])
    assert not ((PID_TOOLS | SETTINGS_TOOLS) & disabled)
    assert info["tool_packs"]["enabled"] == ALL_PACKS


async def test_unknown_pack_is_ignored_with_a_warning_and_core_cannot_be_dropped(
    monkeypatch, caplog
):
    monkeypatch.setattr(config.settings, "tool_packs", "pid,mech3000")
    with caplog.at_level(logging.WARNING, logger="autocad_mcp"):
        info = await server._apply_tool_profile("full")
    assert info["tool_packs"] == {
        "enabled": ["core", "pid"],
        "available": ALL_PACKS,
        "ignored": ["mech3000"],
    }
    assert "mech3000" in caplog.text


async def test_lean_intersects_with_packs(monkeypatch):
    assert {"pid_symbol_insert", "pid_line_draw", "pid_graph"} <= server.LEAN_TOOL_NAMES
    monkeypatch.setattr(config.settings, "tool_packs", "core")
    info = await server._apply_tool_profile("lean")
    assert "pid_graph" in info["disabled_tools"]
    assert "entity_create_line" not in info["disabled_tools"]


async def test_lean_carries_the_five_settings_essentials_and_nothing_from_the_settings_pack(
    monkeypatch,
):
    """Spec §8.2: lean = 55. The five are core-pack tools, so `TOOL_PACKS=core`
    leaves them on a lean surface; no environment tool is lean."""
    assert LEAN_SETTINGS_ESSENTIALS <= server.LEAN_TOOL_NAMES
    assert not (SETTINGS_TOOLS & server.LEAN_TOOL_NAMES)
    assert len(server.LEAN_TOOL_NAMES) == 55
    monkeypatch.setattr(config.settings, "tool_packs", "core")
    info = await server._apply_tool_profile("lean")
    disabled = set(info["disabled_tools"])
    assert not (LEAN_SETTINGS_ESSENTIALS & disabled)
    assert info["enabled_count"] == 55 - 3, "lean minus the three pid tools"


async def test_the_three_track_e_sections_file_under_their_own_groups():
    """Pack, group and SECTION are the same list, so `system_about` and
    `docs/tool-inventory.json` describe the surface the packs gate."""
    groups = await server._tool_groups()
    assert set(groups["styles"]) == STYLE_TOOLS
    assert set(groups["page_setup"]) == PAGE_SETUP_TOOLS
    assert set(groups["environment"]) == SETTINGS_TOOLS
    assert len(groups) == 23


async def test_system_about_reports_packs():
    from fastmcp import Client

    async with Client(server.mcp) as client:
        about = (await client.call_tool("system_about", {})).structured_content
    assert about["tool_packs"]["available"] == ALL_PACKS

Append to tests/test_capability_contract.py (after the last test; add the three contract imports next to the existing from backends.base import ... line):

from backends.contracts.environment import EnvironmentContract
from backends.contracts.pagesetup import PageSetupContract
from backends.contracts.styles import StylesContract

# ── track E: composition and the six agreed keys ────────────────────────────

#: spec §8.1 — the flag each engine must report. A key both engines support is
#: still listed so `system_capabilities` names it.
TRACK_E_KEYS = {
    "mleaderstyle": {"ezdxf": True, "com": False},
    "dwgprops": {"ezdxf": False, "com": True},
    "dwt_write": {"ezdxf": False, "com": True},
    "live_application": {"ezdxf": False, "com": True},
    "preferences": {"ezdxf": False, "com": True},
    "interactive_prompt": {"ezdxf": False, "com": True},
}


def _feature_keys_full(backend) -> dict:
    return backend.capabilities().to_dict()["features"]


def test_track_e_contracts_are_composed_into_the_backend():
    for contract in (StylesContract, PageSetupContract, EnvironmentContract):
        assert issubclass(AutoCADBackend, contract), f"{contract.__name__} not in AutoCADBackend"
        assert issubclass(EzdxfBackend, contract) and issubclass(ComBackend, contract)


def test_track_e_capability_keys_carry_the_agreed_flags():
    ezdxf_features = _feature_keys_full(EzdxfBackend())
    com_features = _feature_keys_full(ComBackend())
    for key, flags in TRACK_E_KEYS.items():
        assert key in ezdxf_features, f"{key} undeclared on ezdxf"
        assert key in com_features, f"{key} undeclared on com"
        assert ezdxf_features[key]["supported"] is flags["ezdxf"], key
        assert com_features[key]["supported"] is flags["com"], key


def test_layer_states_are_declared_as_ours_not_autocads():
    """Spec §7.2 / §8.3: the states live in the file and travel with it, and do
    not appear in AutoCAD's Layer States Manager. Said in the map, not only in
    a docstring."""
    for backend in (EzdxfBackend(), ComBackend()):
        feature = _feature_keys_full(backend)["layer_states"]
        assert feature["supported"] is True
        assert feature["mode"] == "xrecord"
        assert "layer_states_manager" in (feature["reason"] or ""), backend.name

Append to TUNING_CASES in tests/data/discovery_golden.py — after group S's five style cases from Task 13 (the last of them is the dimstyle_list counting case), before the closing ) — and change the module docstring's first sentence, which S's Task 13 left at Seventy-one cases (56 tuning + 15 holdout), to Eighty-one cases (66 tuning + 15 holdout) split into a **tuning** set and a **holdout** set. (base 66 + S's 5 + these 10):

    # v1.6 track E — styles, page setup, templates, environment. Written
    # before the alias records were measured against them; a miss here is
    # fixed by adding vocabulary to the tool's record in discovery/aliases.py,
    # never by moving the expectation.
    GoldenCase("PAGESETUP", "page_setup_apply", "command"),
    GoldenCase("PUBLISH", "batch_plot", "command"),
    GoldenCase("MLEADERSTYLE", "mleaderstyle_create", "command"),
    GoldenCase("STYLE", "textstyle_create", "command"),
    GoldenCase("iso 25 dimension style", "dimstyle_create", "synonym"),
    GoldenCase("layer snapshot", "layer_state_save", "synonym"),
    GoldenCase("save this drawing as a template", "drawing_template_save", "paraphrase"),
    GoldenCase("switch to the other open drawing", "document_activate", "paraphrase"),
    GoldenCase("ask the operator to click a point on screen", "user_pick_point", "paraphrase"),
    GoldenCase(
        "what does the LTSCALE variable mean",
        "system_variable_describe",
        "counting",
        risk="read",
    ),

Why these ten and not DIMSTYLE / LAYERSTATE / OPTIONS / DWGPROPS: each of those AutoCAD commands opens a manager dialog that is equally a list, a create and a restore, and the golden file's own rule is that a case with two defensible answers scores the author, not the ranker. STYLE is the exception the rule allows — the server has no textstyle_modify, so the dialog's only creating action maps to exactly one tool. PUBLISH is batch plotting by definition; PAGESETUP applies a setup; MLEADERSTYLE has one writer.

  • Step 2: Run to verify they fail
uv run --frozen pytest tests/test_tool_packs.py tests/test_capability_contract.py tests/test_discovery_transform.py -q -x

Expected first failure: test_pack_registry_names_real_tools_and_only_them — assert ('core', 'pid') == ('core', 'pid', 'settings'). If the merge agent already added the pack, the first failure moves to test_the_three_track_e_sections_file_under_their_own_groups (KeyError: 'page_setup'): S Task 11 already put style → styles in the tag→label map, but no group owns the pagesetup / environment entries (P tags its tools layout, V uses drawing / layer / view / system).

  • Step 3: Implement

3.1 server.py packs and lean. Replace the TOOL_PACK_NAMES / PACK_TOOL_NAMES block:

# Tool packs: vertical domains a client can opt out of advertising. `core` is
# everything not claimed by another pack and is always on. ~300 tools would
# push the full catalog to ~80k idle tokens; a client that only drafts
# mechanically should not pay for the P&ID surface, and a client that never
# talks to a live seat should not pay for the environment surface. Styles and
# page setup (SECTION 18/19) are drafting essentials and stay in core.
TOOL_PACK_NAMES = ("core", "pid", "settings")
PACK_TOOL_NAMES: dict[str, frozenset[str]] = {
    "pid": frozenset(
        {
            "pid_symbol_list",
            "pid_symbol_insert",
            "pid_line_draw",
            "pid_tag_parse",
            "pid_graph",
            "pid_instrument_index",
            "pid_line_list",
            "pid_equipment_list",
            "pid_from_spec",
        }
    ),
    # SECTION 20 — the 22 environment tools (spec §7, §8.2).
    "settings": frozenset(
        {
            "document_list",
            "document_activate",
            "document_close",
            "layer_state_save",
            "layer_state_restore",
            "layer_state_list",
            "layer_state_delete",
            "view_named_save",
            "view_named_restore",
            "view_named_list",
            "ucs_list",
            "ucs_set",
            "ucs_restore",
            "system_launch",
            "system_preferences_get",
            "system_preferences_set",
            "drawing_properties_get",
            "drawing_properties_set",
            "system_variable_describe",
            "user_pick_point",
            "user_select",
            "system_prompt_message",
        }
    ),
}

In LEAN_TOOL_NAMES, group S's Task 13 already added dimstyle_set_current, textstyle_set_current and drawing_apply_standard directly after the three pid_* entries (50 → 53) — leave them where they are and do not repeat them (a repeated name in the frozenset literal is ruff B033, and bugbear is on in pyproject.toml). Add only the two page-setup essentials, directly after S's three:

        # Page setup (track E) — the two a lean client needs to set the
        # sheet and plot it. Core-pack tools, so TOOL_PACKS=core leaves
        # them on the lean surface; with S's three above, lean is 55.
        "page_setup_apply",
        "batch_plot",

3.2 server.py group tags. Group S's Task 11 already inserted "style", immediately before "layer", in _GROUP_TAG_PRIORITY and "style": "styles", before "layer": "layers", in _GROUP_TAG_LABELS. Move S's "style" entry up so it sits directly after "pid", and add "pagesetup" and "environment" after it (a style tool that also carries dimension, a page-setup tool that also carries layout/drawing, and an environment tool that also carries layer/view/system must file under its section). No tag may appear twice. The tuple then reads, in full:

_GROUP_TAG_PRIORITY = (
    "pid",
    "style",
    "pagesetup",
    "environment",
    "engineering",
    "premium",
    "corner",
    "batch",
    "template",
    "dimension",
    "transaction",
    "layout",
    "solid",
    "view",
    "validation",
    "analysis",
    "block",
    "layer",
    "linetype",
    "create",
    "modify",
    "query",
    "drawing",
    "system",
)

and in _GROUP_TAG_LABELS, move S's "style": "styles", up to directly after "pid": "pid", and add the two new labels after it (a repeated dict key is ruff F601), so the dict opens:

    "pid": "pid",
    "style": "styles",
    "pagesetup": "page_setup",
    "environment": "environment",
    "engineering": "engineering",

Then make every SECTION 18 tool carry "style", every SECTION 19 tool "pagesetup", every SECTION 20 tool "environment" in its tags={...}. Probe rather than read 38 decorators by hand:

uv run --frozen python -c "
import asyncio, server
groups = asyncio.run(server._tool_groups())
from tests.test_tool_packs import STYLE_TOOLS, PAGE_SETUP_TOOLS, SETTINGS_TOOLS
for label, want in (('styles', STYLE_TOOLS), ('page_setup', PAGE_SETUP_TOOLS), ('environment', SETTINGS_TOOLS)):
    got = set(groups.get(label, []))
    print(label, 'missing', sorted(want - got), 'extra', sorted(got - want))
"

Every name printed under missing gets the section tag added to its tags={...} set (keep the group's other tags — tests/test_tool_registry.py::test_registered_tags_are_exactly_the_ones_written_in_source only requires that the live tags equal the source literal).

3.3 SECTION headers. tests/test_release_consistency.py::test_section_header_counts_match_decorators counts @mcp.tool decorators between headers. The merged file must read:

# ── SECTION 18: Styles (10 tools) ────────────────────────────────────────────
# ── SECTION 19: Page setup & templates (6 tools) ─────────────────────────────
# ── SECTION 20: Environment (22 tools) ───────────────────────────────────────

Group C's stub # ── SECTION 20: Environment (3 tools) ── and group V's (19 tools) SECTION 20 header both survive a textual merge; delete C's header line and move all three of C's registration blocks — system_variable_describe, drawing_properties_get, drawing_properties_set (each @cad_tool + @mcp.tool + function, whole) — under V's header, which becomes (22 tools). Should system_variable_describe have ended up under SECTION 11 instead, move its block the same way and correct SECTION 11 back to (8 tools). Run the gate after: uv run --frozen pytest tests/test_release_consistency.py::test_section_header_counts_match_decorators -q.

3.4 Composition. backends/base.py must import and list all three:

from backends.contracts import (
    AnalysisContract,
    BlockContract,
    CornerOpsContract,
    DimensionContract,
    DrawingContract,
    EntityCreationContract,
    EntityModificationContract,
    EntityQueryContract,
    EnvironmentContract,
    GdtContract,
    IdentityContract,
    LayerContract,
    LayoutContract,
    LinetypeContract,
    PageSetupContract,
    PremiumContract,
    SettingsContract,
    SolidContract,
    StylesContract,
    SystemContract,
    TransactionContract,
    ViewContract,
)

and the class bases end with:

    SettingsContract,
    StylesContract,
    PageSetupContract,
    EnvironmentContract,
):

backends/contracts/__init__.py gains the three imports (from backends.contracts.environment import EnvironmentContract, from backends.contracts.pagesetup import PageSetupContract, from backends.contracts.styles import StylesContract) and the three names in __all__, alphabetically. Each group's first task was told to add its own contract "if absent"; a duplicate base raises TypeError: duplicate base class at import, so this step is: read the file, keep exactly one of each.

3.5 Pack text. config.py comment: # list ("core,pid,settings") advertises only those. core cannot be disabled. .env.example: # Tool packs: all (default) | comma list of core,pid,settings — advertise only the (second line unchanged). README Configuration table row: | `TOOL_PACKS` | `all` | Vertical packs to advertise: `core,pid,settings` (`core` always on) |.

3.6 layer_states feature entry. In both capability maps the entry must be exactly:

                "layer_states": FeatureCapability(
                    True,
                    "xrecord",
                    reason="portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager",
                ),

Group V's Task 22 declares exactly this entry in both maps (spec §7.2 asks for the honesty note in system_capabilities; the skeleton's Capability-keys paragraph pins the spelling, and V's tests do not assert on the entry), so this step is read-and-confirm: the README and CLAUDE.md (Task 26) quote this reason string, and one spelling is what makes the three places agree. Only if a merge left a different spelling behind is anything edited here.

3.7 Registry snapshot. In tests/test_tool_registry.py::test_tool_group_sizes_are_unchanged replace the dict and the sum:

    sizes = {label: len(names) for label, names in (await server._tool_groups()).items()}
    assert sizes == {
        "analysis": 12,
        "batch": 3,
        "blocks": 9,
        "corner_ops": 4,
        "dimensions": 5,
        "drawing": 11,
        "engineering": 10,
        "entity_creation": 18,
        "entity_modification": 16,
        "entity_query": 9,
        "environment": 22,
        "layers": 14,
        "layouts": 12,
        "page_setup": 6,
        "pid": 9,
        "premium": 12,
        "solids": 5,
        "styles": 10,
        "system": 7,
        "templates": 2,
        "transactions": 3,
        "validation": 1,
        "view": 4,
    }
    assert sum(sizes.values()) == 204

and append to its docstring, before "Every other number here has been unchanged":

    Three groups appeared with v1.6's track E — `styles` (10, SECTION 18),
    `page_setup` (6, SECTION 19) and `environment` (22, SECTION 20) — each
    tagged with its section name, ranked directly after `pid` in
    `_GROUP_TAG_PRIORITY` so a style tool that also carries `dimension` or an
    environment tool that also carries `layer` files under its section; the
    `environment` group is exactly `PACK_TOOL_NAMES["settings"]`.

Change the docstring's first line to """Frozen snapshot of the surface (204 tools, 23 groups).

3.8 Count files.

uv run --frozen python scripts/generate_tool_inventory.py
uv run --frozen pytest --collect-only -q | tail -1

README release-snapshot block becomes (<collected> is the number the second command prints — the README figure is gated against live collection by scripts/check_doc_numbers.py, so it must be re-taken here and again in Tasks 25 and 26 after each test addition):

> **v1.5 release snapshot:** 204 tools · 8 resources · 5 prompt templates · <collected> collected tests.
> 204 is the **registered** count; a default install advertises 199 over `tools/list`,
> because `ENABLE_3D` is unset. `system_about` is the runtime authority.

README <sub> line under the What-you-get table: 166 tools in 20 groups → 204 tools in 23 groups, and <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools from a client that never draws a P&ID → <code>TOOL_PACKS=core</code> hides the nine <code>pid_*</code> tools and the 22 environment tools (<code>TOOL_PACKS=core,settings</code> keeps the latter) from a client that needs neither. The rest of the README is Task 26.

  • Step 4: Golden-set routing probe

The alias records were written by four groups that never saw these ten queries. Measure before running the test:

uv run --frozen python -c "
import asyncio, server
from discovery.transform import CadSearchTransform
from tests.data.discovery_golden import TUNING_CASES
tools = list(asyncio.run(server.mcp._list_tools()))
t = CadSearchTransform()
for case in TUNING_CASES[-10:]:
    hits = [h.name for h in t.rank(tools, case.query, limit=5).hits]
    print(('ok ' if case.expect in hits[:3] else 'MISS'), case.query, '->', hits[:3])
"

For every MISS, add the query's vocabulary to that tool's record in discovery/aliases.py — the routing this set relies on is: PAGESETUP in page_setup_apply.acad; PUBLISH in batch_plot.acad; MLEADERSTYLE in mleaderstyle_create.acad; STYLE in textstyle_create.acad; synonym "iso 25 dimension style" on dimstyle_create; "layer snapshot" on layer_state_save; "save as a template" on drawing_template_save; "switch to another open drawing" on document_activate; "click a point on screen" on user_pick_point; "what does a system variable mean" on system_variable_describe. An AutoCAD command that a group put on a different tool (e.g. PUBLISH on drawing_export_pdf) moves, it is not duplicated — tests/test_discovery_aliases.py::test_each_acad_command_routes_to_a_single_tool refuses a command on two tools unless it is in SHARED_ACAD_COMMANDS, and none of these four belongs there. Never edit a GoldenCase.

  • Step 5: Run the gates
uv run --frozen pytest tests/test_tool_packs.py tests/test_capability_contract.py tests/test_discovery_transform.py tests/test_discovery_aliases.py tests/test_tool_registry.py tests/test_tool_profiles.py tests/test_token_suite.py tests/test_tool_inventory.py tests/test_release_consistency.py -q
uv run --frozen python scripts/generate_tool_inventory.py --check
uv run --frozen ruff check . && uv run --frozen ruff format --check .

Expected: all green; ok: docs/tool-inventory.json matches the live surface. test_release_consistency.py::test_dockerfile_copies_every_wheel_include fails only if group P added templates to pyproject.toml's only-include (it must, for drawing_new(template="iso_a3_mech") to work from an installed wheel) without the matching Dockerfile line — then add COPY templates/ ./templates/ after the COPY discovery/ ./discovery/ line. The check_doc_numbers gate is green here because the correctness suite is still 29 and the published A/B report still says 29; Task 25 moves both together.

  • Step 6: Commit
git add server.py backends/base.py backends/contracts/__init__.py backends/ezdxf_backend.py backends/com_backend.py config.py .env.example README.md Dockerfile discovery/aliases.py docs/tool-inventory.json tests/test_tool_packs.py tests/test_capability_contract.py tests/test_tool_registry.py tests/data/discovery_golden.py
git commit -m "feat(server): settings pack — 22 environment tools behind TOOL_PACKS, lean grows to 55, three groups, golden set +10"

(Only stage the files that actually changed; Dockerfile and the two backend files are listed for the conditional edits above.)


Task 25: Live COM smoke, .dwt twins, benchmark page_setup_truth, three correctness checks

Group: Final (main).

Files:

  • Create: scripts/smoke_settings_com.py
  • Modify: templates/*.dxf (the five bundled templates rebuilt on the merged tree and recommitted — Step 5), .gitattributes (*.dwt / *.dwg binary), templates/*.dwt (five built artefacts, committed once), templates/README.md (provenance line), CHANGELOG.md (the smoke record, under Unreleased), benchmarks/tasks_v4.py (NEW_TASKS_V4 + page_setup_truth), benchmarks/adapters/autocad_mcp_pro.py (_task_page_setup_truth), benchmarks/correctness_suite.py (+3 checks, group "Settings"), benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json (regenerated), tests/test_benchmark_v4.py, README.md / benchmarks/README.md / CLAUDE.md (the numbers scripts/check_doc_numbers.py gates: 32 checks, 17 tasks, the A/B rows)
  • Test: tests/test_benchmark_v4.py, tests/test_release_consistency.py::test_doc_numbers_match_the_tree

Interfaces:

  • Produces: benchmarks.tasks_v4.NEW_TASKS_V4 == (pid_roundtrip, page_setup_truth), len(TASKS_V4) == 17; AutoCADMCPProAdapter._task_page_setup_truth(); correctness_suite.CHECKS += settings_dimstyle_iso25_values, settings_layer_state_roundtrip, settings_pdf_mediabox_a3 (group "Settings"), len(CHECKS) == 32; scripts/smoke_settings_com.py exit codes 0 / 1 / 2 and flags --build-dwt, --interactive, --keep.
  • Consumes (built by groups S/P/V/C, signatures from the skeleton): engineering.standards.dimstyles.resolve_dimstyle(preset, overrides), engineering.standards.mleaderstyles.resolve_mleaderstyle(preset, overrides), engineering.standards.papers.resolve_page_setup(paper, ...), engineering.standards.plot.batch_plot(backend, layouts, output_dir, file_pattern) -> dict (the per-sheet rows are under "sheets" — P Task 18's key, pinned by its tests and tool docstring — each {layout, ok, path, bytes, mediabox_mm, paper, error?}; nothing in this task reads a "rows" key), engineering.standards.templates.TEMPLATE_CATALOG, TEMPLATES_DIR, resolve_template(name_or_path, engine); backend methods dimstyle_create/list, textstyle_create, mleaderstyle_list/create, page_setup_apply/list, plot_style_list, drawing_template_save, document_list/activate, layer_state_save/restore/list/delete, view_named_save/list/restore, ucs_set/list/restore, system_launch, preferences_get/set, drawing_properties_get/set, user_pick_point, system_prompt_message.

Measured before writing (state of the machine, 2026-09-16): the registered AutoCAD 2026 type library (AutoCAD/ObjectDBX Common 25.0) has no acTemplateDwg constant — the template save constants are ac2018_Template = 66, ac2013_Template = 62, acNative = 64; group P's COM drawing_template_save must use 66 (or acNative+.dwt, which AutoCAD also honours), and the smoke verifies the written file is a DWG container rather than trusting the constant. matplotlib's PDF writer emits one uncompressed /MediaBox [0 0 1190.5511811024 841.8897637795] for a 420×297 mm figure, which read_mediabox returns as 420.000 × 297.000 mm — the ±0.5 mm tolerance of spec §9 is generous by four orders of magnitude on the headless side and exists for AutoCAD's own PDF driver. A fresh headless document holds dimstyles ['Standard'] only (its $DIMSTYLE header says ISO-25 while no such style exists — an ezdxf quirk), so dimstyle_create("ISO-25", ...) has no name clash in the correctness check; dimdsep is stored as the int code (44 for ,); the document has Layout1.

  • Step 1: Write the failing test

Replace tests/test_benchmark_v4.py in full:

# tests/test_benchmark_v4.py
"""The v4 task matrix: v3 plus what 1.6 added.

Track A added ``pid_roundtrip``; track E adds ``page_setup_truth`` — apply an
ISO A3 landscape page setup, plot through ``batch_plot`` and read the sheet
size back from the PDF's own ``/MediaBox``. The published competitor reports
were never asked either question and the chart shows them ``not_run`` — the
same rule v3 set for its five.
"""

from __future__ import annotations

import pytest

from benchmarks.tasks_v3 import TASKS_V3
from benchmarks.tasks_v4 import DEFAULT_MATRIX, MATRICES, NEW_TASKS_V4, TASKS_V4, task_by_id


def test_v4_extends_v3_without_touching_it():
    assert TASKS_V4[: len(TASKS_V3)] == TASKS_V3
    assert [t.task_id for t in NEW_TASKS_V4] == ["pid_roundtrip", "page_setup_truth"]
    assert len(TASKS_V4) == 17
    assert MATRICES["v3"] == TASKS_V3 and MATRICES["v4"] == TASKS_V4 and DEFAULT_MATRIX == "v4"
    assert task_by_id("pid_roundtrip").category == "pid"
    assert task_by_id("page_setup_truth").category == "pagesetup"


def test_the_adapter_implements_every_v4_task():
    from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter

    for task in TASKS_V4:
        assert hasattr(AutoCADMCPProAdapter, f"_task_{task.task_id}"), task.task_id


def test_correctness_suite_has_the_pid_checks():
    from benchmarks.correctness_suite import CHECKS

    for name in ("pid_block_define_attdef_roundtrip", "pid_tag_parse_fic", "pid_graph_edge_count"):
        assert CHECKS[name][1] == "P&ID"


def test_correctness_suite_has_the_settings_checks():
    from benchmarks.correctness_suite import CHECKS

    for name in (
        "settings_dimstyle_iso25_values",
        "settings_layer_state_roundtrip",
        "settings_pdf_mediabox_a3",
    ):
        assert CHECKS[name][1] == "Settings"
    assert len(CHECKS) == 32


@pytest.mark.asyncio  # asyncio_mode is strict; the four tests above are sync
async def test_page_setup_truth_passes_headlessly(tmp_path):
    """The benchmark task itself, run once here so a regression in page setup
    or the PDF read-back fails the suite and not only the matrix."""
    from benchmarks.adapters.autocad_mcp_pro import AutoCADMCPProAdapter

    adapter = AutoCADMCPProAdapter(backend="ezdxf")
    await adapter.setup(tmp_path)
    try:
        await adapter._reset()
        passed, metrics, _artifacts = await adapter._task_page_setup_truth()
    finally:
        await adapter.cleanup()
    assert passed, metrics
    assert abs(metrics["mediabox_mm"][0] - 420.0) < 0.5
    assert abs(metrics["mediabox_mm"][1] - 297.0) < 0.5

(pyproject.toml sets asyncio_mode = "strict", so the one async test carries its own marker and the four sync tests carry none — the per-test pattern tests/test_capability_contract.py already uses.)

  • Step 2: Run to verify it fails
uv run --frozen pytest tests/test_benchmark_v4.py -q

Expected: test_v4_extends_v3_without_touching_it fails with assert ['pid_roundtrip'] == ['pid_roundtrip', 'page_setup_truth']; test_correctness_suite_has_the_settings_checks with KeyError: 'settings_dimstyle_iso25_values'; test_page_setup_truth_passes_headlessly with AttributeError: ... has no attribute '_task_page_setup_truth'.

  • Step 3: Matrix, adapter task, checks

benchmarks/tasks_v4.py — replace the docstring and NEW_TASKS_V4:

"""The v4 task matrix: v3 plus what 1.6 added.

``pid_roundtrip`` (track A) draws a five-item P&ID through the same code the
tools call and reads it back: the edge count, the absence of dangling ends
and the instrument index are each derived independently of the drawing code.

``page_setup_truth`` (track E) applies an ISO A3 landscape page setup to a
layout, plots it through ``batch_plot`` and reads the sheet size back from
the PDF's own ``/MediaBox`` — the setup is verified by the file, not by the
setter's return value.

The published competitor reports were never asked either question; the chart
shows ``not_run`` for them, not zero.
"""

from __future__ import annotations

from benchmarks.tasks_v2 import TASKS_V2, TaskSpec
from benchmarks.tasks_v3 import TASKS_V3

#: Added in v1.6.0. Each can fail on its own.
NEW_TASKS_V4: tuple[TaskSpec, ...] = (
    TaskSpec(
        "pid_roundtrip",
        "pid",
        "Draw a P&ID from a spec and read back a matching graph, index and line list",
        1.0,
    ),
    TaskSpec(
        "page_setup_truth",
        "pagesetup",
        "Apply an ISO A3 page setup, plot it, and read the sheet size back from the PDF",
        1.0,
    ),
)

(TASKS_V4, MATRICES, DEFAULT_MATRIX, task_by_id unchanged.)

Adapter method, in benchmarks/adapters/autocad_mcp_pro.py directly after _task_pid_roundtrip:

    async def _task_page_setup_truth(self):
        """ISO A3 landscape on Layout1, plotted, and the sheet read back from the PDF.

        The number that matters is parsed from the file's ``/MediaBox`` — the
        setter's ``applied`` block is what we asked for, the page-setup list is
        what the LAYOUT now says, and the PDF is what a printer would get. All
        three have to agree with ISO 216's 420 × 297.
        """
        from engineering.standards.papers import resolve_page_setup
        from engineering.standards.plot import batch_plot

        setup = resolve_page_setup(
            "ISO_A3",
            orientation="landscape",
            plot_style="monochrome.ctb",
            scale="1:1",
        )
        applied = await self.backend.page_setup_apply("Layout1", setup)
        listed = (await self.backend.page_setup_list("Layout1"))[0]
        out_dir = self.artifact_dir / "page_setup_truth"
        out_dir.mkdir(parents=True, exist_ok=True)
        report = await batch_plot(self.backend, ["Layout1"], str(out_dir), "{drawing}-{layout}.pdf")
        row = report["sheets"][0]
        width_mm, height_mm = (float(v) for v in row["mediabox_mm"])
        size_listed = tuple(float(v) for v in listed["size_mm"])
        passed = (
            applied["ok"] is True
            and applied["plot_style_known"] is True
            and listed["paper"] == "ISO_A3"
            and listed["orientation"] == "landscape"
            and size_listed == (420.0, 297.0)
            and abs(width_mm - 420.0) < 0.5
            and abs(height_mm - 297.0) < 0.5
            and row["bytes"] > 0
        )
        return (
            passed,
            {
                "paper": listed["paper"],
                "orientation": listed["orientation"],
                "size_mm_listed": list(size_listed),
                "mediabox_mm": [width_mm, height_mm],
                "pdf_bytes": row["bytes"],
                "plot_style_known": applied["plot_style_known"],
            },
            [str(row["path"])],
        )

Correctness checks — append to benchmarks/correctness_suite.py after pid_graph_edge_count and before CHECKS:

# ── Settings and environment (v1.6, track E) ─────────────────────────────────


async def settings_dimstyle_iso25_values():
    """The ISO-25 preset reaches the DIMSTYLE table with ISO 129-1's numbers.

    Read back through ezdxf directly, not through `dimstyle_list`, so the
    check cannot be satisfied by a list that echoes its own input.
    """
    from engineering.standards.dimstyles import resolve_dimstyle

    b = await _b()
    await b.dimstyle_create("ISO-25", resolve_dimstyle("iso-25", None), set_current=True)
    style = b._doc.dimstyles.get("ISO-25")
    listed = {row["name"]: row for row in await b.dimstyle_list()}
    return (
        abs(float(style.dxf.dimtxt) - 2.5) < 1e-9
        and abs(float(style.dxf.dimasz) - 2.5) < 1e-9
        and abs(float(style.dxf.dimexe) - 1.25) < 1e-9
        and abs(float(style.dxf.dimexo) - 0.625) < 1e-9
        and abs(float(style.dxf.dimgap) - 0.625) < 1e-9
        and int(style.dxf.dimtad) == 1
        and int(style.dxf.dimdsep) == ord(",")
        and str(style.dxf.dimtxsty) == "ISOCP"
        and b._doc.header["$DIMSTYLE"] == "ISO-25"
        and listed["ISO-25"]["current"] is True
        and listed["ISO-25"]["values"]["DIMDSEP"] == ","
    )


async def settings_layer_state_roundtrip():
    """Save → change → restore puts the table back, and the state survives a
    save/reopen because it is an XRECORD in the file, not server memory."""
    b = await _b()
    await b.layer_create("LS_A", color=1)
    await b.layer_create("LS_B", color=2)
    saved = await b.layer_state_save("bench", description="A/B round trip")
    await b.layer_modify("LS_A", color=5)
    await b.layer_freeze("LS_B")
    restored = await b.layer_state_restore("bench")
    layers = {layer.name: layer for layer in await b.layer_list()}
    path = os.path.join(tempfile.mkdtemp(), "ls.dxf")
    await b.drawing_save_as(path, "dxf")
    await b.drawing_open(path)
    names_after_reopen = {row["name"] for row in await b.layer_state_list()}
    return (
        saved["ok"] is True
        and restored["missing_layers"] == []
        and restored["new_layers"] == []
        and layers["LS_A"].color == 1
        and layers["LS_B"].is_frozen is False
        and "bench" in names_after_reopen
    )


async def settings_pdf_mediabox_a3():
    """Page setup evidence is the PDF's own /MediaBox (spec §8.3)."""
    from engineering.standards.papers import resolve_page_setup
    from engineering.standards.plot import batch_plot

    b = await _b()
    await b.page_setup_apply("Layout1", resolve_page_setup("ISO_A3"))
    out_dir = tempfile.mkdtemp()
    report = await batch_plot(b, ["Layout1"], out_dir, "{drawing}-{layout}.pdf")
    width_mm, height_mm = report["sheets"][0]["mediabox_mm"]
    return abs(width_mm - 420.0) < 0.5 and abs(height_mm - 297.0) < 0.5

and the three CHECKS entries after the P&ID rows:

    "settings_dimstyle_iso25_values": (settings_dimstyle_iso25_values, "Settings"),
    "settings_layer_state_roundtrip": (settings_layer_state_roundtrip, "Settings"),
    "settings_pdf_mediabox_a3": (settings_pdf_mediabox_a3, "Settings"),

Run each once in isolation, the way the A/B runner will:

uv run --frozen python benchmarks/correctness_suite.py settings_dimstyle_iso25_values
uv run --frozen python benchmarks/correctness_suite.py settings_layer_state_roundtrip
uv run --frozen python benchmarks/correctness_suite.py settings_pdf_mediabox_a3
uv run --frozen python benchmarks/run_competitors.py --matrix v4 --server autocad-mcp-pro --task page_setup_truth

Expected: PASS three times; the JSON report's page_setup_truth row has "status": "pass" and metrics.mediabox_mm of [420.0, 297.0] (to within 1e-9 headlessly).

  • Step 4: The live smoke script
# scripts/smoke_settings_com.py
"""Exercise the settings track once against the live AutoCAD on this machine.

Creates a NEW document (never touches the operator's open drawing) and runs
every SECTION 18-20 path that needs a live seat, through the same backend
methods the tools call:

    system_launch (attach)                  -> {attached, launched, version}
    dimstyle_create ISO-25 / textstyle_create ISOCP / mleaderstyle_list
        + mleaderstyle_create refused with capability "mleaderstyle"
    page_setup_apply ISO_A3 -> batch_plot -> the PDF's own /MediaBox (420 x 297)
    plot_style_list (installed .ctb files)
    drawing_new(template=iso_a3_mech .dwt) -> mech layers, ISO-25 current, ISO_A3
    document_list / document_activate
    layer_state_save -> change -> layer_state_restore -> layer_state_delete
    view_named_save / view_named_list / view_named_restore
    ucs_set / ucs_list / ucs_restore("world") + a non-orthogonal refusal
    preferences_get + ONE reversible preferences_set (Display.CursorSize)
        + a read-only key refused
    drawing_properties_set / get (SummaryInfo + a custom property)
    system_prompt_message
    --interactive: user_pick_point (needs the operator at the keyboard)
    --build-dwt:   open each templates/<name>.dxf and save the .dwt twin
                   through drawing_template_save, before anything else

Prints one JSON report. Exit codes:

    0  every assertion held (``failures`` is empty)
    1  the seat answered but at least one assertion failed (listed)
    2  no live CAD application reachable (pywin32 missing, or the ProgID
       cannot be created) -- the COM paths then remain fake-tested only

Reachability is read from ``system_status()`` because ``connect()`` is lazy.
The ``Dispatch`` fallback *launches* the ProgID when nothing is running and a
cold start can exceed ``COM_CALL_TIMEOUT`` (60 s): open AutoCAD first.

    AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_settings_com.py
    uv run --frozen python scripts/smoke_settings_com.py --build-dwt
    uv run --frozen python scripts/smoke_settings_com.py --interactive --keep
"""

from __future__ import annotations

import argparse
import asyncio
import json
import sys
import tempfile
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))

#: The five bundled templates (spec §6). Pinned here rather than read from
#: the catalogue so the smoke also notices a catalogue that lost a row.
TEMPLATE_NAMES = ("iso_a3_mech", "iso_a1_arch", "iso_a3_pid", "ansi_b_mech", "ansi_d_arch")

#: Every DWG/DWT since R13 opens with "AC10xx" (AC1032 for the 2018 format).
DWG_MAGIC = b"AC10"

#: What the ISO A3 landscape sheet must read back as, from the PDF itself.
A3_LANDSCAPE_MM = (420.0, 297.0)
MEDIABOX_TOLERANCE_MM = 0.5


def _check(failures: list[str], label: str, ok: bool) -> None:
    if not ok:
        failures.append(label)


async def build_dwt(backend) -> list[dict]:
    """Open each bundled DXF over COM and save its .dwt twin next to it."""
    from engineering.standards.templates import TEMPLATE_CATALOG, TEMPLATES_DIR

    rows: list[dict] = []
    if len(TEMPLATE_CATALOG) != len(TEMPLATE_NAMES):
        raise SystemExit(
            f"TEMPLATE_CATALOG has {len(TEMPLATE_CATALOG)} rows, the smoke pins "
            f"{len(TEMPLATE_NAMES)}: update TEMPLATE_NAMES deliberately"
        )
    for name in TEMPLATE_NAMES:
        dxf = Path(TEMPLATES_DIR).resolve() / f"{name}.dxf"
        dwt = Path(TEMPLATES_DIR).resolve() / f"{name}.dwt"
        if not dxf.exists():
            raise SystemExit(f"{dxf} is missing: run scripts/build_templates.py first")
        await backend.drawing_open(str(dxf))
        saved = await backend.drawing_template_save(
            str(dwt), name=name, description=f"AutoCAD MCP Pro bundled template {name}"
        )
        await backend.drawing_close(save=False)
        head = dwt.read_bytes()[:6]
        rows.append(
            {
                "name": name,
                "path": str(dwt.relative_to(ROOT)),
                "bytes": dwt.stat().st_size,
                "magic": head.decode("ascii", "replace"),
                "is_dwg_container": head.startswith(DWG_MAGIC),
                "format": saved["format"],
            }
        )
    return rows


async def main(args: argparse.Namespace) -> int:
    try:
        from backends.com_backend import ComBackend
    except ImportError as exc:
        print(f"pywin32 not available: {exc}")
        return 2
    from backends.base import UnsupportedCapabilityError

    backend = ComBackend()
    try:
        await backend.connect()
    except Exception as exc:
        print(f"no live AutoCAD reachable: {exc}")
        return 2

    failures: list[str] = []
    report: dict = {}
    try:
        status = await backend.system_status()
        if not status.get("connected"):
            print(f"no live AutoCAD reachable: {status.get('error', status)}")
            return 2
        report["autocad"] = status.get("autocad_version") or status.get("cad_progid")
        report["system_launch"] = await backend.system_launch(visible=True)
        _check(failures, "system_launch attached", report["system_launch"]["attached"] is True)

        if args.build_dwt:
            report["dwt_built"] = await build_dwt(backend)
            _check(
                failures,
                "every .dwt is a DWG container",
                all(row["is_dwg_container"] for row in report["dwt_built"]),
            )

        await backend.drawing_new()
        smoke_document = (await backend.system_status()).get("active_document")
        report["document"] = smoke_document

        # ── styles ────────────────────────────────────────────────────────
        from engineering.standards.dimstyles import resolve_dimstyle
        from engineering.standards.mleaderstyles import resolve_mleaderstyle

        created = await backend.dimstyle_create(
            "ISO-25-SMOKE", resolve_dimstyle("iso-25", None), set_current=True
        )
        dimstyles = {row["name"]: row for row in await backend.dimstyle_list()}
        smoke_style = dimstyles["ISO-25-SMOKE"]
        _check(failures, "dimstyle DIMTXT 2.5", abs(smoke_style["values"]["DIMTXT"] - 2.5) < 1e-6)
        _check(failures, "dimstyle DIMDSEP ','", smoke_style["values"]["DIMDSEP"] == ",")
        _check(failures, "dimstyle current", smoke_style["current"] is True)
        textstyle = await backend.textstyle_create("ISOCP-SMOKE", "isocp.shx")
        _check(failures, "isocp.shx resolved on the seat", textstyle["font_resolved"] is True)
        mleader_styles = await backend.mleaderstyle_list()
        try:
            await backend.mleaderstyle_create("SMOKE", resolve_mleaderstyle("iso", None))
            _check(failures, "mleaderstyle_create refused on COM", False)
        except UnsupportedCapabilityError as exc:
            _check(failures, "mleaderstyle refusal carries its key", exc.capability == "mleaderstyle")
        report["styles"] = {
            "dimstyle": created,
            "textstyle": textstyle,
            "mleaderstyles_on_seat": [row["name"] for row in mleader_styles],
        }

        # ── page setup + the PDF's own mediabox ───────────────────────────
        from engineering.standards.papers import resolve_page_setup
        from engineering.standards.plot import batch_plot

        applied = await backend.page_setup_apply(
            "Layout1", resolve_page_setup("ISO_A3", orientation="landscape", scale="1:1")
        )
        listed = (await backend.page_setup_list("Layout1"))[0]
        _check(
            failures,
            "page_setup_list says 420 x 297",
            tuple(float(v) for v in listed["size_mm"]) == A3_LANDSCAPE_MM,
        )
        out_dir = Path(tempfile.mkdtemp(prefix="acadmcp_settings_smoke_"))
        plotted = await batch_plot(backend, ["Layout1"], str(out_dir), "{drawing}-{layout}.pdf")
        pdf_row = plotted["sheets"][0]
        width_mm, height_mm = (float(v) for v in pdf_row["mediabox_mm"])
        _check(
            failures,
            "PDF /MediaBox is 420 x 297 (+-0.5 mm)",
            abs(width_mm - A3_LANDSCAPE_MM[0]) < MEDIABOX_TOLERANCE_MM
            and abs(height_mm - A3_LANDSCAPE_MM[1]) < MEDIABOX_TOLERANCE_MM,
        )
        installed = sorted(
            row["name"] for row in await backend.plot_style_list() if row["source"] == "installed"
        )
        _check(failures, "monochrome.ctb installed on the seat", "monochrome.ctb" in installed)
        report["page_setup"] = {
            "applied": applied["applied"],
            "changed": applied["changed"],
            "pdf": pdf_row,
            "installed_plot_styles": installed,
        }

        # ── bundled template over COM ─────────────────────────────────────
        from engineering.standards.templates import resolve_template

        template_path, template_source = resolve_template("iso_a3_mech", "com")
        opened = await backend.drawing_new(template=template_path)
        template_layers = {layer.name for layer in await backend.layer_list()}
        current_dimstyles = [row["name"] for row in await backend.dimstyle_list() if row["current"]]
        template_papers = [row["paper"] for row in await backend.page_setup_list()]
        _check(
            failures,
            "template carries the mech layers",
            {"GEOMETRY", "DIM", "CENTER", "HIDDEN", "TITLEBLOCK"} <= template_layers,
        )
        _check(failures, "template has ISO-25 current", current_dimstyles == ["ISO-25"])
        _check(failures, "template has an ISO_A3 page setup", "ISO_A3" in template_papers)
        report["template"] = {
            "path": str(template_path),
            "source": template_source,
            "document": opened["name"],
            "dimstyle_current": current_dimstyles,
            "papers": template_papers,
        }
        await backend.drawing_close(save=False)
        activated = await backend.document_activate(smoke_document)
        _check(failures, "smoke document re-activated", activated["active"] == smoke_document)
        report["documents"] = await backend.document_list()

        # ── layer state round trip ────────────────────────────────────────
        await backend.layer_create("SMOKE_A", color=1)
        await backend.layer_create("SMOKE_B", color=2)
        saved = await backend.layer_state_save("SMOKE", description="settings smoke")
        await backend.layer_modify("SMOKE_A", color=5)
        await backend.layer_freeze("SMOKE_B")
        restored = await backend.layer_state_restore("SMOKE")
        after = {layer.name: layer for layer in await backend.layer_list()}
        _check(failures, "layer state restored the colour", after["SMOKE_A"].color == 1)
        _check(failures, "layer state thawed SMOKE_B", after["SMOKE_B"].is_frozen is False)
        _check(failures, "restore reports nothing missing", restored["missing_layers"] == [])
        states = await backend.layer_state_list()
        _check(failures, "layer state listed", any(row["name"] == "SMOKE" for row in states))
        deleted = await backend.layer_state_delete("SMOKE")
        report["layer_state"] = {"saved": saved, "restored": restored, "deleted": deleted}

        # ── named view ────────────────────────────────────────────────────
        view = await backend.view_named_save("SMOKE_VIEW", center=[100.0, 50.0], height=80.0)
        views = await backend.view_named_list()
        _check(failures, "named view listed", any(row["name"] == "SMOKE_VIEW" for row in views))
        report["view"] = {"saved": view, "restored": await backend.view_named_restore("SMOKE_VIEW")}

        # ── UCS ───────────────────────────────────────────────────────────
        ucs = await backend.ucs_set(
            "SMOKE_UCS", [10.0, 20.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0]
        )
        ucs_rows = await backend.ucs_list()
        _check(
            failures,
            "ucs current",
            any(row["name"] == "SMOKE_UCS" and row["current"] for row in ucs_rows),
        )
        try:
            await backend.ucs_set("SMOKE_BAD", [0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [1.0, 1.0, 0.0])
            _check(failures, "non-orthogonal UCS refused", False)
        except ValueError as exc:
            _check(failures, "non-orthogonal UCS refusal names the angle", "45" in str(exc))
        report["ucs"] = {"set": ucs, "world": await backend.ucs_restore("world")}

        # ── preferences: get, one reversible set, a read-only refusal ─────
        prefs = await backend.preferences_get()
        cursor = int(prefs["values"]["Display.CursorSize"])
        probe = cursor + 1 if cursor < 100 else cursor - 1
        bumped = await backend.preferences_set("Display.CursorSize", probe)
        reverted = await backend.preferences_set("Display.CursorSize", cursor)
        _check(failures, "preference set reports old", bumped["old"] == cursor)
        _check(failures, "preference set reports new", bumped["new"] == probe)
        _check(failures, "preference reverted", reverted["new"] == cursor)
        _check(failures, "read-only keys listed", "Files.SupportPath" in prefs["read_only"])
        try:
            await backend.preferences_set("Files.SupportPath", "C:/nope")
            _check(failures, "read-only preference refused", False)
        except ValueError:
            _check(failures, "read-only preference refused", True)
        report["preferences"] = {
            "cursor_size": cursor,
            "read_only": prefs["read_only"],
            "template_dwg_path": prefs["values"].get("Files.TemplateDwgPath"),
        }

        # ── SummaryInfo ───────────────────────────────────────────────────
        written = await backend.drawing_properties_set(
            summary={"title": "ACADMCP settings smoke", "author": "smoke_settings_com.py"},
            custom={"ACADMCP_SMOKE": "1"},
        )
        props = await backend.drawing_properties_get()
        _check(failures, "summary available on COM", props["summary_available"] is True)
        _check(failures, "title round trip", props["summary"]["title"] == "ACADMCP settings smoke")
        _check(failures, "custom property round trip", props["custom"].get("ACADMCP_SMOKE") == "1")
        report["properties"] = {"set": written, "get": props}

        # ── operator prompt line ──────────────────────────────────────────
        report["prompt_message"] = await backend.system_prompt_message(
            "ACADMCP settings smoke: done"
        )

        if args.interactive:
            picked = await backend.user_pick_point("ACADMCP smoke: pick any point (Esc cancels)")
            report["user_pick_point"] = picked
            _check(
                failures,
                "pick returns a point, cancelled or timed_out",
                bool(picked.get("cancelled"))
                or bool(picked.get("timed_out"))
                or ("x" in picked and "y" in picked),
            )

        report["failures"] = failures
        print(json.dumps(report, indent=2, default=str))
        if not args.keep:
            await backend.drawing_close(save=False)
        return 0 if not failures else 1
    finally:
        await backend.disconnect()


def _parser() -> argparse.ArgumentParser:
    parser = argparse.ArgumentParser(description=__doc__.split("\n\n")[0])
    parser.add_argument("--build-dwt", action="store_true", help="build templates/*.dwt first")
    parser.add_argument("--interactive", action="store_true", help="also run user_pick_point")
    parser.add_argument("--keep", action="store_true", help="leave the smoke document open")
    return parser


if __name__ == "__main__":
    raise SystemExit(asyncio.run(main(_parser().parse_args())))

Add to .gitattributes:

*.dwt binary
*.dwg binary
  • Step 5: Rebuild the five DXF templates on the merged tree

scripts/build_templates.py (group P, Task 19) builds each template through the server's own methods, and two of its inputs exist only after the merge: the ISO-25 / ANSI dimension and text styles now come from group S's dimstyle_create / textstyle_create (before S merged, the script wrote the same values as header DIM variables), and iso_a1_arch's annotation_scale: "1:50" is applied only once group C's facade key exists (before that the script recorded a pending note). The five committed DXFs therefore no longer match a fresh build — tests/test_templates.py::test_committed_templates_match_a_fresh_build and build_templates.py --check are red on the merged tree until the rebuild — and the .dwt twins of Step 6 must be built from the rebuilt DXFs, not the stale ones.

uv run --frozen python scripts/build_templates.py
uv run --frozen python scripts/build_templates.py --check
uv run --frozen pytest tests/test_templates.py -q

Expected: 5 templates reproduce byte-for-byte (volatile stamps masked); tests/test_templates.py green; git diff --stat templates/ lists the five DXFs (their DIMSTYLE / STYLE table entries and, for iso_a1_arch, the ACAD_SCALELIST / CANNOSCALE objects moved). Commit the DXFs on their own, before anything on the .dwt side:

git add templates/*.dxf
git commit -m "chore(templates): rebuild the five bundled DXFs on the merged tree (styles via dimstyle_create/textstyle_create, iso_a1_arch annotation scale 1:50)"
  • Step 6: Run the smoke on this machine (AutoCAD 2026 open)
AUTOCAD_MCP_BACKEND=com uv run --frozen python scripts/smoke_settings_com.py --build-dwt

Expected: exit 0, "failures": [], five dwt_built rows with "magic": "AC1032" and "is_dwg_container": true, page_setup.pdf.mediabox_mm within 0.5 mm of [420, 297], template.source == "bundled" (the .dwt was just built, so the resolver no longer falls back to bundled_dxf). Then git status shows five new templates/*.dwt.

If a COM call fails (a wrong ActiveX member, a variant shape, the save constant — see the type-library measurement above), fix the backend, re-run that group's fake-ActiveX tests, and re-run the smoke until it exits 0: that is what the run is for. If it exits 2, the CHANGELOG says the COM paths are fake-tested only and the .dwt twins are absent (the resolver's bundled_dxf fallback keeps drawing_new(template=) working on COM — AutoCAD opens a DXF template through Documents.Add — and tests/test_templates.py must not require the .dwt files to exist headlessly). Run once more with --interactive and pick a point when AutoCAD prompts; paste the user_pick_point block into the record too.

Append to templates/README.md:

The `.dwt` twins were built on 2026-09-16 on AutoCAD 2026 by
`scripts/smoke_settings_com.py --build-dwt` — each DXF opened over COM and
saved as a template (`AcSaveAsType` 66, `ac2018_Template`). Rebuild them the
same way after changing a DXF; `scripts/build_templates.py --check` gates the
DXF side only.
  • Step 7: Regenerate the published A/B report and the doc numbers
uv run --frozen python benchmarks/compare_versions.py v1.5.1 --json benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json
uv run --frozen python scripts/check_doc_numbers.py

Expected A/B: baseline v1.5.1: 26/32 (81.2%) current: 32/32 (100.0%) fixed: 6 regressed: 0 — the three P&ID checks and the three settings checks are miss → pass (v1.5.1 has none of the methods), the 26 it was released on still pass. check_doc_numbers.py then lists exactly the drifts to fix, all of them here:

  • README.md: 29 deterministic headless checks → 32; the A/B rows become
    | v1.5.1 *(baseline)* | 26 / 32 | 81.2 % | — | — |
    | **v1.6.0-dev** *(this branch)* | **32 / 32** | **100 %** | 6 | **0** |
    
    and the sentence under the table: The six fixed rows are new capability (miss → pass): the three P&ID checks of track A and, from track E, settings_dimstyle_iso25_values(the ISO-25 preset lands in the DIMSTYLE table with ISO 129-1's numbers),settings_layer_state_roundtrip(save → change → restore puts the layer table back, and the state survives save/reopen because it is an XRECORD in the file) andsettings_pdf_mediabox_a3(the plotted PDF's own/MediaBox reads 420 × 297). The 26 checks v1.5.1 was gated on all still pass. The task-matrix table gains the row | `page_setup_truth` | ISO A3 landscape applied to Layout1, plotted through `batch_plot`, and the sheet read back from the PDF's `/MediaBox`: 420 × 297 mm, not the setter's return value | and its intro sentence 1.6 adds a sixth that the P&ID reader can fail on its own → 1.6 adds two more — one the P&ID reader can fail on its own, one where the PDF file, not the setter, is the witness.
  • benchmarks/README.md: 29 checks, ezdxf backend → 32 checks, ezdxf backend; the two A/B rows to 26 / 32 | 81.2 % and 32 / 32 | 100 % | 6 | **0**; the paragraph under them names the six; **16/16 (100.0)** on the v4 matrix → **17/17 (100.0)**; the Matrix v4 heading — the P&ID round trip → — the P&ID round trip and the page-setup truth, its lead adds one task the reader can fail on its own → adds two tasks that can each fail on their own, and a table row | `page_setup_truth` | pagesetup | `page_setup_apply("Layout1", ISO_A3 landscape 1:1)` then `batch_plot`; the PDF's own `/MediaBox` must parse to 420 × 297 mm (±0.5), the page-setup list must say `ISO_A3` / `landscape` / `[420, 297]`, and the ctb must be a known one |.
  • CLAUDE.md: (16 tasks; → (17 tasks; ; correctness_suite.py (29 checks)→(32 checks); for the five v3 tasks or the v4 pid_roundtrip`` → for the five v3 tasks or the v4 pid_roundtrip / `page_setup_truth``.
  • README release-snapshot collected tests figure: re-take with uv run --frozen pytest --collect-only -q | tail -1 (this task added five tests).

scripts/check_doc_numbers.py itself needs no edit: AB_REPORT, the two row labels and every pattern still match the sentences above.

  • Step 8: CHANGELOG smoke record

In CHANGELOG.md, under ## [Unreleased], directly after the existing ### Live COM smoke block (Track A's), add a second block with the JSON the run printed, verbatim — report["autocad"], system_launch, dwt_built, styles, page_setup (the pdf row is the evidence), template, layer_state, view, ucs, preferences, properties, prompt_message, user_pick_point (from the --interactive run), failures: []:

### Live COM smoke — settings (track E)

`scripts/smoke_settings_com.py --build-dwt`, then `--interactive`, AutoCAD 2026
(`25.1s (LMS Tech)`), Windows 11, 2026-09-16, in a new document — styles →
page setup → `batch_plot` (the PDF's own `/MediaBox`) → the five `.dwt` twins
built and `iso_a3_mech.dwt` reopened → layer state round trip → named view →
UCS → preferences (one reversible set) → SummaryInfo → prompt line → one
operator pick, all through the COM engine, exit 0:

```json
<the printed report>
```

The headless engine reports the identical `mediabox_mm`, `layer_state.restored`
and `properties.get.custom` on the same steps; `summary_available` is `false`
headlessly and the five summary fields are `null` (capability `dwgprops`).

Anything the run refused or timed out on (timed_out, a failures entry that was fixed before the final green run) is written down in one sentence each, the way Track A's record names its gen_py cache fault.

  • Step 9: Run and commit
uv run --frozen pytest tests/test_benchmark_v4.py tests/test_benchmark_v3.py tests/test_benchmark_v2.py tests/test_release_consistency.py tests/test_templates.py -q
uv run --frozen ruff check . && uv run --frozen ruff format --check .

Expected: green; check_doc_numbers reports ok: 16 document numbers match the tree.

git add scripts/smoke_settings_com.py .gitattributes templates/*.dwt templates/README.md CHANGELOG.md
git commit -m "feat(settings): live COM smoke on AutoCAD 2026 builds the five .dwt twins and records the run"
git add benchmarks/tasks_v4.py benchmarks/adapters/autocad_mcp_pro.py benchmarks/correctness_suite.py benchmarks/results/published/ab-v1.5.1-vs-v1.6.0-dev.json tests/test_benchmark_v4.py README.md benchmarks/README.md CLAUDE.md
git commit -m "bench: matrix v4 adds page_setup_truth; correctness suite grows to 32 with three settings checks (A/B vs v1.5.1: 26 -> 32 / 32)"

Task 26: Documentation, spec status, full gate

Group: Final (main).

Files:

  • Modify: CLAUDE.md (sections 18–20 in the tool list, the profiles/packs paragraphs, Key Conventions: layer-state honesty, page-setup evidence, UCS rule, TOOL_PACKS line), README.md (What-you-get rows, <sub> line, token table, Configuration row, "What this release is bad at"), CHANGELOG.md (Unreleased: Added / Fixed / Changed for track E), docs/superpowers/specs/2026-09-16-v1.6-settings-design.md (status line)
  • Test: the full gate (Step 4)

Interfaces: none produced; every number quoted is derived in Task 24/25 (204 tools, 199 advertised, 23 groups, lean 55 / 52 with TOOL_PACKS=core, 32 checks, 17 tasks) or measured in Step 2 (token rows).

  • Step 1: CLAUDE.md

Add after item 17 in the tool-sections list:

18. Styles (10 tools, pack `core`): `dimstyle_list/create/modify/set_current` (authored ISO-25 / ANSI presets from `engineering/standards/dimstyles.py`; `dimstyle_modify` accepts only `DIM_VARIABLE_WHITELIST` names and refuses out-of-range values by name before writing; `changed` lists only variables that moved, and `rerender_required: true` says a headless DIMENSION keeps its rendered block until redrawn), `textstyle_list/create/set_current` (`font_resolved: false` is reported, never refused — DXF stores only the name), `mleaderstyle_list/create` (ezdxf only for `create`: capability `mleaderstyle`, because ActiveX exposes no MLeaderStyle collection; `leader_create_mleader`'s per-leader parameters are the COM route), and `drawing_apply_standard("iso"|"ansi")` — dimstyle + text style + units + the `mech` layer set in one call, each item reporting `created` or already present.
19. Page setup & templates (6 tools, pack `core`): `page_setup_list/apply` (ISO 216 / ANSI Y14.1 sizes from `PAPER_SIZES`; an unknown ctb is written and reported `plot_style_known: false`), `plot_style_list` (catalog + the files actually installed on a live seat), `batch_plot` (every paper layout through `drawing_export_pdf(layout=)`; `mediabox_mm` is **read back from the PDF's own `/MediaBox`**, so the sheet size is verified, not assumed), `drawing_template_list` (the five bundled templates: `iso_a3_mech`, `iso_a1_arch`, `iso_a3_pid`, `ansi_b_mech`, `ansi_d_arch` — DXF headless, `.dwt` twin on COM, `scripts/build_templates.py --check` proves the DXFs reproducible), `drawing_template_save` (a `.dwt` request headlessly is refused with capability `dwt_write` naming the DXF route). `drawing_new(template=)` resolves a catalog name to the bundled file and reports `template: {name, source}`.
20. Environment (22 tools, pack `settings`): documents `document_list/activate/close` (both engines — the headless backend keeps a document registry; `document_close` refuses to discard unsaved work unless `discard=True`), layer states `layer_state_save/restore/list/delete` (portable: JSON chunks in an `ACADMCP_LAYERSTATES` XRECORD that travels with the file and **does not appear in AutoCAD's Layer States Manager** — `system_capabilities.layer_states` says so), named views `view_named_save/restore/list` (headless restore writes `$VIEWCTR/$VIEWSIZE` and reports `applied: "header_only"`), UCS `ucs_list/set/restore` (non-orthogonal axes refused with the measured angle; **tool coordinates stay WCS** — a UCS is stored for the operator, no tool interprets input in it), `system_variable_describe` (the 90-entry `SYSVAR_CATALOG`; an unknown name answers `known: false` plus nearest names, never an error; `system_set_variable` now refuses an out-of-range value for a catalogued name before writing), `drawing_properties_get/set` (custom properties on both engines through `$CUSTOMPROPERTYTAG`; the five summary fields are COM-only, capability `dwgprops`), and the live-only group `system_launch` (`live_application`), `system_preferences_get/set` (`preferences`; a whitelist of `Preferences.*` keys with ranges, read-only keys refused by name, writes report `old`/`new`), `user_pick_point/select`, `system_prompt_message` (`interactive_prompt`; a cancelled pick returns `{cancelled: true}`, a `COM_CALL_TIMEOUT` overrun reports `timed_out`). The COM paths were executed live once by `scripts/smoke_settings_com.py` (AutoCAD 2026), which also built the `.dwt` twins.

Profiles paragraph: lean ≈ 50 curated drafting tools (47 with TOOL_PACKS=core; → lean = 55 curated drafting tools (52 with TOOL_PACKS=core; the five settings essentials — drawing_apply_standard, dimstyle_set_current, textstyle_set_current, page_setup_apply, batch_plot — are core-pack tools and stay;. Packs paragraph: TOOL_PACKS=all|core,pid→TOOL_PACKS=all|core,pid,settings, and add ; settings is the 22 SECTION 20 environment tools, pid the nine P&ID tools; SECTION 18/19 are core. Key Conventions: the TOOL_PACKSbullet becomesTOOL_PACKS=all|core,pid,settings advertises only the packs a client uses; core is always on; system_about reports tool_packs.` and three bullets are added after it:

- **Layer states are ours, not AutoCAD's.** `layer_state_*` stores a portable snapshot in an `ACADMCP_LAYERSTATES` XRECORD; it survives save/reopen on both engines and travels with the file, and it is not listed in AutoCAD's Layer States Manager. Said in the tool docstring, in `system_capabilities.layer_states` (`mode: "xrecord"`, reason `portable_acadmcp_xrecord;not_listed_in_autocad_layer_states_manager`) and in the README.
- **Page-setup evidence is the PDF.** `batch_plot` reports `mediabox_mm` parsed from the file's `/MediaBox` (`engineering/standards/pdfinfo.py`); a page setup is verified by what a printer would get, not by the setter's return value. A style or setting write reports `changed` — re-setting a value to itself is not a change.
- **Nothing interprets coordinates in UCS.** `ucs_set` / `ucs_restore` store and activate a UCS for the operator's benefit; every tool input and output stays WCS (the repository rule above).

Keep item 11's text; drawing_settings gained keys (limits, grid, snap, ortho, polar, psltscale, annotation_scale, linear_units, angular_units, dimstyle, textstyle) — append to item 11: ; the facade now also covers limits, grid/snap/ortho/polar, PSLTSCALE, annotation scale ("1:50"), linear/angular unit formats and the current dimstyle/textstyle, with unknown keys refused and every key reported on read.

  • Step 2: README

What-you-get table, three rows after P&ID:

| Styles & standards | ISO-25 / ANSI dimension styles, ISOCP / ROMANS text styles, ISO / ANSI leader styles from authored presets; `drawing_apply_standard("iso")` sets all of it plus units and the `mech` layers in one call; `changed` reports only what moved |
| Page setup & templates | ISO 216 / ANSI Y14.1 sheets, ctb catalog, `batch_plot` verified by the PDF's own `/MediaBox`, five bundled templates (`drawing_new(template="iso_a3_mech")`), save any drawing as a template |
| Environment | several open documents headlessly, portable layer states, named views, UCS, a system-variable catalog with ranges, document properties, and on a live seat: launch/attach, preferences, an operator prompt / pick / select |

<sub> line: keep Task 24's wording. Token table: measure the four rows again (the surface grew by 38) and add the fifth:

uv run --frozen python benchmarks/token_suite.py
TOOL_PACKS=core uv run --frozen python benchmarks/token_suite.py --out /tmp/packs-core.json
TOOL_PACKS=core,settings uv run --frozen python benchmarks/token_suite.py --out /tmp/packs-core-settings.json

and rewrite the table with the printed default / lean / search rows of the first run and the default row of each of the other two:

| Advertised surface | Tools seen | Idle cost |
|---|---:|---:|
| `TOOL_PROFILE=full` (default) | 199 | <tokens> tokens |
| `TOOL_PACKS=core,settings` (full profile) | 190 | <tokens> tokens |
| `TOOL_PACKS=core` (full profile) | 168 | <tokens> tokens |
| `TOOL_PROFILE=lean` | 55 | <tokens> tokens |
| `DISCOVERY_MODE=search` | 2 | **<tokens> tokens** |

(<tokens> is the tokens column the suite prints for that row — the Tools seen values are arithmetic on the registry: 204 − 5 solids, minus 9 pid_*, minus 22 environment.) The "Why this exists" first sentence's two numbers (44,930 / 356) and the task-matrix row token_budget | 44,930 → 356 tokens are the same default and search figures — replace them from the same run. The Discovery row's of the 161-tool advertised catalog → of the 199-tool advertised catalog, and the corpus sentence **162 AutoCAD command names and 731 synonym phrases** covering all 166 tools is re-derived with:

uv run --frozen python -c "
from discovery.aliases import TOOL_ALIASES as T
print(len({c for r in T.values() for c in r.acad}), sum(len(r.synonyms) for r in T.values()), len(T))"

"What this release is bad at": add after the P&ID limitations subsection:

### Known limitations of the settings track

**Layer states are ours.** `layer_state_save` writes a portable snapshot into an `ACADMCP_LAYERSTATES` XRECORD. It survives save/reopen on both engines and travels with the DWG/DXF; it is **not** an AutoCAD `LAYERSTATE` and does not appear in the Layer States Manager. `system_capabilities` reports `layer_states` as `mode: "xrecord"` for that reason.

**Half the environment needs a live seat.** Preferences, the five summary fields of document properties, launching, and the operator prompts are refused headlessly with `preferences` / `dwgprops` / `live_application` / `interactive_prompt` — a refusal, not a stub. `mleaderstyle_create` is the mirror case: ActiveX exposes no MLeaderStyle collection, so the live engine refuses it and `leader_create_mleader` carries the per-leader parameters instead.

**A headless dimension does not re-render.** `dimstyle_modify` reports `dimensions_using_style` and `rerender_required: true` on the ezdxf engine; the DIMENSION keeps its rendered block until it is redrawn. AutoCAD regenerates on the next regen.
  • Step 3: CHANGELOG Unreleased and the spec

Insert directly after the Track A intro paragraph (before ### Added):

Track E of 1.6: the environment a drawing lives in. 38 tools in three new
sections — styles (18), page setup and templates (19), environment (20) —
the `drawing_settings` facade grown, a system-variable catalog, five bundled
templates, several open documents headlessly, portable layer states, named
views and UCS, and the live-only group (launch, preferences, document
properties, operator prompts) refused honestly headlessly. Spec:
`docs/superpowers/specs/2026-09-16-v1.6-settings-design.md`.

Under ### Added, before the P&ID bullet:

- **Styles — 10 tools, pack `core`.** `dimstyle_list/create/modify/set_current`,
  `textstyle_list/create/set_current`, `mleaderstyle_list/create`,
  `drawing_apply_standard`. ISO-25 and ANSI (metric) presets are authored data
  pinned by `tests/test_standards_presets.py` against ISO 129-1 / ASME Y14.2
  (DIMTXT 2.5 / 3.0, DIMASZ 2.5 / 3.0, DIMEXO 0.625 / 1.5, DIMDSEP `,` / `.`,
  DIMTAD 1 / 0 …). `dimstyle_modify` accepts the 33-name whitelist only (the
  17 preset variables plus 16 more — `DIM_VARIABLE_WHITELIST`) and
  refuses an out-of-range value by name before writing; `changed` lists what
  moved. A font neither bundled nor on the engine's font path is written and
  reported `font_resolved: false`, never refused. `mleaderstyle_create` is
  ezdxf-only (capability `mleaderstyle`): ActiveX has no MLeaderStyle
  collection.
- **Page setup & templates — 6 tools, pack `core`.** `page_setup_list/apply`
  (`PAPER_SIZES` ISO A0–A4 / ANSI A–E, `CTB_CATALOG`, `SCALES`), `plot_style_list`
  (catalog + installed), `batch_plot` — `mediabox_mm` parsed from each PDF's
  `/MediaBox`, so `ISO_A3` landscape is 420 × 297 because the file says so —
  `drawing_template_list`, `drawing_template_save` (`.dwt` refused headlessly,
  capability `dwt_write`). Five bundled templates built by
  `scripts/build_templates.py` through the server's own methods (`--check`
  proves the DXFs byte-reproducible); their `.dwt` twins were built once on
  AutoCAD 2026 by the smoke. `drawing_new(template="iso_a3_mech")` resolves the
  bundled file and reports `{name, source}`.
- **Environment — 22 tools, pack `settings`.** `document_list/activate/close`
  on both engines (the headless backend keeps a document registry;
  `drawing_close` is the active-document case); `layer_state_save/restore/
  list/delete` as portable XRECORD snapshots — restore applies only the
  requested `properties`, reports `missing_layers` (skipped, never created)
  and `new_layers` (untouched), and the state survives save/reopen;
  `view_named_save/restore/list`; `ucs_list/set/restore` (non-orthogonal axes
  refused with the measured angle; tool coordinates stay WCS);
  `system_variable_describe` over a 90-entry catalog (`known: false` + nearest
  names on a miss); `drawing_properties_get/set` (custom properties on both
  engines, summary fields COM-only: `dwgprops`); `system_launch`
  (`live_application`), `system_preferences_get/set` (whitelisted keys with
  ranges, read-only keys refused by name, `old`/`new` reported:
  `preferences`), `user_pick_point/select` and `system_prompt_message`
  (`interactive_prompt`; a cancelled pick is `{cancelled: true}`, a
  `COM_CALL_TIMEOUT` overrun is `timed_out`).
- **`drawing_settings` grows** by `limits`, `grid`/`grid_spacing`,
  `snap`/`snap_spacing`, `ortho`, `polar`/`polar_angle`, `psltscale`,
  `annotation_scale`, `linear_units`, `angular_units`, `dimstyle`,
  `textstyle`; `system_set_variable` refuses an out-of-range value for a
  catalogued name before writing.
- **Benchmark evidence.** `benchmarks/tasks_v4.py` adds `page_setup_truth`;
  the correctness suite grows to 32 with `settings_dimstyle_iso25_values`,
  `settings_layer_state_roundtrip`, `settings_pdf_mediabox_a3`. A/B against
  `v1.5.1`: **26 / 32 → 32 / 32, six `miss → pass`, zero regressed**.
  `scripts/smoke_settings_com.py` runs the track once against the live seat
  (record below).

Under ### Fixed, before the block_insert bullet, the seven Track A hardening fixes (Tasks 1–8 of this plan) — one bullet each, with the reproduction the hardening test pins:

- **Track A hardening** (`tests/test_track_a_hardening.py`; the remaining
  review findings are filed with a disposition each in
  `docs/analysis/track-a-review-backlog.md`):
  - `entity_create_block_ref` of an undefined block name is refused before
    writing (it returned a handle to a dangling INSERT).
  - `block_explode` keeps ATTRIB text: each ATTRIB becomes a TEXT entity on
    both engines instead of being dropped.
  - `pid_from_spec` refuses a NaN coordinate before the transaction opens and
    rolls back on `BaseException`, not only `Exception`.
  - A signal line no longer receives a fabricated line number and label; the
    reader reports `line_number: null` for it.
  - A bubble's exit point honours the first waypoint instead of the port
    direction.
  - `block_insert` attribute values are validated as strings; an int or None
    is a `TypeError` naming the tag, not `str()`-coerced.
  - `block_define` refuses a NaN base point and a layer that does not exist
    in the drawing (it wrote both and `drawing_audit` repaired them later).

Under ### Changed, before the TOOL_PACKS bullet, replace that bullet's first line with **TOOL_PACKS=all|core,pid,settings** (default all) and its lean sentence with (which now carries the five settings essentials: 55 tools, 52 with TOOL_PACKS=core), and its measured-cost sentence with the numbers from Task 26 Step 2. Add:

- Three tool groups appear in `system_about` and `docs/tool-inventory.json`:
  `styles` (10), `page_setup` (6), `environment` (22) — 204 tools in 23 groups;
  `LEAN_TOOL_NAMES` 55; capability keys `mleaderstyle`, `dwgprops`,
  `dwt_write`, `live_application`, `preferences`, `interactive_prompt` and the
  `layer_states` feature (`mode: "xrecord"`) in both maps.
- README release snapshot: 204 tools · 8 resources · 5 prompts · <collected>
  collected tests (this machine).

Spec status line (docs/superpowers/specs/2026-09-16-v1.6-settings-design.md, line 3–6):

**Date:** 2026-09-16 · **Status:** implemented 2026-09-16 (26 tasks of
`docs/superpowers/plans/2026-09-16-v1.6-settings-implementation.md`; live COM smoke on AutoCAD 2026 recorded in `CHANGELOG.md` Unreleased, `.dwt` twins built by it; A/B vs v1.5.1 32 / 32) · **Parent:**
`2026-09-15-v1.6-roadmap-design.md` (track E) · **Baseline:** main after
Track A (166 tools, 8 resources)
  • Step 4: Full gate

Run, in order, and fix anything red before committing:

uv run --frozen ruff check . && uv run --frozen ruff format --check .
uv run --frozen pytest -x -q
uv run --frozen python scripts/generate_tool_inventory.py --check
uv run --frozen python scripts/check_doc_numbers.py
uv run --frozen python scripts/build_templates.py --check
uv run --frozen python benchmarks/compare_versions.py v1.5.1

Expected: ruff clean; pytest green with every track E test file collected (tests/test_standards_presets.py, test_styles.py, test_settings_facade.py, test_sysvar_catalog.py, test_drawing_properties.py, test_page_setup.py, test_templates.py, test_batch_plot.py, test_documents.py, test_layer_states.py, test_views_ucs.py, test_environment_live.py, test_track_a_hardening.py, test_benchmark_v4.py, test_tool_packs.py); ok: docs/tool-inventory.json matches the live surface; ok: 16 document numbers match the tree (re-take the README collected-tests figure first if Step 1–3 changed nothing in tests/ it will not have moved since Task 25); the five DXFs reproducible; A/B 32 / 32, fixed: 6 regressed: 0.

  • Step 5: Commit
git add CLAUDE.md README.md CHANGELOG.md docs/superpowers/specs/2026-09-16-v1.6-settings-design.md
git commit -m "docs(settings): track E shipped — CLAUDE.md sections 18-20, README, changelog with the live COM smoke record"

Track E is done when the gate in Step 4 is green and the spec's §9 evidence is demonstrated by tests/test_standards_presets.py (every §4.1 value by name), tests/test_batch_plot.py (the parsed /MediaBox), tests/test_layer_states.py (the XRECORD survives reopen), tests/test_documents.py (two headless documents) and the smoke record in the CHANGELOG.