From 49761a7f173172278381fc5bffe5867242b7bb67 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 18:11:08 +0000 Subject: [PATCH 1/5] correct: Keep a later separator immutable Bech32 separates the HRP at the last "1". Correction only checked that the input started with the context's "1", so an entered "1" later in the body was treated as a mutable explicit error and corrected away. For example, an ms string with a "1" typed at position 15 actually carries a different HRP, yet correct(CorrectionContext("ms", 48), ...) returned the original ms artifact. Reject the input during normalization unless its last "1" sits immediately after the context HRP. This applies to every structural and fixed search, public and CLI, since all of them normalize here. Security: invariant 5 requires a syntactically present HRP and separator to remain immutable correction context. Correction can no longer move text from one namespace into another by repairing its separator. Validation: new regression test fails before and passes after; ruff, mypy, and the correction test modules pass. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- src/codex32/indel.py | 3 ++- tests/test_correction_indel.py | 9 +++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 98a63698..91510bad 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -407,7 +407,8 @@ def _normalize(context: CorrectionContext, damaged_text: str) -> tuple[str, int] return None prefix = context.immutable_prefix or f"{context.hrp}1" matches = text.startswith(prefix) if context.immutable_prefix else text.lower().startswith(prefix) - if not matches or not target - 8 <= len(text) <= target + 8: + # Bech32 separates at the last "1"; a later one enters a different HRP. + if not matches or text.rfind("1") != len(context.hrp) or not target - 8 <= len(text) <= target + 8: return None return text, len(prefix) diff --git a/tests/test_correction_indel.py b/tests/test_correction_indel.py index d15c6225..08d9b233 100644 --- a/tests/test_correction_indel.py +++ b/tests/test_correction_indel.py @@ -229,6 +229,15 @@ def test_immutable_confirmed_header_reduces_domains_and_cannot_be_repaired() -> assert correct(context, SOURCE[:5] + "?" + SOURCE[6:]) == () +def test_later_separator_enters_another_namespace_and_is_not_repaired() -> None: + # Bech32 splits at the last "1", so this text no longer carries the ms HRP. + damaged = SOURCE[:15] + "1" + SOURCE[16:] + + assert correct(CONTEXT, damaged) == () + assert correct(CorrectionContext(Profile.MS), damaged) == () + assert correct(CONTEXT, damaged.upper()) == () + + def test_header_pruning_is_a_lossless_lower_bound() -> None: ordinary = [0, 1, 2, 3, 4, 5] ordinary[0] = 15 # threshold 0 From 950606e6a669a5fcdb0fdae515089b0170c1f9eb Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 23:02:46 +0000 Subject: [PATCH 2/5] correct: Share one ledger across case readings Mixed-case input is searched twice: once with minority-case symbols erased and once with the case normalized. Each _search_many() call built its own frontier, so each interpretation started a fresh admission budget and the combined admitted capture mass could exceed the mandatory bound of 1. For a 48-character ms string with 13 minority-case symbols, the erased reading admitted a whole-checksum completion while the normalized reading admitted its own layers; the returned candidate reported a cumulative mass of about 1.318. _frontier() now accepts the other interpretation's states. Their layers take part in the volume-ordered admission but are not returned, so the union of both calls is exactly one greedy admission over every layer, independent of search order. The required-only discovery passes compute that same full frontier and then restrict it, so a seeded candidate can only come from a layer the full search also admits. The public API and the CLI scheduler both pass the other reading. Security: restores invariant 5's single shared mass bound for mixed-case correction. Inputs whose explanation no longer fits the bound now yield no suggestion instead of an over-budget one. Validation: new public and CLI regression tests fail before and pass after; ruff, mypy, full pytest (914 passed), verify_correction_constants.py and differential_correction.py --verify pass. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- docs/developer/api.md | 2 +- src/codex32/_cli_input.py | 21 ++++++++---- src/codex32/correction.py | 6 ++-- src/codex32/indel.py | 54 +++++++++++++++++-------------- tests/test_mixed_case_deadline.py | 26 ++++++++++++++- 5 files changed, 74 insertions(+), 35 deletions(-) diff --git a/docs/developer/api.md b/docs/developer/api.md index 51c9fb5c..8d46bf11 100644 --- a/docs/developer/api.md +++ b/docs/developer/api.md @@ -432,7 +432,7 @@ and reparses survivors. No meet-in-the-middle search is used. Each layer is charged `alignment_count * 32**E * C(M-E,S) * 31**S`, with `M` the mutable body length. All fixed, character, group and target-length layers -share one admission ledger. Complete equal-volume batches are admitted only +of both mixed-case interpretations share one admission ledger. Complete equal-volume batches are admitted only while their cumulative mass is at or below `1`. Integer scaling handles 65-bit and 75-bit checksum spaces together. Consecutive fixed erasures are included in this ledger, with no independent allowance. Profile validation, diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 0d2a41e4..67f3b711 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -432,6 +432,7 @@ def _correction_candidates( seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, optional_only: bool = False, + shared_texts: Sequence[str] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) targets = _correction_plan(profile, byte_length, count, target) @@ -450,6 +451,7 @@ def _correction_candidates( seed_candidates=seed_candidates, required_only=required_only, optional_only=optional_only, + shared_texts=shared_texts, ) if allowed is not None: candidates = tuple(candidate for candidate in candidates if allowed(candidate)) @@ -492,21 +494,28 @@ def _scheduled_candidates( if retry is not None: # Find required candidates for both case interpretations before either # full search can spend the shared deadline on optional alignment. - # These discovery passes deliberately do not charge capture_layers; - # the full searches below account each admitted frontier once. - for required_value in (first, retry): + # Both interpretations draw on one admission ledger. These discovery + # passes do not charge capture_layers; the full searches below account + # each admitted frontier once. + for required_value, other in ((first, retry), (retry, first)): seeded, complete, deadline = search( - required_value, deadline=deadline, seed_candidates=seeded, required_only=True + required_value, + deadline=deadline, + seed_candidates=seeded, + required_only=True, + shared_texts=(other,), ) if not complete: return (), False capture_layers: list[tuple[int, int]] = [] full_search = partial(search, capture_layers=capture_layers, optional_only=retry is not None) - candidates, complete, deadline = full_search(first, deadline=deadline, seed_candidates=seeded) + candidates, complete, deadline = full_search( + first, deadline=deadline, seed_candidates=seeded, shared_texts=() if retry is None else (retry,) + ) if retry is None: return candidates, complete retry_candidates, retry_complete, _deadline = full_search( - retry, deadline=deadline, seed_candidates=(*seeded, *candidates) + retry, deadline=deadline, seed_candidates=(*seeded, *candidates), shared_texts=(first,) ) complete = complete and retry_complete annotated = [] diff --git a/src/codex32/correction.py b/src/codex32/correction.py index 39fc2627..175fe37e 100644 --- a/src/codex32/correction.py +++ b/src/codex32/correction.py @@ -857,8 +857,8 @@ def _correct_complete( if interpretation is not None: # Establish both interpretations' fixed/required candidates before # either interpretation can spend the shared deadline on optional - # alignment work. These discovery passes use a private accounting - # ledger; the full searches below account every admitted layer once. + # alignment work. Both interpretations draw on one admission ledger; + # only the full searches below account every admitted layer once. for input_context, value in inputs: preflight_contexts: tuple[CorrectionContext, ...] if input_context.expected_length is not None: @@ -884,6 +884,7 @@ def _correct_complete( observed_text=damaged_text, seed_candidates=candidates, required_only=True, + shared_texts=tuple(other for _context, other in inputs if other != value), ) if not current_complete: return (), False @@ -912,6 +913,7 @@ def _correct_complete( observed_text=damaged_text, seed_candidates=candidates, optional_only=interpretation is not None, + shared_texts=tuple(other for _context, other in inputs if other != value), ) complete &= current_complete if not current_complete and not candidates: diff --git a/src/codex32/indel.py b/src/codex32/indel.py index 91510bad..5ba5635c 100644 --- a/src/codex32/indel.py +++ b/src/codex32/indel.py @@ -333,24 +333,30 @@ def _layers( def _frontier( - states: Sequence[_Target], primary: frozenset[int] + states: Sequence[_Target], primary: frozenset[int], shared: Sequence[_Target] = () ) -> dict[tuple[int, _StructuralClass, int, int], int]: - layers = tuple(layer for state in states for layer in _layers(state)) - maximum = max((bits for _volume, bits, _key in layers), default=0) + """Admit layers by volume; ``shared`` interpretations spend the same ledger but are not returned.""" + layers = tuple( + (*layer, own) + for own, group in ((True, states), (False, shared)) + for state in group + for layer in _layers(state) + ) + maximum = max((bits for _volume, bits, _key, _own in layers), default=0) admitted: dict[tuple[int, _StructuralClass, int, int], int] = {} cumulative = 0 def add( - pool: Sequence[tuple[int, int, tuple[int, _StructuralClass, int, int]]], + pool: Sequence[tuple[int, int, tuple[int, _StructuralClass, int, int], bool]], ) -> None: nonlocal cumulative for _rank, grouped in groupby(sorted(pool, key=lambda item: item[0]), key=lambda item: item[0]): batch = tuple(grouped) - increment = sum(volume << (maximum - bits) for volume, bits, _key in batch) + increment = sum(volume << (maximum - bits) for volume, bits, _key, _own in batch) if _FALSE_BOUND_DENOMINATOR * (cumulative + increment) > 1 << maximum: break cumulative += increment - admitted.update((key, volume) for volume, _bits, key in batch) + admitted.update((key, volume) for volume, _bits, key, own in batch if own) add(tuple(layer for layer in layers if layer[2][0] in primary)) add(tuple(layer for layer in layers if layer[2][0] not in primary)) @@ -562,34 +568,32 @@ def _search_many( seed_candidates: Sequence[CorrectionCandidate] = (), required_only: bool = False, optional_only: bool = False, + shared_texts: Sequence[str] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool]: deadline = monotonic() + 10 if deadline is None else deadline - states = tuple( - state - for context in contexts - if ( - state := _prepare( - context, - damaged_text, - _CLASSES, - observed_text, - ) + + def prepare(text: str) -> tuple[_Target, ...]: + return tuple( + state + for context in contexts + if (state := _prepare(context, text, _CLASSES, observed_text)) is not None ) - is not None - ) + + states = prepare(damaged_text) + # Every case interpretation and pass draws on one complete admission ledger. + frontier = _frontier(states, primary, tuple(state for text in shared_texts for state in prepare(text))) if required_only: + + def required(shape: _StructuralClass) -> bool: + return shape == _FIXED or shape.unit == 4 or shape.distance <= 2 + states = tuple( replace( - state, - counts={ - shape: values - for shape, values in state.counts.items() - if shape == _FIXED or shape.unit == 4 or shape.distance <= 2 - }, + state, counts={shape: values for shape, values in state.counts.items() if required(shape)} ) for state in states ) - frontier = _frontier(states, primary) + frontier = {key: volume for key, volume in frontier.items() if required(key[1])} layers_accounted = [] if capture_layers is None else capture_layers widths = {state.target: 5 * state.degree for state in states} layers_accounted.extend((volume, widths[key[0]]) for key, volume in frontier.items()) diff --git a/tests/test_mixed_case_deadline.py b/tests/test_mixed_case_deadline.py index f812096f..10b05df5 100644 --- a/tests/test_mixed_case_deadline.py +++ b/tests/test_mixed_case_deadline.py @@ -5,8 +5,9 @@ import pytest -from codex32 import Profile +from codex32 import CorrectionContext, MasterSeed, Profile from codex32._cli_input import _case_interpretation, _correction_candidates, _scheduled_candidates +from codex32.correction import _correct_complete SOURCE = "ms10testsxxxxxxxxxxxxxxxxxxxxxxxxxx4nzvca9cmczlw" @@ -81,3 +82,26 @@ def truncated_first(value: str, *_args: object, **kwargs: object) -> tuple[objec assert [(candidate.artifact.text, candidate.search_complete) for candidate in candidates] == [ (SOURCE, False) ] + + +@pytest.mark.parametrize("entry", ("public", "cli")) +def test_case_interpretations_share_one_admission_ledger(entry: str) -> None: + # Thirteen minority-case symbols: alone, the erased reading admits a + # whole-checksum completion; the normalized reading must share that mass. + source = MasterSeed.from_seed(bytes(16), identifier="test").text + damaged = source[:10] + "P" * 13 + source[23:] + deadline = monotonic() + 1 + if entry == "public": + candidates, _complete = _correct_complete( + CorrectionContext(Profile.MS, 48), damaged, deadline=deadline + ) + else: + interpretation = _case_interpretation(damaged, "", (Profile.MS,), None) + assert interpretation is not None + _direct, normalized, erased, _prefix = interpretation + candidates, _complete = _scheduled_candidates( + normalized, erased, Profile.MS, None, "ms1", target=48, deadline=deadline + ) + + for candidate in candidates: + assert candidate.cumulative_capture_volume <= 1 << candidate.capture_space_bits From 43e74a07e39959f5c3ee4952689fa1e492117fe9 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 23:04:00 +0000 Subject: [PATCH 3/5] correct: Keep fixed witnesses in optional pass For mixed-case CLI input, the scheduler runs fixed and required work in discovery passes and then a full pass with optional_only=True. That pass skipped fixed BCH entirely, leaving the per-target fixed-witness map empty. _covered_candidates() reads a missing witness as a failed fixed decode and returns "no possible survivor", so optional layers that could give a known candidate a lower capture volume were pruned. Example: bytes(range(16)) encoded with identifier "test", with positions 11/12 and 23/34 swapped and position 40 capitalized, was reported with capture volume 2,663,936, while the identical lowercase input finds the adjacent-plus-distant swap at 83,248. Recompute each fixed witness in the optional pass as well. It is a single BCH decode per target; results are deduplicated against the seeded candidates, and admission is unchanged. Security: pruning may only drop work that cannot improve rank. A wrong proof could rank a worse explanation first or misstate the reported capture volume shown before the low-discrimination gate. Validation: new regression test (expected volume from independent arithmetic, 2 * 44 * C(44, 2)) fails before and passes after; ruff, mypy, competitor, mixed-case and disclosure tests pass. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- src/codex32/_competitors.py | 4 +++- tests/test_competitors.py | 21 +++++++++++++++++++++ 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/src/codex32/_competitors.py b/src/codex32/_competitors.py index b5895a9b..eac37107 100644 --- a/src/codex32/_competitors.py +++ b/src/codex32/_competitors.py @@ -182,7 +182,9 @@ def _search_competitors( completed: set[_Layer] = set() try: for state in states: - if not optional_only and _FIXED in state.counts: + # Coverage proofs read a missing witness as a failed decode, so the + # optional pass recomputes each fixed witness instead of omitting it. + if _FIXED in state.counts: _check_deadline(deadline) fixed[state.target] = _search_fixed(state, frontier, results, allowed) targets = {state.target: state for state in states} diff --git a/tests/test_competitors.py b/tests/test_competitors.py index 033cce0e..2a518dc6 100644 --- a/tests/test_competitors.py +++ b/tests/test_competitors.py @@ -1,6 +1,7 @@ """CLI competitor scheduling, proof boundaries, and differential scoring.""" from dataclasses import replace +from math import comb from time import monotonic from unittest.mock import patch @@ -8,6 +9,7 @@ from codex32 import CorrectionContext, MasterSeed, Profile, Share, indel from codex32 import _competitors as search +from codex32._cli_input import _case_interpretation, _scheduled_candidates from codex32.bech32 import CHARSET from codex32.correction import _correct_fixed from codex32.profiles.ms32 import TEXT_LENGTHS @@ -342,3 +344,22 @@ def test_pruning_does_not_change_mass_admission(): search._search_competitors((state,), frontier, monotonic() + 10, None) assert frontier == original assert sum(frontier.values()) <= 2**65 + + +def test_mixed_case_optional_pass_keeps_fixed_witnesses(): + damaged = list(SOURCE) + for left, right in ((11, 12), (23, 34)): + damaged[left], damaged[right] = damaged[right], damaged[left] + lowercase = "".join(damaged) + mixed = lowercase[:40] + lowercase[40].upper() + lowercase[41:] + interpretation = _case_interpretation(mixed, "", (Profile.MS,), None) + assert interpretation is not None + _direct, normalized, erased, _prefix = interpretation + assert normalized == lowercase + + candidates, complete = _scheduled_candidates(normalized, erased, Profile.MS, None, "ms1", target=48) + + assert complete + assert [candidate.artifact.text for candidate in candidates] == [SOURCE] + # One adjacent and one distant swap among 45 body characters, ordered two ways. + assert candidates[0].capture_volume == 2 * 44 * comb(44, 2) From 23e613ebb57c7981208e52510774a62cf61b5240 Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 23:05:44 +0000 Subject: [PATCH 4/5] cli: Skip impossible correction target lengths For an opaque HRP the CLI tries every length within -8..+8 of the entered text. Near the short/long checksum boundary some of those targets expand to the forbidden lengths 94 and 95, and _prepare() raised InvalidLength from _checksum_for_encoded_length() before any BCH work ran. A valid 90-character "zz" string with a single substituted checksum symbol therefore failed with "expanded codex32 lengths 94 and 95 are invalid" instead of being corrected. Filter planned targets through the same context validation the public unknown-length path already applies, so unsupported lengths are never searched or charged to the admission ledger. Security: none beyond availability; the remaining targets, ledger and confirmation boundaries are unchanged. Validation: new regression test fails before and passes after; ruff, mypy, CLI, CLI length and generic-HRP tests pass. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- src/codex32/_cli_input.py | 13 +++++++++++-- tests/test_cli_lengths.py | 10 +++++++++- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/src/codex32/_cli_input.py b/src/codex32/_cli_input.py index 67f3b711..304a3ccb 100644 --- a/src/codex32/_cli_input.py +++ b/src/codex32/_cli_input.py @@ -22,7 +22,7 @@ parse_codex32, recover_secret, ) -from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass +from codex32.correction import CorrectionCandidate, CorrectionContext, _best, _capture_mass, _validate_context from codex32.errors import ( CodexError, DuplicateShareIndex, @@ -435,7 +435,16 @@ def _correction_candidates( shared_texts: Sequence[str] = (), ) -> tuple[tuple[CorrectionCandidate, ...], bool, float]: count = len(value.replace(" ", "")) - targets = _correction_plan(profile, byte_length, count, target) + + def supported(length: int) -> bool: + # Like the public unknown-length path, skip lengths no codex32 string can have. + try: + _validate_context(CorrectionContext(profile, length)) + except CodexError: + return False + return True + + targets = tuple(filter(supported, _correction_plan(profile, byte_length, count, target))) deadline = monotonic() + 10 if deadline is None else deadline contexts = tuple(CorrectionContext(profile, length, immutable, excluded) for length in targets) from codex32.indel import _search_many diff --git a/tests/test_cli_lengths.py b/tests/test_cli_lengths.py index 6fa10899..af99612b 100644 --- a/tests/test_cli_lengths.py +++ b/tests/test_cli_lengths.py @@ -3,7 +3,7 @@ import pytest from codex32 import Profile -from codex32._cli_input import InputError, _parse, _prefixed_input_error +from codex32._cli_input import InputError, _parse, _prefixed_input_error, _suggestions from codex32.errors import InvalidChecksum, InvalidLength @@ -45,3 +45,11 @@ def test_reported_invalid_length_vector_in_neutral_entry(): def test_unknown_profile_does_not_replace_checksum_failure_with_a_length_guess(): with pytest.raises(InputError, match="The checksum does not match"): _parse("zz10fauxs" + "x" * 87, (Profile.MS,)) + + +def test_opaque_correction_skips_lengths_inside_the_checksum_gap(): + # 90 characters: nearby targets 92 and 93 would expand to the forbidden 94/95. + source = "zz10tests" + "q" * 68 + "eqq7qmrek3dw3" + damaged = source[:87] + "p" + source[88:] + + assert [candidate.artifact.text for candidate in _suggestions(damaged, "", None, [])] == [source] From 0ba725bce6fbc0618af91eead47d76e7b7f8c35d Mon Sep 17 00:00:00 2001 From: Ben Westgate Date: Wed, 28 Oct 2026 23:06:13 +0000 Subject: [PATCH 5/5] ci: Keep the release token out of the build The build job held contents: write and actions/checkout persisted that token in .git/config while "python -m build --no-isolation" executed repository code (the build backend and project hooks). Anything running during the build could therefore read a token able to modify releases and repository contents. Check out without persisting credentials, drop the build job to contents: read, and move "gh release upload" into a separate attach job. That job runs no repository code: it only downloads the built distributions and receives the token through GH_TOKEN. PyPI publishing still waits for the release attachment, as it did when both steps shared a job; gh is pointed at the repository explicitly because the attach job has no checkout. Security: limits the write-scoped token to a job that executes no project code, closing a path from a compromised build dependency or hook to release tampering. Validation: workflow YAML parses and the job graph and permissions are as intended (build: read, attach: write, publish: read + id-token). Not run on GitHub. Claude-Session: https://claude.ai/code/session_01MDsbGqZmvVThoJrs4vzc4K --- .github/workflows/publish.yml | 26 ++++++++++++++++++++------ 1 file changed, 20 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 7edcac9f..974bcdda 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -11,9 +11,11 @@ jobs: build: runs-on: ubuntu-latest permissions: - contents: write + contents: read steps: - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1 + with: + persist-credentials: false - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 with: python-version: "3.13" @@ -33,18 +35,30 @@ jobs: tar --sort=name --mtime="@${SOURCE_DATE_EPOCH}" --owner=0 --group=0 --numeric-owner \ -C normalized-sdist -cf - "codex32-${GITHUB_REF_NAME#v}" | gzip -n > "$archive.new" mv "$archive.new" "$archive" - - name: Attach distributions to the GitHub release - run: gh release upload "$GITHUB_REF_NAME" dist/* - env: - GH_TOKEN: ${{ github.token }} - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 with: name: distributions path: dist/ - publish: + attach: + # Only this job, which runs no repository code, can write to the release. needs: build runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: distributions + path: dist/ + - name: Attach distributions to the GitHub release + run: gh release upload "$GITHUB_REF_NAME" dist/* --repo "$GITHUB_REPOSITORY" + env: + GH_TOKEN: ${{ github.token }} + + publish: + needs: [build, attach] + runs-on: ubuntu-latest environment: name: pypi url: https://pypi.org/p/codex32