Skip to content

ADR-037: Proposal validation

Status

Accepted; amended by ADR-039 (D9) and ADR-052.

Decision

D1. Seventeen words in one fixed order, first refusal wins

GuardRefusal is a closed set in models/vocabulary.py. The checks run in this order and only the first refusal is recorded:

# Word Refused because
1 path_outside_root the path is absolute, holds a .. component, or is not a relative POSIX path
2 path_not_python it does not end in .py
3 path_not_the_consulted_file it is not the file the question was about (D2)
4 file_changed_since_read the bytes on disk are not the bytes the plan read (D3)
5 outside_the_context the replaced range is not inside the range that was sent
6 site_not_replaced the replaced range does not contain the line asked about
7 symbol_mismatch the proposal names a symbol other than the question's
8 replaced_text_differs the lines it says it replaces are not the lines it names, as sent (D12)
9 replacement_too_large over the line or byte limit (D5), or a statement over the token limit (D11)
10 replacement_not_displayable it holds a character a terminal acts on
11 replacement_carries_redaction it holds [REDACTED, which the lines it replaces do not (D10)
12 replacement_not_encodable the file's own encoding cannot hold it (D6)
13 output_does_not_compile compile() refuses it (D6)
14 output_does_not_parse libcst refuses a module compile() accepted, or it nests deeper than obelize reads (D6, D11)
15 replacement_runs_past_its_range it changes how the lines after it are read
16 import_outside_the_target the result imports a module the pack does not target (D7)
17 name_outside_the_question it names something the question did not license (D7)

Three groups: may this file be touched (1-4), is this an answer to the question (5-8), and is the result Python that brings nothing in (9-17), last because those cost a parse. ../../.bashrc is reported as an escape, not an unplanned file, because that is what a reviewer must learn first.

D2. The allowlist is one path: the file the question was about

The target must be exactly Consultation.context.path, narrower than ADR-003's "the impact plan". An invented filename and a different planned file share path_not_the_consulted_file; the detail line says which. Because one question is one finding in one file.

D3. The hash is asked of the disk, and every way the disk can disagree is one word

fsutil.sha256 of the bytes the plan read is compared with that of fsutil.read now (O_NOFOLLOW). Changed, gone, a symlink or unreadable are all file_changed_since_read, with the reason in the detail. Because a proposal's line numbers mean nothing against other bytes; fsutil.apply repeats the check for the write.

D4. Four questions about where the edit lands, not one

outside_the_context is Context.holds, called rather than re-derived. site_not_replaced: the replaced range must contain Consultation.line. symbol_mismatch: the proposal's symbol must equal the consultation's, which also refuses a consultation with no symbol. replaced_text_differs is D12's. Because whether the symbol exists is the scan's fact; the guard checks the answer is about it.

D5. The diff size limit is on the replacement, and it is absolute

REPLACEMENT_LINE_LIMIT = CONTEXT_LINE_LIMIT (80) and REPLACEMENT_BYTE_LIMIT = 1024 * REPLACEMENT_LINE_LIMIT (81 920 UTF-8 bytes), not relative to the replaced range. Because the removed side is already bounded by the context, and the byte limit bounds one huge line. A third limit, on the tokens of one statement, is D11's.

D6. Three output gates, because encoding is not parsing

The result is encoded in the file's own encoding as libcst reports it, then compiled, then parsed. Because a latin-1 file cannot take a character latin-1 lacks, and libcst parses what compile() refuses (await outside async). compile() goes first because it answers a deeply nested expression with an exception, where libcst's parser overruns the C stack and kills the process; a syntax error is therefore output_does_not_compile, and output_does_not_parse is what compile() accepted and libcst cannot read. Before them a replacement may carry no character a terminal acts on except tab and line breaks; after them its last token must end a statement.

D7. The import check reads __import__ and importlib.import_module too

Every module the result imports and the input did not must be a to_module of the pack's rename_import changes or under one; a relative or non-literal import is under nothing. import, from ... import, __import__, importlib.import_module and a bare import_module all count. Then every name the replacement reads must be in the replaced lines, bound by a target import or the replacement, or one of a few pure builtins, and never a dunder. Because --accept-model with verification commands runs a proposal before anyone reads it.

D8. The guard returns the bytes it checked

check writes nothing and returns a Checked: the proposal, the refusal or None, a detail line, and on acceptance the resulting file bytes, the only splice in the package. providers.base.split_lines divides lines for both, and Consultation.to_modules carries the allowed imports to prompt and guard. Because bytes rebuilt by a caller need not be the bytes that were checked.

D9. Nothing calls the guard yet

Superseded by ADR-039: providers/proposals.py checks every proposal before obelize fix may write it.

D10. A placeholder the model echoes is refused

The context a model is shown is redacted, so a secret stands in it as [REDACTED...]. A replacement that repeats the marker would write the word into the file where the value stood, and what ran with the value would run with the word. replacement_carries_redaction compares the [REDACTED prefix in the replacement with the lines it replaces: a marker those lines already hold is the file's own text and passes. Because a proposal that compiles and imports nothing can still destroy a credential, and the later checks cannot see it.

D11. A reply nested deeper than the stack is refused, not raised

A replacement of a few hundred operands in one chain passes compile() and libcst's parser, and the visitors that walk its tree recurse once per level and raise RecursionError. check catches that and MemoryError and records output_does_not_parse, with the detail "nested more deeply than obelize reads". Because a refusal is a row for a reviewer, and an exception ends the run with a traceback and no run folder.

libcst's parser itself does not raise: it overruns the C stack and kills the process. compile() answers most deep expressions with an exception, so it is asked first, but it takes a chain of about 13,000 and or or terms on every version (the AST holds them flat) and a long chain of any operator from Python 3.14, where its own limit is the stack's. Between compile() and libcst the guard therefore counts the tokens of the longest statement that the replacement holds and refuses more than REPLACEMENT_STATEMENT_TOKENS (500) as replacement_too_large. A statement is never deeper than its tokens. The number is a fraction of what was measured to kill the process on Python 3.15, about a thousand tokens of any chain per MiB of stack, and far above a rewritten call; a chain past a few hundred levels is refused after the parse anyway. The limit holds for the replacement alone: a file that already holds such an expression is read by the scanner, which has the same open item.

D12. A proposal quotes the lines it replaces, and the quote must be what was sent

EditProposal.replaces is required: the text of the lines the range names, as the model was shown them. After symbol_mismatch the guard compares it with Consultation.context.text for start_line to end_line, line by line without trailing whitespace or line break, and ignoring blank lines at the end of either (a quote joined with newlines has no tail where the range ends blank; a final line break is allowed; indentation is not ignored), and refuses a difference as replaced_text_differs. The context is the redacted text the model read, so a secret is quoted as its marker and the quote of the raw value is refused. A redaction can join lines (a private key over three lines becomes one marker), which leaves the context with fewer lines than its range and every later line number untrustworthy: the guard then refuses every proposal for that context, with that sentence in the detail. Because a range that slipped by one line still covers the asked line, is inside the context and names the right symbol, and the result can compile: only the text it claims to replace can tell it from the range that was meant. A model that cannot quote exactly is refused more often, which fails closed.

Consequences

  • A proposal failing several checks reports one, so counts by word count first refusals.
  • A latin-1 repository refuses some correct-looking proposals; the row stays needs_review and the file keeps its bytes.
  • REPLACEMENT_LINE_LIMIT follows CONTEXT_LINE_LIMIT; tests/unit/test_providers_guard.py pins the boundary.
  • Open: a pack targeting several modules, or a target import absent from the files rewritten, makes D7's allowed set a real constraint. A model is asked about one pack, so the set never holds two packs' targets (ADR-052 D6).