fix(corepoint): stop emitting a live field-write stub, and harden the literal and comment renderers (BACKLOG #1681, #1683) - #1197
wshallwshall wants to merge 2 commits into
Conversation
… literal and comment renderers (BACKLOG #1681, #1683)
#1681 -- the JSON and flat statement layers still passed a recovered target
into `_generate_steps`, which emitted
`msg.set(<target>, msg.field(<target>) or "")` for every unmapped action. That
line was never the inert passthrough its own comment claimed: `Message.set`
raises `KeyError` on an absent segment, and on a present segment with an absent
field it materialises the field and its empty components on the wire. The
validated role-parsed path (`_decline`) had already withdrawn it on exactly
those grounds; this finishes the job on the two layers that had not.
`UnmappedAction.stub_path` is deleted rather than left unused, so there is no
field for a future caller to re-populate. The recovered target is not lost -- it
rides into the marker text as `; intended target <path>`, which is what
`_decline` already did.
#1683 -- `_lit` is still `json.dumps`, NOT `repr`. `_collect_sends` recovers a
`MsgSend` destination by `json.loads`-ing `_lit`'s output back, so `repr`'s
single-quoted string kills the shipped XML fixture on an uncaught
`JSONDecodeError` before any module is written; `repr` also breaks the
double-quoted output that 39 assertions in the suite treat as a contract and
that ruff's format gate requires. Instead `_lit` gains `ensure_ascii=False` plus
`_assert_renderable`, which walks containers and refuses only what JSON renders
and Python cannot read back: `null`/`true`/`false`, a non-finite number, an
unpaired surrogate, a non-string dict key. A blanket "no non-string scalars"
refusal would have broken working input -- the `code_lookup` table dict and the
`split_field` destinations list are legitimate `_lit` arguments.
`_comment_text` now also deletes non-whitespace control characters, using
`messagefoundry.controlchars.strip_control_chars` rather than restating the
C0+DEL alphabet an eighth time.
Moving the recovered target into a comment is where #1681 and #1683 meet: a
`_lit` literal contains a newline by escaping it, a comment does not. So
`_generate_steps` is now the single escape boundary for the two raw fields that
reach it, and `_count_steps` applies the same escape to the class names the CLI
prints in its import summary.
Four tests that pinned the stub are rewritten to assert its absence;
`"msg.field(" not in body` is the stronger form, matching the already-hardened
declined-statement test. Five new tests cover the injection, the NUL, the JSON
scalars, the surrogate, and the container case that must keep working -- each
verified to fail with its own fix reverted.
ADR 0086 gains amendment (c-double-prime) withdrawing the stub, with AC-2 and
AC-5 updated and the ADR index row corrected.
Lander reviewThe find is the sink the brief did not name, and it is a count-and-log defect rather than a cosmetic one. Escaping at ONE boundary, proved rather than asserted. Sanitising at construction and at render looked safer, and the measurement is what settled it: doing both neutered the construction-time escape and produced a byte-identical marker line for the hostile payload. Nothing pinned that, so a future reader would have deleted the load-bearing half believing it redundant. Two escapes that produce identical output are indistinguishable until one is removed. It uses the shared alphabet. Whitespace collapse then C0+DEL strip. Both halves needed: Scope confirmed: zero The residual is recorded at its true size. 18 renders of All five new tests verified to fail with their own fix reverted, in two control runs. The Verdict: merge. |
Closes BACKLOG #1681 and #1683. One PR because both edit
messagefoundry/corepoint_import.pyandtests/test_corepoint_import.py, and #1681 deletes the two_lit(step.stub_path)call sites that #1683 would otherwise have hardened.#1681 -- the live field-write stub is withdrawn from the two layers that still emitted it
_generate_stepsemittedmsg.set(<target>, msg.field(<target>) or "")for everyUnmappedActioncarrying astub_path._map_action(the superseded JSON layer) and_map_statement(the fallback for an export whose@Datacarries no span markup) both still set one.That line was never the inert passthrough its own comment claimed.
Message.setraisesKeyErroron an absent segment, and on a present segment with an absent field it materialises the field and its empty components on the wire._decline, on the validated role-parsed path, had already withdrawn it on exactly those grounds and left the reasoning in a comment. This finishes the job.UnmappedAction.stub_pathis deleted, not left unused, so there is no field for a future caller to re-populate. The recovered target is not lost: it rides into the marker text as; intended target <path>, which is what_declinealready did.#1683 --
repr()was rejected, and the evidence is_collect_sendsThe row prescribed rendering strings with
repr(). That is fatal, not merely disruptive:_collect_sendsparses_lit's output back as JSON. It recovers aMsgSenddestination withjson.loads(step.args[0]), whereargscomes from_role_send_args/_send_args, both of which render through_lit.reprproduces a single-quoted Python string thatjson.loadsrejects, so the shippedacme_adt_package.xmlfixture dies on an uncaughtJSONDecodeErrorbefore any module is written.tests/test_corepoint_import.pypin_lit's double-quoted output, and ruff's format gate wants double quotes in the generated module too.json.dumpsgives double,reprgives single.So
_litstaysjson.dumps, gainsensure_ascii=False, and gains a refusal pass. Theensure_ascii=Falsehalf mirrorsmessagefoundry/lens.py::_render_literal(read, not edited) -- the default ASCII escaping turns an astral code point into a\uXXXXsurrogate pair that Python re-reads as two lone surrogates.The refusal is narrower and wider than the row asked
The row prescribed "refuse non-string scalars". That is too broad:
_litis legitimately handed theItemCodeLookuptabledict and theItemSplitdestinationslist, and a blanket refusal breaks working input.test_a_nested_container_of_strings_still_renderspins that they keep working.It is also too narrow: the genuinely unguarded values were
raw["default"]and the table's own values.{"table": {"M": null}}renders{"M": null}, which is aNameErrorthe moment the generated module is imported._assert_renderabletherefore walks containers and refuses exactly what JSON renders and Python cannot read back:null/true/falsenull/true/falseNaN/Infinity/-InfinityUnicodeEncodeErrorat file writeInts, finite floats, lists and dicts of those all still render. The error names the position (
export value['M'] is ...) so a 200-entry lookup table says which entry is bad.The surrogate check is
text.encode("utf-8")in atry, not a per-character code-point scan: it is the same operationimport_corepointperforms later, so there is no second definition of "encodable" to drift out of step with it, and it is 5x-77x faster by length.The injection hole #1681's own prescription would have opened, and how it is closed
This is the finding that changed the shape of the fix. #1681 prescribed moving the recovered target into a
#comment. On the JSON layer,_map_action's target israw.get("target") or raw.get("destination") or raw.get("source")-- arbitrary, unvalidated JSON that has been through no grammar. Today it is contained only by_lit, and a_litliteral contains a newline by escaping it, while a comment does not: the line simply ends and whatever follows is a statement.So the row's own fix, applied literally, converts a contained value into a top-level code injection. An export with
{"class": "ItemMystery", "target": "MSH-6\nimport os\nos.system(...)"}yields a module that still compiles and importsos. Measured -- see the control runs below.Both
source_classand the recovered target now go through the hardened_comment_textat the render site (_generate_steps), which is the single escape boundary for the comment path. They stay raw at construction deliberately: a comment needs a different escape from a literal, the renderer is the one place that knows a value is about to become a comment, and there are more places that build anUnmappedActionthan render one. Escaping at both layers would leave a reader unable to tell which is the contract -- and the natural "cleanup" would be to delete the load-bearing one._comment_textalso now deletes non-whitespace control characters. NUL is the member that matters: Python refuses to compile a source string containing one, so a single NUL in an export's class name turned the whole generated module into a file that cannot be imported. The alphabet comes frommessagefoundry/controlchars.py::strip_control_charsrather than being spelled out an eighth time -- that module exists precisely to stop the copying.Second render site closed:
_count_stepscollectssource_classintounmapped_classes, which the CLI prints in its import summary. Because the taint is neutralised at the render site rather than at construction, that sink inherited nothing. A class named"Foo\n IB_X.py (400 mapped)"would forge a line in the count-and-log record a migrator trusts. Same escape, different sink.The four tests that pinned the stub
test_unmapped_action_is_stubbed_not_dropped(renamed to..._is_marked_not_dropped)stub_path == "OBX-5",'msg.set("OBX-5", msg.field("OBX-5") or "")' in src"intended target OBX-5"in the detail and in the source;_assert_no_live_stubtest_unmapped_without_target_emits_marker_onlystep.stub_path is None,"msg.set(" not in src"intended target" not in step.detail;_assert_no_live_stubtest_unmapped_verb_emits_a_todo_marker_and_is_counted'msg.set("OBX-5", ...)' in src"intended target OBX-5" in src,"msg.field(" not in srctest_a_path_that_does_not_resolve_is_never_guessed'msg.set("PID-3.1"' in src,'msg.set("NK1-2.1"' in src"intended target PID-3.1"/"...NK1-2.1"in src;_assert_no_live_stubThe rename is carried into ADR 0086's AC-2 pointer -- those were the only two places the old name appeared.
Why
"msg.field(" not in bodyis the stronger assertion. It pins the withdrawn stub's read, which no marker or hint text has any reason to mention, so it still fails on a passthrough rewritten to write through something other thanMessage.set-- which is the shape a partial revert would take."msg.set("alone would pass such a revert. Both are asserted, in one helper (_assert_no_live_stub) rather than four copy-pasted pairs, matching the already-hardenedtest_a_declined_statement_emits_no_live_stub.New tests, each verified to fail with its own fix reverted
Two control runs, hardening reverted one mechanism at a time, then restored:
test_an_unmapped_actions_recovered_target_cannot_escape_its_commentimport osbecomes a real top-level statement; the module stillast.parsestest_a_nul_in_an_action_class_cannot_make_the_module_uncompilable\x00reaches the source;compile()raisesValueErrortest_a_json_scalar_python_cannot_read_is_refused_not_rendered(3 cases){"M": null}/default=null/{"M": true}written into the moduletest_an_unpaired_surrogate_is_refused_before_it_reaches_the_filewrite_text(..., encoding="utf-8")test_a_nested_container_of_strings_still_rendersThe parametrized case asserts the position in the message (
export value['M'] is), not just the token, so the three cases can actually tell each other apart.ADR 0086
Amendment
(c'')withdraws the passthrough stub. The original(c)paragraph is kept as written rather than edited, because the stub shipped and a reader needs to recognise the line in a module generated before today; the amendment names the two sentences that no longer describe the code. AC-2 and AC-5 are updated, anddocs/adr/README.md's index row -- which still asserted the stub as the general rule, and is what a reader hits before the ADR body -- is corrected.tests/test_lens_parse.pyhas a two-word docstring/comment correction ("TODO stub" -> "TODO marker"). Thekinds.count("code") == 1assertion is unchanged and still passes: the lens classifies the bare marker comment as onecoderow, so AC-4 is untouched. PR 1170 is merged into this base, solens.pyitself is not edited here.Checks run, in this worktree's venv, all foreground
ruff format --check .-- 1305 files already formattedruff check .-- all checks passedmypy messagefoundry(strict) -- no issues in 275 source filespytest tests/test_corepoint_import.py tests/test_lens_parse.py-- 105 passedpytest -qwithQT_QPA_PLATFORM=offscreen-- 3280 passed, 116 skipped, 8 xfailed/simplify-- four review agents; fixes applied belowOne pre-existing local failure, unrelated and environmental.
tests/test_ci_retry_native_crash.pyfails 6 of 10 on this box because the test invokesC:\windows\system32\bash.EXE(WSL bash), which cannot resolve the Windows pathC:/Users/.../scripts/ci/retry-native-crash.sh. It fails identically against the committed tree and touches none of the five files this PR changes. It should pass on a Linux runner.Legs a Builder never sees -- please read these after the run:
windows-service-smoke(NSSM), and any leg needing thevaultextra, which is absent from this interpreter (the suite says so itself at the end of every run, so the 3280 above does not cover vault-gated modules).From the
/simplifypassApplied: the C0+DEL alphabet now reuses
controlchars.strip_control_charsinstead of a private table;_comment_safefolded back into_comment_text(the split was a default argument in disguise);_assert_renderable's breadcrumb simplified to a plain string and given a test; the surrogate scan replaced byencode("utf-8"); the construction-time escape in_map_actionremoved in favour of the single render-site boundary;_one_action_exportfolded together with_named_inbound_exportand the two inline copies of the same JSON skeleton retired.Skipped, deliberately: 18 renders of
Control.source_verbinto comments are still unescaped. They are safe by grammar today -- every construction site is either an XML local Name (which cannot contain whitespace or NUL; NUL is not a legal XML character, so expat rejects it first) or a string matched by_VERB = ^[A-Za-z][A-Za-z0-9_]*$. Two of the 18 are trailing comments on live statements, which is the sharp shape. Nothing is measured broken, so fixing them is out of scope here -- but widening_VERBwould silently arm all 18, and that is recorded here so the next person knows.Also skipped:
messagefoundry/__main__.pystill says "TODO stubs" in itsimportsubparser help and its summary line. Open PRs 1191, 1192 and 1194 hold that file, so this PR stays out of it.docs/backlog-proposals/fable-packet10b-rootrest.mdstill describes the stub as unfixed; it is a dated packet snapshot, so it is left as the historical record.#1682 is NOT in this PR
#1682 (the importer recurses once per sibling branch marker) also edits
corepoint_import.pyand was not measured. It is untouched here and should be expected to rebase onto this result --_generate_steps,_map_action,_map_statementand_declineall moved.Proposed ledger banner text (vault-only;
docs/BACKLOG.mdnot edited)#1681 -- SHIPPED 2026-09-16, PR 1197.
UnmappedAction.stub_pathdeleted;_map_actionand_map_statementpass no stub target and carry the recovered field in the marker text as_declinedoes; themsg.setemission is gone from_generate_steps. Four stub-pinning tests rewritten to"msg.field(" not in body. ADR 0086 amendment (c'') + AC-2 + the ADR index row. The row's own prescription (move the target into a comment) opens a code-injection hole on the JSON layer, where the target is arbitrary unvalidated JSON; closed by routing it through the hardened_comment_textat the render site, with a fixture.#1683 -- SHIPPED 2026-09-16, PR 1197. The row's
repr()prescription is fatal, not disruptive:_collect_sendsdoesjson.loadson_litoutput and the shipped XML fixture dies onJSONDecodeError;repralso breaks the double-quoted output 39 assertions treat as a contract. Usedjson.dumps(..., ensure_ascii=False)plus a refusal pass instead. The row's "refuse non-string scalars" is both too broad and too narrow -- thecode_lookuptable dict andsplit_fielddestinations list are legitimate_litinput; the genuinely unguarded values wereraw["default"]and the table's own values._comment_textnow strips non-whitespace controls viacontrolchars.strip_control_chars. Five fixtures, each verified to fail with its fix reverted.