Skip to content

fix(optics): FOV gate width follows lens confidence, plus lens self-heal (ADR 0029) - #624

Merged
brickbots merged 6 commits into
mainfrom
worktree-lens-gate-selfheal
Aug 18, 2026
Merged

fix(optics): FOV gate width follows lens confidence, plus lens self-heal (ADR 0029)#624
brickbots merged 6 commits into
mainfrom
worktree-lens-gate-selfheal

Conversation

@brickbots

@brickbots brickbots commented Aug 17, 2026

Copy link
Copy Markdown
Owner

Some rev4 PiFinders shipped with a 12 mm lens and no camera_lens in their config. 2.6.2's derived FOV gate falls back to the imx462's default_lens_key of 16mm, derives 10.40°, and gates [8.84, 11.96] — against frames that are actually 13.51° wide. Those units stopped solving on update having changed nothing, and the symptom presents as an exposure problem rather than a configuration one.

The pre-0027 window was the fixed 12.0 ± 4.0 = [8.0, 16.0], which happened to span the imx462 at both 12 mm and 16 mm. That is the whole reason these units worked on 2.6.1 and not on 2.6.2.

Note

#625 (lens-menu-restart) has merged. This branch was stacked on it; it is now rebased onto main with #625's commit dropped, and no longer carries it. The rebase was clean and the resulting tree is bit-identical to the pre-rebase branch, so nothing in this PR's content changed.

The fix

A fallback is not a claim, and it should never have bought a claim's confidence. Gate width now follows how the lens was obtained:

the lens is… gate
stated — config names a lens we recognise ±15% of that lens's field of view, exactly as ADR 0027 had it
assumed — nobody has said the union of every lens that sensor has shipped with
sensor shipped lenses assumed gate stated-16mm gate
imx462 / imx290 12 mm (13.51°), 16 mm (10.40°) 12.19 ± 3.35 = [8.84, 15.53] [8.84, 11.96]
imx296 12 mm (17.78°), 16 mm (13.71°) 16.05 ± 4.39 = [11.65, 20.44] [11.65, 15.77]
hq 25 mm (10.33°) [8.78, 11.88] — unchanged n/a

The imx462's assumed gate lands within a whisker of the pre-0027 constants it replaces, which is the behaviour these units are known to work under.

Then self-heal closes the assumption. A successful solve reports the fitted FOV and the sensor is already known, so what is left is the lens. Three consecutive identifications agreeing to within 5% promote the assumption to a statement, and the gate narrows onto it. It:

  • never overwrites a stated lens — the user's claim stays authoritative, so ADR 0027's "a mis-stated lens means no solves" is untouched, and the write happens at most once per device;
  • writes nothing when the measurement matches no shipped lens — third-party glass keeps the wide gate, which is honest;
  • skips replayed solves — a recorded session measures whatever device recorded it;
  • can never take the integrator down — a read-only filesystem or a dropped manager connection costs the promotion, not the pointing.

Why a union rather than dropping the hint

Measured against the real solver and real frames before committing to this: widening costs nothing on speed. ±15%, ±30%, ±50% and the pre-0027 fixed window all solve in 1.1–1.4 ms, and even no hint at all costs only +0.3 ms; Prob was bit-identical across every width.

But the gate does buy mis-solve rejection. With injected noise, wide and hintless gates returned confident false solves at 20–23° that match_threshold did not reject. The assumed gate still excludes everything above 15.53°, so it keeps that protection — omitting fov_estimate would give it up. (Single-shot noise trials: direction established, magnitude not.)

And the cost is wildly asymmetric. A good frame outside the gate burns the entire solve_timeout and returns nothing, every frame, forever. Against a wide gate's ~0.3 ms, an over-tight gate is the expensive failure by three orders of magnitude.

Rejected

  • A rev4 → 12 mm config migration. Rev4 reports as imx462 and so do some v3 units, so the sensor is not the predicate. The board is probeable, but board revision is not the fact we need — which lens was in the box is, and not all rev4s shipped with the 12 mm. It would fix most units by breaking the rest, and break them in exactly the way this PR exists to fix.
  • camera_lens in default_config.json. Config.get_option consults the default file before resolve_lens ever sees a None, so hq would derive 17.12° instead of its true 10.33°.
  • Raising FOV_GATE_MARGIN globally. Spends 0027's entire benefit on every correctly-configured user to cover a case they are not in.

Full reasoning and the numbers: docs/adr/0029-fov-gate-width-follows-lens-confidence.md.

The hq must not move

The hq only ever shipped one lens, so it has no union to take. That case returns early rather than falling through the union arithmetic — which re-derives the same window through different float operations and lands 1 ULP off the value that has been shipping. test_the_hq_gate_is_bit_identical_to_what_shipped asserts the exact floats, not approx.

Verification

  • 1,285 unit + smoke tests pass, 0 failures. Ruff lint + format clean, mypy clean over 151 files.
  • 115 new test functions across test_optics.py (63) and the new test_lens_self_heal.py (19), plus the existing suites.
  • Sphinx builds with zero warnings (-n -q), and the new prose carries no em-dashes or semicolons.
  • Self-heal verified end to end on the running app (--camera debug), which exercises the write path through the integrator wiring: Lens self-heal: 3 solves fitted 10.20 deg, which is the 25mm (10.33 deg derived). The dev config was backed up and restored to its original checksum afterwards.

Warning

The debug camera declares hq, which has one shipped lens, so its assumed gate is identical to its stated one. The debug run cannot exercise the widening at all — the imx462 12mm-vs-16mm case, which is the entire point of the change, is only reachable in unit tests or on real hardware. A green debug run is not evidence the fix works on an affected unit.

Known limitation, not fixed here

After self-heal writes camera_lens, the UI process's Config still holds the dict it loaded at boot. Any later set_option from the menu rewrites config.json from that stale dict and drops the self-healed key. This is self-correcting — the device re-heals after three solves on the next boot, and shared_state carries the correct lens for the whole session, so solving is never affected. It is also pre-existing: camera_interface.py already writes config from a non-UI process with the same exposure. Worth a follow-up issue rather than a wider change to Config in this PR.

Docs

Closes #613. Related: #610, #611, #612.

🤖 Generated with Claude Code

@brickbots
brickbots force-pushed the worktree-lens-gate-selfheal branch from ac7f4ef to adc3b44 Compare August 17, 2026 21:30
@brickbots brickbots changed the title docs: FOV gate width follows lens confidence (ADR 0029) fix(optics): FOV gate width follows lens confidence, plus lens self-heal (ADR 0029) Aug 17, 2026
@brickbots
brickbots marked this pull request as ready for review August 17, 2026 21:31
@brickbots

Copy link
Copy Markdown
Owner Author

Reviewed this branch. Verified the PR's own claims independently: 1,285 unit + smoke tests pass, ruff check/format clean, mypy clean on the changed modules, and sphinx -n -q builds with zero warnings. The stated/assumed split and the union arithmetic both check out against the ADR table, and the hq early-return with the bit-identical float assertion is the right call.

One defect found and pushed as 5514a39 (now 1,286 passing):

The Lens menu showed the wrong lens after self-heal, and confirming it was unrecoverable.

get_camera_lens resolved the sensor half from shared_state but the lens half from config_object. The UI process loads its Config at boot and reloads only on an explicit reload_config, so after self-heal writes from the integrator the menu still reported the assumed lens. Confirmed against this branch:

after self-heal, on disk : 12mm
Lens menu displays       : 16mm   (shared_state says 12mm)

Not only cosmetic: text_menu writes the highlighted entry on select, so a user confirming that stale 16mm on a healed 12mm unit states the wrong lens, tightens the gate to [8.84, 11.96] against 13.51° frames, and stops solving permanently — and self-heal can't recover it, since it only writes into an absence. The troubleshooting.rst added by this PR sends a user with no solves to exactly that screen.

The fix reads shared_state.camera_lens() instead. Shared state is seeded from config at construction and republished by both set_camera_lens and self-heal, and nothing else writes camera_lens (server.py never touches it), so it is strictly the more current view. Added test_shows_the_self_healed_lens_not_this_process_stale_config, which fails on the old code with ['16mm'] != ['12mm'].

Filed #626 for the general form of the staleness — dump_config rewrites the whole file from a per-process dict, so a cross-process write is dropped by the next UI write. Worth reading before merge for one reason: it means "it writes at most once per device" is not accurate as currently written in ADR 0029 and release_notes/2.6.2.md. A device with an unstated lens re-heals every boot. Harmless (shared state carries the right lens all session) but the claim should be softened. server.py already has the mechanism to fix it properly — ui_queue.put("reload_config") — the integrator just isn't passed ui_queue.

Smaller points, not pushed:

  • Self-heal will write a lens identified against a fallback camera profile. resolve_camera_profile silently falls back to imx296 for an unknown sensor, and test_an_unrecognised_sensor_does_not_raise asserts that as desired. If the sensor isn't really an imx296 the identified lens is wrong, and the write is unrecoverable — which contradicts the ADR's own "a wrong write is worse than no write". Near-unreachable in practice (camera_interface sets camera_type before the first capture), but the guard is one line: skip when camera_type not in CAMERA_PROFILES.
  • The imx296 assumed gate reaches 20.44°, inside the 20–23° band where the ADR reports wide gates returned confident false solves. The "still excludes everything above 15.53°" argument — and test_the_assumed_gate_still_rejects_a_wild_field_of_view — are imx462-specific. Worth a sentence in the ADR, or an imx296 bound in the test.
  • except (OSError, AttributeError, ValueError) is narrower than the docstring's promise: a dropped manager connection usually surfaces as EOFError, which isn't an OSError. And catching AttributeError will silently disable self-heal on a rename rather than failing loudly. A plain except Exception with the same disable-and-warn matches the stated intent and swallows nothing more.
  • No test pins LENS_IDENTIFY_TOLERANCE < FOV_GATE_MARGIN, which is what guarantees a mis-identification still solves inside the gate the write produces. Load-bearing, one line.
  • LensSelfHeal.__init__ is untyped, so mypy skips its body (integrator.py:116: note: bodies of untyped functions are not checked). _write(..., profile) likewise.
  • docs/source/menu_map.rst adds an em-dash ("what is fitted — and naming the wrong one"), which the published-docs house style replaces; the PR body says the new prose carries none. ADR/CONTEXT em-dashes are fine. BOM.rst's addition also ends without a full stop and writes 12mm where the release notes use 12 mm.

Nothing else blocking from me.

brickbots and others added 6 commits August 17, 2026 18:02
Some rev4 units shipped with a 12mm lens and no camera_lens in config.
resolve_lens falls back to the imx462's default_lens_key of 16mm, so the
device derives 10.40 deg and gates [8.84, 11.96] against a true field of
13.51 deg -- every frame rejected before verification. Those units stopped
solving on update having changed nothing. The pre-0027 window was the fixed
12.0 +/- 4.0, which spanned both lenses; that is why 2.6.1 worked.

ADR 0027 treated the camera profile's fallback lens as knowledge. It isn't.
Record the decision to make gate width proportional to what is actually known
about the train, and to let the fitted FOV promote an assumption to a
statement.

Adds ADR 0029 and the vocabulary the change turns on: stated lens vs assumed
lens (camera CONTEXT.md), and the gate-width and self-heal roles in
positioning CONTEXT.md. Also corrects the camera glossary's claim that a
wrong lens is "a configuration error the device cannot discover for itself"
-- positioning CONTEXT.md already said the fitted FOV diagnoses exactly that,
and the two entries contradicted each other.

Docs and plan only; no behaviour change yet. Implementation checklist in
lens-gate-confidence-plan.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
Measured against the real solver and real frames rather than assumed. On
speed the narrow gate buys nothing: +-15%, +-30%, +-50% and the pre-0027
fixed window all solve in 1.1-1.4 ms, and even omitting the hint entirely
costs only +0.3 ms, with Prob bit-identical across widths.

It does buy mis-solve rejection, which is why the assumed gate is a union
rather than no hint at all. With injected noise, wide and no-hint gates
returned confident false solves at 20-23 deg that match_threshold did not
reject; the gate's upper bound is what catches those, and [8.84, 15.53]
keeps it. Also records the asymmetry that settles the trade: a good frame
outside the gate burns the whole solve_timeout every frame, forever, which
is three orders of magnitude worse than a wide gate's 0.3 ms.

Adds an implementation note on tetra3's _pattern_cache, which is keyed on
hash_index alone but stores the FOV-pruned result and is never cleared.
Self-heal moves wide -> tight, the safe direction (cached entries are a
superset and the post-fit rejection still applies). A Lens menu change moves
tight -> different-tight and may not take effect until solver restart --
pre-existing in 2.6.2, not introduced here, but it will mislead anyone
testing this change by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
The plan told the implementer to file it separately. It is filed and fixed:
callbacks.set_camera_lens now restarts, as the neighbouring PiFinder Type and
Camera Type settings do. The upstream tetra3 cache bug remains out of scope.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012sYi5W1u5HsZCTKeUYHPxa
Some rev4 units shipped with a 12mm lens and no camera_lens in config.
resolve_lens fell back to the imx462's 16mm, so 2.6.2 derived 10.40 deg
and gated [8.84, 11.96] against frames that are actually 13.51 deg wide.
Those units stopped solving on update having changed nothing.

The fallback was never a claim, so the gate should never have narrowed
around it. Gate width now follows how the lens was obtained:

* stated lens (config names a registered key) -> +/-15%, as 0027 had it
* assumed lens -> the union of every lens that sensor shipped with

CameraProfile gains shipped_lens_keys; default_lens_key keeps its value
but a narrower job -- it is now specifically the lens to *assume*.

LensSelfHeal (integrator) then closes the assumption: a successful solve
reports the fitted FOV, the sensor is known, so what is left is the lens.
Three consecutive identifications agreeing to within 5% promote the
assumption to a statement and the gate narrows onto it. It never
overwrites a stated lens, writes nothing when the measurement matches no
shipped lens, skips replayed solves, and can never take the integrator
down -- a failed promotion costs the promotion, not the pointing.

The hq only ever shipped one lens, so it has no union to take. That case
returns early rather than falling through the union arithmetic, which
would re-derive the same window and land a float ULP off the value that
has been shipping; its gate is asserted bit-identical.

Also: the solver's "Optical train:" line now says stated or assumed, and
api_extensions' frustum comment says "derived" -- the glossary reserves
"fitted" for what tetra3 measures.

Docs: release notes, troubleshooting.rst (#613) and BOM.rst's
imx462-specific "10deg FOV" line (#613).

Implements docs/adr/0029-fov-gate-width-follows-lens-confidence.md.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
lens-gate-confidence-plan.md was a working checklist for this change and
the work it describes is now done. The decisions it summarised live in
ADR 0029, the vocabulary in the camera and positioning CONTEXT.md files,
and the code and tests are on this branch. Keeping it would land a
stale to-do list on main, which is also why the project's other planning
docs stay untracked.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…nfig

`get_camera_lens` resolved the sensor half from `shared_state` but the lens
half from `config_object`. The UI process loads its `Config` at boot and
reloads it only on an explicit `reload_config`, so once self-heal writes a
lens from the integrator, this process still reports the *assumed* lens.

The menu therefore showed 16mm on a rev4 that had just measured itself as a
12mm. That is not only a display fault: `text_menu` writes the highlighted
entry on select, so confirming the stale value states a lens the device
measured itself out of, tightens the FOV gate around it, and stops solving
for good -- self-heal cannot undo it, because it only ever writes into an
absence. `troubleshooting.rst` sends a user with no solves to exactly this
screen.

`shared_state` is seeded from config when it is built and republished by both
`set_camera_lens` and self-heal, and nothing else writes `camera_lens`, so it
is strictly the more current of the two views.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@brickbots
brickbots force-pushed the worktree-lens-gate-selfheal branch from 5514a39 to 6875eab Compare August 18, 2026 01:03
@brickbots
brickbots merged commit e499621 into main Aug 18, 2026
4 of 5 checks passed
brickbots added a commit to mrosseel/PiFinder that referenced this pull request Aug 18, 2026
0029 was already taken by 0029-fov-gate-width-follows-lens-confidence.md,
merged to main in brickbots#624 after this branch was cut. That ADR keeps the number:
it is referenced from seven places in shipped code (optics.py, integrator.py,
camera_profiles.py), against three here, all of them inside this branch.

Updates the three references: two comments in nearby.py and the test_nearby.py
module docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
brickbots added a commit that referenced this pull request Aug 19, 2026
…622)

* fix(nearby): rank against true angular distance, in a bounded window

The BallTree behind both the object-list Nearby sort and the chart's
nearby-DSO markers was built and queried as [ra, dec] against sklearn's
haversine metric, which reads dimension 0 as latitude. Separations came out
right only between objects sharing a meridian, and worse towards the poles.
Index and query as [dec, ra].

Rank a bounded window (NEAREST_LIST_CAP = 200) rather than ordering the whole
catalog to draw nine rows. This bounds the k-NN query and the cursor-tracking
helper, which together were the per-frame cost during a slew.

Count what the carousel actually navigates. UIObjectList draws, scrolls, opens
and serialises _menu_items_sorted, so get_nr_of_menu_items() now measures that
list rather than the source; otherwise long-DOWN parks the cursor past the end
of the ranked window and opening that row raises IndexError. The catalog's own
object count stays the source length, reported in catalog_info_1.

Trigger the re-rank on great-circle separation rather than per-axis RA/Dec
degrees, and raise MAX_TIME to 10 s -- that trigger is for catalog and filter
changes, not pointing.

In the Nearby sort, hold the cursor on the top row while the user has not
scrolled, so the focused object tracks the pointing; pin it to the selected
object once they scroll off the top.

Also: implement SortOrder.RA and route both sort labels through one helper,
drop the redundant pre-sort nearby_refresh() in mm_change_sort, make the
unreachable 'No Solve Yet' message reachable, cache the spatial index on the
filter's dirty_time as UIChart already does, skip the scrollbar when the list
is empty, and hold off the in-frame re-rank until the index is built.

Tests place objects off a shared meridian and at high declination -- a
same-meridian check cannot observe the axis order at all. A UI regression test
drives long-DOWN then RIGHT over a catalog larger than the cap.

See docs/adr/0029-nearby-ranking-correctness-and-cost.md

* docs(adr): renumber the nearby-ranking ADR 0029 -> 0030

0029 was already taken by 0029-fov-gate-width-follows-lens-confidence.md,
merged to main in #624 after this branch was cut. That ADR keeps the number:
it is referenced from seven places in shipped code (optics.py, integrator.py,
camera_profiles.py), against three here, all of them inside this branch.

Updates the three references: two comments in nearby.py and the test_nearby.py
module docstring.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(object details): serialise a Nearby-sorted object_list without raising

serialize_ui_state tested self.object_list for truthiness to compute
object_list_length. A Nearby-sorted list is the NumPy object array
get_closest_objects returns, and bool() on a multi-element array raises
"truth value of an array with more than one element is ambiguous".

The raise landed in the method's own except clause, so it was never visible
as a crash -- it just made every object opened from a Nearby-sorted list
serialise as {"error": ...} instead of state, silently blanking the remote
web interface's object-details view for that whole path.

Tests the real path rather than a hand-built array: sorts the object list by
NEAREST and hands the resulting _menu_items_sorted to UIObjectDetails the way
show_object_details does. The test fails on the previous line with exactly the
ambiguous-truth-value error.

Pre-existing, but adjacent to this branch's work on which list the screen
addresses, and a one-line fix.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Richard <rich@brickbots.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
brickbots added a commit that referenced this pull request Aug 25, 2026
Merging main in brought `0030-nearby-ranking-correctness-and-cost.md`
alongside this branch's `0030-equipment-measurements-are-validated-floats.md`.
Two different files, so git merged them without a conflict, and a bare
"ADR 0030" in the tree now pointed at either one.

Standing rule: the most-referenced file keeps the contested number. The
nearby ADR has landed on main and is cited from nearby.py (x2),
test_nearby.py, ADR 0031 and the 2.6.3 notes, against three references
for this one — so it keeps 0030 and this moves.

0033 is the lowest slot free across every branch and open PR (0031 is
main's chart readout, 0032 is claimed by #635). Three inbound references
updated, plus the now-doubly-stale earmark in the 2.6.2 test plan, which
had reserved 0029 for this ADR before #624 and #622 took 0029 and 0030.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ShwenqLLrgHTRsrM1UyfZa
brickbots added a commit that referenced this pull request Aug 25, 2026
#571)

* fix(web): validate equipment and GPS form input at the API, not just the browser

The equipment handlers parsed with bare float()/int() inside
`except Exception: logger.error(...)` and then rendered the success
template regardless, so an unreadable value reported "Eyepiece added"
and saved nothing:

    POST /equipment/add_eyepiece/-1  focal_length_mm=7,5
      -> HTTP 200 + "Eyepiece added, restart your PiFinder to use"
      -> eyepiece count unchanged

/gps/update had no try/except at all, so the same input reached the user
as an unhandled 500. A decimal comma is the easiest trigger — PiFinder
ships de/es/fr/zh — but any unparseable value did it, including the
blank instrument name from #569.

Field rules now live in one table in equipment.py: the edit forms render
them into their client-side check and the API re-checks them before
anything reaches config. Measurements are floats throughout, so a 279.4mm
aperture is enterable and a config carrying one is loadable (#291); whole
millimetres still display as "1000", not "1000.0", via format_measurement.

- equipment: measurements are validated floats; limits + name length live
  beside the dataclasses (ADR 0027)
- server: parse_measurement/parse_name/*_from_form; failures re-render the
  edit form with the message and the values that were typed; route indexes
  are range-checked instead of raising IndexError as a 500; the DeepskyLog
  import skips records it can't read rather than writing them through
- gps: parse everything before locking anything, so a bad clock entry
  can't half-apply a position; gps.html gets #536's normalizeDecimal,
  which never reached it, and locations.html's two decimal->DMS bypasses
  are fixed
- config: an undecodable equipment section logs and falls back to the
  defaults instead of aborting main() before the UI comes up (#291)

Covered at the request level (the Selenium suite runs en-US and
structurally can't catch a decimal-comma bug): 68 new tests, and the
equipment + locations web suites still pass against a live PiFinder.

Fixes #569

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: renumber the equipment-validation ADR to 0030

0027 was already taken twice over. #608/#606 collided on it and were
resolved by #616 (FOV gate keeps 0027, tracked black level moved to
0028); this branch, cut before that, carried a third 0027. Under the
standing tiebreak the most-referenced ADR keeps the number, and the FOV
gate wins by a wide margin -- CONTEXT-MAP.md, docs/ax/sqm.md,
docs/ax/positioning.md, positioning/CONTEXT.md, ADR 0029 and two test
modules all point at it, against three references here.

The 2.6.2 test plan (P1.4) earmarked 0029 for this branch, but 0029 was
taken by the lens-confidence ADR in the meantime, so this takes 0030 --
the lowest free number.

Three inbound references updated. No content change; ADR titles in this
repo carry no number, so the rename is the whole of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs: renumber the equipment-validation ADR to 0033

Merging main in brought `0030-nearby-ranking-correctness-and-cost.md`
alongside this branch's `0030-equipment-measurements-are-validated-floats.md`.
Two different files, so git merged them without a conflict, and a bare
"ADR 0030" in the tree now pointed at either one.

Standing rule: the most-referenced file keeps the contested number. The
nearby ADR has landed on main and is cited from nearby.py (x2),
test_nearby.py, ADR 0031 and the 2.6.3 notes, against three references
for this one — so it keeps 0030 and this moves.

0033 is the lowest slot free across every branch and open PR (0031 is
main's chart readout, 0032 is claimed by #635). Three inbound references
updated, plus the now-doubly-stale earmark in the 2.6.2 test plan, which
had reserved 0029 for this ADR before #624 and #622 took 0029 and 0030.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant