Decisions¶
Architecture decision records live in docs/adr/, and this is their index. An ADR without a
row here does not count as a decision; tests/unit/test_status_claims.py fails on an ADR without a
row and on a row without an ADR.
Architecture decision records¶
| ADR | Title | Status | Summary |
|---|---|---|---|
| ADR-001 | First migration family | Accepted; amended by ADR-050 and ADR-051 | The first v0 migration is google-generativeai to google-genai, chosen for its end-of-life status, official migration guide, corpus size and lack of an existing codemod. |
| ADR-003 | Model adapter | Accepted | Model help is one OpenAI-compatible adapter on stdlib urllib, configured only by the user, asked only about needs_review rows, and its untrusted proposals are applied only after the guard and with --accept-model. |
| ADR-004 | Repo visibility and CI | Accepted | The repository is public and CI runs on Linux, macOS and Windows for every push and pull request; the main ruleset requires fifteen named checks and a tag ruleset restricts v* to me. |
| ADR-005 | Technical stack | Accepted; amended by ADR-021, ADR-022, ADR-049 and ADR-050 | obelize is built with hatchling, uv, Typer, pydantic v2, libcst, subprocess git, pytest, ruff and strict mypy, runs on Python 3.12-3.15, and has at most seven runtime dependencies. |
| ADR-006 | Pack carries no code | Accepted; amended by ADR-050, ADR-053, ADR-055 and ADR-057 | A pack is declarative YAML that selects rule kinds from a Python registry and carries no code, and whatever a rule cannot prove becomes needs_review or unsupported, never a guess. |
| ADR-007 | Verify command trust | Accepted; amended by ADR-011 and ADR-033 | Verification commands come only from the user: --verify, the user allowlist, or .obelize.yml once approved (non-interactively only with --trust-repo-config); they run without a shell, and a pack's suggestions are shown, never run. |
| ADR-008 | Scanner and codemod corrections | Accepted; amended by ADR-010, ADR-012, ADR-017, ADR-019, ADR-022 and ADR-050 | Resolution branches on the qualified-name set, files are bytes in and bytes out behind a round-trip gate, path containment resolves both sides, output uses a total sort key, and benchmark versions are pinned. |
| ADR-009 | Project named Obelize | Accepted | The project, command, PyPI distribution, import package and repository are all named Obelize, chosen after register-level trademark clearance. |
| ADR-010 | Fixture oracle and atomicity | Accepted; amended by ADR-012, ADR-013, ADR-014, ADR-050 and ADR-053 | The hand-built fixture keys are the oracle: a file migrates completely or not at all (F-1), the manifest gains the new pin once anything migrates and loses the old one only when nothing needs it (F-2), and the closed vocabularies live in SCAN_VOCABULARY.md. |
| ADR-011 | CLI contract, open questions | Accepted; amended by ADR-015, ADR-033 and ADR-052 | Exit 6 means verification reached no verdict (policy_refused stays 5), exit 7 means an invalid pack, a dry run exits 0, and obelize undo takes no --force. |
| ADR-012 | File atomicity | Accepted; amended by ADR-050 | A file with any bail withholds every otherwise-eligible row, each withheld finding names the most specific rule that fired, and caused_by lists the bails behind it. |
| ADR-013 | Run folder output keys | Accepted; amended by ADR-023 and ADR-032 | RUN_FOLDER.md specifies the run folder and a test holds the code to it, model_proposed stays a distinct edit status, and a fixture file comes out as its .after.py byte for byte, or unchanged when it has none. |
| ADR-014 | The scan-time grade | Accepted; amended by ADR-050 | Each closed vocabulary is a Literal in src/obelize/models/vocabulary.py asserted against SCAN_VOCABULARY.md in both directions, a scan publishes a scan-time status and never a verdict, and the models refuse bad data rather than repair it. |
| ADR-015 | Configuration merge and exclusions | Accepted; amended by ADR-016 | An unusable .obelize.yml exits 2, --verify replaces the file's commands, a boolean flag only adds permission, a published always-excluded set outranks any configuration, and a command that needs a shell is refused on read. |
| ADR-016 | File selection and path guard | Accepted | One path guard in src/obelize/fsutil.py serves reads and writes, git's file listing is used only when it describes this root, a user's exclude prunes no directory, every refusal is a published reason, and the walker opens no file. |
| ADR-017 | Input gates | Accepted | A file is opened with O_NOFOLLOW and passes compile(), a depth check, libcst and the byte round-trip in that order, and a failure becomes one of four limitation codes carrying the parser's message (an unexpected error, only its class) and no source line. |
| ADR-018 | Pack schema | Accepted; amended by ADR-019, ADR-025, ADR-050, ADR-051 and ADR-053 | A pack declares every v0 surface, is refused at load for schema or coherence errors, refuses unsafe prose rather than stripping it, cites one primary source plus a per-claim SOURCES.md, and is checked by obelize pack validate. |
| ADR-019 | Resolution and detection rules | Accepted; amended by ADR-020 | scan/analysis.py resolves and the planner decides; a mention is anchored to its line, the hyphenated distribution name is never a source finding, and an unresolvable receiver is reported only where the file builds a legacy object. |
| ADR-020 | Impact plan and ladder | Accepted; amended by ADR-021, ADR-031 and ADR-050 | impact/dataflow.py decides a binding group and impact/planner.py the file, the client rung applies to a module's call rewrites, the bail ladder is one total order, and ImpactPlan refuses a plan F-1 would leave half-applied. |
| ADR-021 | Dependency manifests and verdict | Accepted; amended by ADR-050, ADR-053 and ADR-056 | A dependency manifest is selected by name and edited as lines (setup.py is parsed), and one repository-wide ManifestPlan adds the new pin once anything migrates and removes the legacy pin only when no file, notebook or transitive import still needs it; where one distribution holds both APIs, any withheld row holds the pin. A notebook is read as code for the legacy API and never written, and a pack may let the pin move by declaring the modules the legacy distribution installed (declare_transitive). |
| ADR-022 | Runner and result order | Accepted; amended by ADR-050 and ADR-052 | The parent reads, hashes and prefilters while workers parse, resolve and grade; the default pool is min(8, cpu_count - 1) workers from 32 candidates up, a typed --jobs is used as typed, and results are ordered by (path bytes, sha256). |
| ADR-023 | Scan command | Accepted; amended by ADR-050, ADR-052 and ADR-056 | obelize scan writes its run folder before printing, findings.json and --json carry no clock while run.json carries the run id and timings, a repository whose declared Python floor is unsupported is reported blocked, and untracked files off the plan do not make the tree dirty. |
| ADR-024 | Gate 1 measurement | Accepted; amended by ADR-026 | Gate 1 is measured on a committed, size-stratified sample of real repositories with keys written before the scan, a row matches on path, line, kind and symbol, precision over zero findings prints --, and a test recomputes the published block offline; the same frame, drawn for any pack, is asked what a fix dry run would write. |
| ADR-025 | Rule protocol and import manager | Accepted; amended by ADR-026 and ADR-050 | A rule claims findings and a file is written only when every eligible finding in it is claimed, finish() applies a rule's recorded decisions in one pass, and rename_import owns the import statements and the reads of the names a pack maps but no call site. |
| ADR-026 | Client placement and layout | Accepted; amended by ADR-050 | configure_to_client places the client by a table over where the module's call rewrites are and refuses when no placement reaches them all, carries only the pack's allowed_kwargs and two named keyword roles, and wraps rewritten calls at the pack's line length of 100. |
| ADR-027 | The model object | Accepted | generative_model_calls deletes GenerativeModel(...) and copies what it held into every call where that copy is portable, maps positional arguments by the legacy order, refuses a group whose uses are not all rewritable, and records nothing until nothing can refuse. |
| ADR-028 | Chat surface and async | Accepted; amended by ADR-050 | A binding group is the transitive closure of what its calls return, so a chat migrates with its model; a history is reshaped only where every part is certainly a string, and an await is kept, never inserted. |
| ADR-029 | Free functions | Accepted; amended by ADR-053 | rewrite_call migrates the free functions one call at a time with refusals local to the call, maps arguments only through the legacy signature the pack declares, refuses any other keyword, and writes a call whose result changed shape only where the result is discarded or every read of it is a path the pack lists. |
| ADR-030 | Flagged surfaces and dependency line | Accepted; amended by ADR-053 and ADR-054 | flag_only writes nothing and records an edit naming the change that refused the surface, and manifest_dependency is a separate ManifestRule that rewrites a declaration as two spans on its line, in place or as a new line below. |
| ADR-031 | The codemod driver | Accepted; amended by ADR-050, ADR-052 and ADR-053 | The driver runs source rules in pack order, then F-2's survey, then manifest rules; applies F-1 again; withholds a file whose configure another module still uses, and every file of a pack that is its own target while any of its rows is withheld; and returns, unwritten, only output that parses and binds every name it reads. |
| ADR-032 | Writing a file | Accepted; amended by ADR-049 | An apply refuses when the repository is dirty or git cannot say, checks every path before writing any, writes each file by temporary file, fsync, original mode and rename below an O_NOFOLLOW parent walk, and stops if a file changed since the plan read it. |
| ADR-033 | Running somebody else's command | Accepted; amended by ADR-049 | One refused command refuses the whole verification phase, interactive means a TTY with no CI and no --non-interactive, a baseline that does not pass stops the after-phase, a deadline kills the whole process group, and a program that never starts is command_not_executable, not fail. |
| ADR-034 | Fix evidence | Accepted; amended by ADR-039 | plan.json holds what a run would write and run.json's file_edits[] what it wrote, refused writes go in refused[], obelize never applies its own patch, and an apply saves its originals and a journal before the first write so undo can restore them. |
| ADR-035 | Apply order | Accepted | obelize fix gates the tree and paths before the baseline and again at the write, neither a refused verification nor a failed baseline stops the write, exit 4 means something was withheld, and obelize verify reads its commands from configuration, never the run folder. |
| ADR-036 | Model requests and answers | Accepted; amended by ADR-039 | A model is asked only about a needs_review row in a writable file whose bail it could repair, is sent the smallest def or class holding the whole binding group (else the group plus 20 lines either side), or nothing past 80 lines, and answers with an untrusted EditProposal. |
| ADR-037 | Proposal validation | Accepted; amended by ADR-039 and ADR-052 | The guard checks a proposal in one fixed order (the file asked about and unchanged, the sent range and the lines quoted from it, an absolute size limit, encoding, parse and compile, allowlisted names), records the first refusal, and returns the bytes it checked. |
| ADR-038 | One endpoint | Accepted; amended by ADR-039 | The adapter sends one request, with no retry, to the configured host only (no proxy, redirect, cookies or compression) and no Authorization header without a key, and records a failure as a ProviderFailure word with the reply redacted and capped. |
| ADR-039 | Model command surface | Accepted | The model pass runs after the tree gate and before the baseline, a file takes at most one accepted proposal, a model edit never clears a review item, --show-context prints every payload string unescaped and stops, and model/ holds one file per consultation. |
| ADR-040 | Candidate corpus | Accepted; amended by ADR-041 | A benchmark candidate is a repository the scanner reported on at a pinned SHA with an SPDX licence, archived does not disqualify, and a pack surface no candidate exercises is resolved by a dated, published query. |
| ADR-041 | Case definitions and split | Accepted | A case's key is written from the source before its scan is read and judges the code, not the tool; the split is hashed by owner and stratified on test-file presence; pattern_key comes from the ground truth; and a case with no test command is kept. |
| ADR-042 | Benchmark harness and tiers | Accepted; amended by ADR-043 | The harness runs both test phases around the SDK swap and compiles written files with the case's interpreter, a tier is computed from the record and an unreviewed case is never verified, --fixtures-only refuses the network, and the ceiling is published before a round. |
| ADR-043 | Round one results | Accepted | A case runs outside this repository, a pytest exit code is read against pytest's table, a published result carries no absolute path, provenance records the interpreter's sys.version, and a suite that ran no test verifies nothing. |
| ADR-044 | Error analysis | Accepted | Each unmigrated row gets one of three dispositions (rule change, scan change, recorded limitation) from a committed row-level record (a limitation names its ADR, a change an open COVERAGE gap), and causes are ranked by lever: the round recomputed with that cause alone fixed. |
| ADR-045 | The comparison arm | Accepted | The comparison arm is one general-purpose coding agent with one committed prompt over the same development-split repositories and keys, scored by scanning the tree it leaves, with its patches reviewed in a separate file and every free protocol choice made in its favour. |
| ADR-048 | Accepted gaps in 0.1.0 | Accepted | 0.1.0 ships with the open gaps in TM-5, TM-8, TM-9 and TM-10 stated rather than closed, and every format stays unstable through 0.x with each change in the changelog. |
| ADR-049 | The platform seam | Accepted | The calls behind never following a link and stopping a command with all it started go through src/obelize/native/, one module per mechanism (files, processes, console) that picks its implementation at import; callers hold no platform branch, what obelize writes is the same bytes everywhere, and CI runs the suite on Windows. |
| ADR-050 | Generic pack engine | Accepted; amended by ADR-053 and ADR-056 | The engine knows no SDK: a pack states only what a rule reads, and a field, code or policy no code reads is deleted rather than documented. |
| ADR-051 | Second migration family | Accepted; amended by ADR-056 | A family joins the bundled packs after a Gate 0 that measures every fact its rules rest on; PyPDF2 to pypdf is the second, and its target is pypdf 6.19 or later because every earlier release has known advisories, with what the pack cannot see stated as a limitation. |
| ADR-052 | Several packs per run | Accepted; amended by ADR-053 | A run uses every pack the repository needs, or those --pack names, from the wheel, the user's own directories and files; packs that could feed each other are refused, and the rest run in id order over one read, each as a single-pack run, one gate, write and undo for all, with the evidence naming each pack. |
| ADR-053 | Shared-module migrations | Accepted; amended by ADR-055 and ADR-057 | A module the new SDK keeps (openai 0 to 1) lists the legacy names, not the kept ones, with the old side held by a frozen snapshot; rewrite_call stays on the module and carries only listed keywords and result paths; where one distribution holds both APIs the pin and every file move together or not at all, and a model is not asked about such a pack. |
| ADR-054 | Pack guidance in the report | Accepted | scan and fix print the message and suggestion of each flagged change that claimed a finding of its own pack, and REPORT.md lists them with each pack's limitations and verification suggestions, escaped so a viewer runs none of it; no format gains a field, and obelize verify replaces only the last, whole note. |
| ADR-055 | Rename a setting | Accepted | A rename_setting change moves a shared module's setting to another name by rewriting its plain assignment, with the value ending as the new name demands (a literal gets a /, anything else is formatted and given one), and refuses every other use of the old name; openai.api_base becomes openai.base_url. |
| ADR-056 | The newest legacy release | Accepted | A pack may name the newest release of the distribution it migrates from (from.newest) and the names that release lacks (from.lacks); a declaration with no version, or one that admits it, is then taken as that release unless the repository shows another (code, a lock, a constraints or index setting, a version written elsewhere, an environment kept in it), and the run says what it took. A pack whose newest release is on the new side names the names only its old range defines (from.since) and takes a declaration that may install a release of that range beside code that uses one, on the same evidence; it can also name what only the new API defines (to.introduces), so a pin that admits only the old releases beside code that uses one is a manifest_code_mismatch. |
| ADR-057 | Rename a symbol | Accepted | A rename_symbol change moves a class a shared package's submodule defined to the package, in a reference and in the import that names it, only where a local server shows the same responses raise it on both sides (openai.error.RateLimitError, PermissionError and Timeout), and refuses a class that is called, raised or derived from, a handler that reads an attribute of its error, and a handler ahead that may now catch it. |
| ADR-058 | Models by section | Accepted | obelize.models is a package of six modules (vocabulary, fields, scan, config, verify, run), each importing only the ones before it, and its __init__ re-exports every name, so no import path and no JSON Schema changed. |
| ADR-059 | Verified dependency upgrades | Accepted | obelize is a tool for verified dependency upgrades, with the three bundled migrations as examples: the local-first, evidence-first and fail-closed rules stay, the things the first boundary ruled out (pack-less discovery, an optional network, more model routes, a registry, branch commits, isolated environments) become planned directions built one release at a time, and anything beyond reading the repository is allowed only by the user, never by the repository. |
| ADR-060 | Rename a name in its own module | Accepted | A rename_symbol change may carry renamed, a name that stays in its module and takes another spelling (acme.kit.old_greet is acme.kit.greet): references and the name on a from line change, the module's import does not, a collision is alias_collision, and a call is refused unless the change says calls: allowed, the pack author's claim that the arguments are unchanged; invented libraries under tests/packs/generic/ test the other shapes of a kind. |
| ADR-061 | Reach | Accepted | obelize is offered to CI, code scanning and coding agents as ways to read what the commands already do: scan --sarif is a view of the findings, scan --fail-on exits 4 for a status you name, and verify --json and undo --json print the file the command wrote. |
| ADR-062 | Reading other releases | Accepted | To migrate a library that has no pack, obelize reads two releases of it: a cached, host-limited network seam, files accepted only by the sha256 the index gave, archives opened for source alone, and the public API read with griffe (the seventh runtime dependency) in a child process, with obelize's own pairing of names and reading of deprecation messages. |
| ADR-063 | Synthesized packs | Accepted | obelize pack synth makes a pack from the public API of two releases of a library: a rename only where the library's own message names the new name, a move out of a submodule only on an unchanged signature, a flag for everything else with the library's words, and the dependency line; the pack must pass pack test or it is removed, its source type is api_diff and never bundled, and the index is named only in the user's own configuration. |
| ADR-064 | Finding what to upgrade | Accepted | obelize outdated reads the manifests and asks the index for the gap, the flags (inactive, withdrawn, advisory) and what can migrate each dependency; obelize upgrade plans one move as a dry run with a known pack or one made from the two releases and kept in the cache. |
| ADR-065 | Trying an upgrade | Accepted | upgrade --apply tries a move in a copy of the repository with two uv environments (the release in use, and the release moved to), compares the repository's own tests test by test, re-runs what broke once, and writes only if no passing test now fails; it asks first and is the one place obelize installs. |
| ADR-066 | A branch on request | Accepted | upgrade --apply --branch commits a move a trial passed to a new branch with git's plumbing (a temporary index, no checkout, no hook, no signature), never moves a branch that exists and never pushes. |
| ADR-067 | The flagged places | Accepted | upgrade --apply --branch --agent claude_code puts the places a pack only flags to the person's own claude as a text-only model (every tool, session, MCP server, skill and setting off, a fixed environment), checks each proposal with the guard, writes it to the trial's copy, lets the rules write the rest and the tests judge, and commits only to a branch. A proposal may bring its imports, and a place may be judged to need no edit, on the tests' word. |
Decisions not yet promoted to an ADR¶
Settled and implemented, but too small for an ADR; one that is questioned gets one.
- No
obelize inspect:obelize scan --packalready shows what a pack matches. - The console script is
obelize.cli:main, not the Typer app, so argv handling, exit codes and lazy imports stay in obelize's hands. - The version is single-sourced from
src/obelize/__init__.pyvia[tool.hatch.version];obelize --version, the wheel and the release tag check all read it. - Development tools are PEP 735 dependency groups (
[dependency-groups]:dev, andworkflowsfor the workflow audit), never an extra, which PyPI would publish as installable; a unit test enforces it.
How to add a decision¶
Name the file docs/adr/ADR-NNN-<slug>.md with the next number and a slug of at most five words,
and give it the same three headings every ADR here uses: # ADR-NNN: <title> (the title also at
most five words), ## Status, ## Decision and ## Consequences. Set the status to Accepted,
and add its row here in the same pull request. An ADR states the current decision and git keeps
its history: an amendment updates the amended ADR, whose status and row name the amending one, and
a superseded ADR is deleted with its row.