Skip to content

0.5.228: a re-open never supersedes a key — the owner's fed-ID opens from the tier its files are in; a stranded key is restored (#783) - #784

Merged
emooreatx merged 5 commits into
mainfrom
repro/platform-sealed-reopen-archives-the-key
Oct 16, 2026
Merged

emooreatx merged 5 commits into
mainfrom
repro/platform-sealed-reopen-archives-the-key

Conversation

@emooreatx

Copy link
Copy Markdown
Contributor

Fixes the "cannot sign as the user" half of #783, found live on Eric's desktop (agent 2.16.1 / server 0.5.227).

Root cause

Eric's owner fed-ID mooreericnyc-ubnr7f7uxe was minted platform-sealed while the TPM was not usable, so ciris-keyring stored it in its software tier. On 2026-10-08 16:54:58Z the TPM plugin became loadable in-process. The next re-open went get_user_identity_signer → SealedEd25519Signer::open_existing → create_platform_storage → PluginTpmSecureBlobStorage::new, which minted a fresh TPM master, counted that as "the higher-tier mint landed", archived the live software key to *.superseded-pre-v8, and then loaded ed25519.seed from the empty TPM tier: KeyNotFound. Every owner act since has fallen to the machine path, which is the consent loop, the missing membership widener and, very likely, the node never being Rooted.

Fix

  • src/key_tier.rs: the files decide the tier. A software master with no plugin seal opens as software; a plugin seal opens through the factory, which unseals and never mints; only an alias with no artifacts mints. Every sealed re-open and open-or-create goes through it: the user Ed25519 key, all four ML-DSA-65 re-opens, the node and substrate keys at every boot, and the legacy node probe.
  • Heal: an alias left exactly as the bug leaves it (a seal holding no blob beside a superseded software pair) is restored. The files are renamed back, the empty seal is kept aside as *.orphaned-<unix>, nothing is deleted, and a loud WARN is logged.
  • Embedded provisioning registers the owner seed dir before compose, so pre-serve owner acts find the pen.

Proof

tests/platform_sealed_reopen_keeps_the_key.rs, run with a real TPM:

  • the server re-open keeps a software key and opens both halves with the same public keys;
  • Eric's exact on-disk shape heals and opens the original keys;
  • a TPM tier that holds a key is never overridden;
  • a canary pins that the keyring itself still supersedes (it flips when upstream fixes it).

An operator probe on a copy of Eric's files healed it and derived exactly mooreericnyc-ubnr7f7uxe; the ML-DSA half opened in place with nothing archived; the copy was deleted. Nine identity and key suites are green locally. Upstream: CIRISVerify issue linked below.

Opens 0.5.228. Runs under ci:full.

🤖 Generated with Claude Code

https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

…opens from the tier its files are in, and a key stranded by the keyring is restored (#783)

Eric's desktop (agent 2.16.1 / server 0.5.227): every owner act fell to the
machine path — "registered no user seed dir", "consent re-sign as owner failed:
open the user-identity signing key (platform-sealed): Key not found:
ed25519.seed", membership widener not installed, consent grants looping.

Root cause, from his node log and the keyring source: his owner fed-ID
mooreericnyc-ubnr7f7uxe was minted `platform-sealed` while the TPM was not
usable, so ciris-keyring degraded to SOFTWARE storage ({alias}.master.key +
{alias}.ed25519.seed.blob). On 2026-10-08 16:54:58Z the TPM plugin became
loadable in-process (libciris_tpm_plugin.so ships in ciris_verify; the user is in
tss). The next RE-OPEN (get_user_identity_signer → SealedEd25519Signer::
open_existing → create_platform_storage → PluginTpmSecureBlobStorage::new) found
no seal, minted a fresh TPM master, witnessed THAT as "the higher-tier mint
landed", and archived the live software key to *.superseded-pre-v8 — then loaded
ed25519.seed from the empty TPM tier: KeyNotFound, forever. 19 s earlier the same
key had signed his age record.

Fix (server-side, no upstream wait):
- src/key_tier.rs: the files decide the tier. A software master with no plugin
  seal opens as software, directly; a plugin seal opens through the factory (it
  unseals, never mints); only an alias with NO artifacts mints. Every sealed
  re-open and open-or-create in the server goes through it: the user Ed25519
  re-open, all four ML-DSA-65 re-opens (the owner's PQC half was next in line —
  still software, one successful Ed25519 open away from the same supersession),
  the node/substrate open-or-create at every boot, and the legacy node probe.
- The heal: an alias exactly as the bug leaves it — a plugin seal holding NO
  tpmp_blob beside a superseded software pair — is restored (archived files back
  under their names, the empty seal renamed *.orphaned-<unix>, nothing deleted,
  a loud WARN). The TPM tier held nothing, so the archive is the only key.
- node_key::provision_node_identity (the embedded node's first birth, before
  compose) registers the owner seed dir, so the agent's pre-serve owner acts
  find the pen.

Proof: tests/platform_sealed_reopen_keeps_the_key.rs, run with a real TPM —
the server re-open keeps a software key and opens both halves with the same
public keys; Eric's exact on-disk shape heals and opens the ORIGINAL keys; a
TPM tier that holds a key is never overridden; a canary pins that the keyring
itself still supersedes (it flips when upstream fixes). Operator probe on a COPY
of Eric's files: healed, derived key id == mooreericnyc-ubnr7f7uxe, ML-DSA opened
in place with nothing archived (copy deleted). Identity/key suites green:
claim_remote, consent_survives_the_key_split, node_key_is_not_the_actor_key,
owner_signer_capsule, portable_occurrence, self_identity_fold, the_sealed_half_
moves_into_this_home, split_node_authors_consent_as_itself, owner_is_the_identity.

Opens 0.5.228 (Cargo.toml, Cargo.lock, evidence TSV version column).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
@emooreatx emooreatx added the ci:full Run the full CI matrix (macOS + Windows) on this PR label Oct 16, 2026
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

…keys live there; the real-wire proof (#783)

Building the regression scenario found the trap is wider than the owner key:
CIRISEdge opens the node key at edge init and persist's keyring signer opens
identity/keyring/ — both through create_platform_storage, both outside the
server's routing. On v0.5.227, restarting a node after the TPM plugin becomes
loadable logged SIX supersessions (the substrate key, the transport-identity
keystore → 'generated fresh RNS transport identity', the keyring signer) and the
node failed to boot.

pin_process_tier(home): a home whose keys are all software-tier (a
*.master.key, no *.tpmp_blob) gets CIRIS_TPM_PLUGIN pointed at a sentinel BEFORE
any component opens a key, so no component can select the TPM tier and
supersede them; a fresh home keeps TPM custody for new keys; a home with live
TPM keys is untouched; a software→TPM move stays an explicit act. Called at
serve start (cfg.home), at PyO3 import (CIRIS_HOME, else ~/ciris — the agent
builds its Engine before any serve names a home) and at embedded provisioning.
identity create's re-open goes through key_tier too (its PQC half is sealed in
verify-core's create_federation_identity, which has the same hazard — noted on
CIRISVerify#311).

harness/native owner_key_tpm_appears (needs CIRIS_TPM_PLUGIN_REAL + a TPM):
claim --backend platform-sealed with the plugin invisible (software tier),
restart with it visible, the node opens the owner pen itself (membership
widener installed) and nothing is archived; then Eric's exact shape heals and
the pen works. This branch: PASS, 0 supersessions. v0.5.227: 6 supersessions,
boot failure. Suites: key_tier 4/4, node_key_is_not_the_actor_key 19,
claim_remote 5, owner_signer_capsule 6, portable_occurrence 2,
self_identity_fold 14, the_sealed_half_moves_into_this_home 4,
consent_survives_the_key_split 6; lib 630/630.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
emooreatx and others added 2 commits October 16, 2026 12:52
…oid Keystore) goes to the keyring's own load-only open — macOS CI red

PR #784's macos-latest: identity::tests mint_*_user_identity and
minted_identity_composes_a_usable_claim_remote_signer failed 're-open sealed
ML-DSA-65 half: Key not found: mldsa65.seed'. On macOS the keyring seals in
the Secure Enclave / keychain tier, which writes neither a plugin seal nor a
software master, so on_disk_tier read Absent and key_tier answered KeyNotFound
itself. Absent now delegates to SealedEd25519Signer / SealedMlDsa65Signer::
open_existing (load-only, unchanged behaviour on those tiers); only a recognised
SOFTWARE key bypasses the factory. identity::tests 6/6, key_tier 4/4 locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
…e — macOS CI red on #784

Without a TPM the canary asserted the keyring's OWN re-open of a software-tier
key succeeds. On macOS the platform factory selects the keychain / Secure Enclave
tier and cannot see software-tier files (KeyNotFound) — the same stranding by
another route, which key_tier already handles (the_servers_reopen_keeps_a_
software_tier_key… covers the server path on every platform). The no-TPM branch
now reports and returns; noted on CIRISVerify#311.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
emooreatx added a commit that referenced this pull request Oct 16, 2026
…784), the contacts batch (#785)

Cargo.toml names the next tag (v0.5.227 is on 21869a6); the evidence
rows follow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
@emooreatx
emooreatx merged commit 5eedb2a into main Oct 16, 2026
21 checks passed
emooreatx added a commit that referenced this pull request Oct 16, 2026
…ain red on log_citations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
emooreatx added a commit that referenced this pull request Oct 16, 2026
…e warnings cite it as tracked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb
emooreatx added a commit that referenced this pull request Oct 16, 2026
…omplete on their own (#777), plain refusals + pending logged as pending (#776, #780), scorer skips its own agent (#781), history-vs-tracked citations (#782), real-wire e2e (#777/#778/#779) (#785)

* contacts: infrastructure and the owner's own devices are never listed as contacts (#775)

Sharing traces with a canonical emits a consent:replication grant under the
default prefixes, which include chat:, so every node that shares traces listed
the canonical as a person (Eric's and Francesco's installs, 2026-10-16); a grant
naming the owner's own client key or second node listed the owner as their own
contact. list_contacts now skips a subject that is a canonical server
(persist's is_canonical, or a directory row naming the canonical role) and a
subject that resolves to the owner's fed-ID; the skip is logged at DEBUG.

E2E: tests/contacts_chat.rs::a_node_that_consents_to_send_traces_lists_no_
infrastructure_and_no_self_as_contacts — default-prefix grants to the baked
canonical and to the owner's identity list ZERO contacts; a person added beside
them is listed alone. Proven red with the skips disabled. contacts_chat 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* refusal: log each typed answer by its class — a 202 is 'pending', not 'auth refused' (#780)

refuse/refuse_with logged every answer as 'auth refused: …' at INFO, including
the typed NOT-YET 202s (contacts.awaiting_key_body, awaiting_consent,
unreachable); Eric and the agent session read the pending contact as a failure.
Now the line says pending / refused / unavailable with the status, and a 5xx
logs at WARN. Unit-tested classifier.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* contacts: a v3 contact completes on its own when the key arrives (#777); refusal operator detail moves to the log (#776, server half)

#777 — POST /v1/contacts with a code whose key body this node lacks answered 202
contacts.awaiting_key_body with converges_on_its_own: true, yet nothing ever
finished the contact: the person had to paste the code again. The add path is
now add_contact_for_owner (the handler authorizes, then calls it), and
pending_contacts holds the owner's intent (pasted code, owner authorization of
that request) and re-runs the SAME path every 10 s once the key is in the
directory: a match completes (grant emitted, chat opens), a refusal discards
with the reason logged, 24 h expiry. In memory: a restart drops pending entries
and the person can paste again. The e2e (two nodes, real wire) is the native
harness scenario that follows.

#776 — contact_stall_refusal now logs the operator detail (rungs, announce,
admission, directory convergence) under the reason_id and answers the person
in plain language where the server's text is what they see. The text a person
actually reads comes from the CLIENT bundle by reason_id (Eric's 'Admit the key
first (peering)… bookmark' is CIRISClient's en.json for contacts.unknown_fed_id),
and the localization guard pins the server's fallback to that bundle for the
literal ids, so those stay byte-identical until CIRISClient ships the rewrite
(CIRISClient#188). Test: refusal_copy_tests::contact_refusals_speak_plainly_to_
people over every LadderStall. contacts_chat 36/36; localization guard passes.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* contacts: pending_contacts clippy — drop the unused len(), name the key/due types

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* scorer: a node never scores the agent it hosts — skipped by rule before the work, counted, named (#781)

On an agent-hosted node the engine signs as the actor, so the hosted agent's
traces carry a key this process wields. The scorer computed the score and only
then hit CapacityAttestation::new's self-attestation guard, so every pass on
Eric's desktop ended in 'capacity attestation violates CEG §7.5 anti-Goodhart'.
Now an agent whose key is the attester or this process's actor identity is
skipped BEFORE scoring (DEBUG line naming the rule anti_goodhart_self), counted
as self_agents in every pass summary, and accounts for a zero pass instead of
raising 'the agents do not account for it'.

Test: capacity_scorer::the_agent_this_node_hosts_is_skipped_by_rule_not_erred —
attester = the hosted agent's key: Ok(0), the guard is never reached, the rule is
named, no zero-pass alarm. Proven red with the skip disabled. capacity_scorer 5/5.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* logs: every issue cited in a WARN/ERROR says whether it is history or still tracked (#782)

Eric and the agent session read steady-state warnings that cite issue numbers
as live bugs; every one quoted had closed months earlier. In this crate's
tracing::warn!/error! messages an issue citation now reads 'history: X#N'
(closed — why the gate exists) or 'tracked: X#N' (open — work still owed): 45
distinct citations across 29 files, six of them tracked (CIRISServer#372,
#601, #602, #630, #678, CIRISNodeCore#38).

Gate (offline): tests/log_citations.rs fails on any bare citation in a
warn!/error! string. State check (network, run before a cut):
tools/check_log_citations.py — tracked must be OPEN, history CLOSED/MERGED;
45 checked, 0 mismatched. lib 632/632.

The lines Eric actually saw repeating (item-2 drops, unattributed frames,
CRPL source_key_id=None, no-route) are emitted by CIRISEdge — filed there.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* harness: contact_edges — add by fed ID before and after convergence (#778), add by code before the key is held completes on its own (#777), on the real wire

Native-harness scenario modelling production: each person's node joins the mesh
the way an agent does (consent to replicate to the canonical — without it the
first run reproduced Eric's symptom exactly: the key never reached the canonical,
'receive-axis pull found NO PEERS'). #778: an unknown fed ID is a 404
contacts.unknown_fed_id; after bob announces, the SAME add succeeds (150 s on
this box), bob is listed, POST /v1/chat opens. #777: alice pastes carol's v3 code
before holding carol's key → 202 contacts.awaiting_key_body → with no second
paste carol is listed ~30 s later and the chat opens. PASS. Proven red with the
pending completer disabled: the chat answers 403 chat.not_a_contact — Eric's
symptom.

    python -m harness.native contact_edges --binary target/debug/ciris-server --owner-wait 240

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* harness: fresh_claim — a freshly claimed laptop node admits its owner rows at the canonical with no replication storm (#779 guard)

Canonical + one laptop-class node claimed, announced and joined to the mesh as
an agent joins (consent to the canonical). Asserts the canonical holds the
owner's key and owner-signed rows within the window, and counts the storm
signatures from Eric's 0.5.223 log (item-2 drops, source_key_id=None CRPL drops,
round timeouts, occurrence refusals — raw lines plus log_dedup-folded repeats)
against ceilings. Measured: PASS on main (owner rows in 52 s; 3/5/3/0) AND on a
v0.5.223 test-anchor build (12 s; 3/4/3/0) — a clean fresh claim does not
reproduce Eric's storm, which traces to his stranded owner key (#783: never
Rooted). Kept as a guard; the regression for the real chain rides #784.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* logs: the no-analyze WARN reads 'WITHOUT the analyze grant (history: CC#46)'; its source-scan test keys on that stable phrase — ubuntu CI red on #785

The citation pass rewrote 'WITHOUT the CC#46 analyze grant' to 'WITHOUT the
history: CC#46 analyze grant' — awkward mid-sentence, and
fold_consent_surface::declining_to_be_scored_states_what_it_costs located the
WARN by the old phrase. The citation moves to a parenthetical and the test finds
'WITHOUT the `analyze` grant'. fold_consent_surface 9/9, log_citations 1/1.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* contacts: key_pull_unavailable/awaiting_key_body refusals back in the shape both localization scrapers read — ubuntu CI red on #785

localization_gate::rust_and_python_resolvers_agree_on_the_bundle: Rust saw 498
server ids, Python 499. The Rust scraper reads emitter-call argument positions
and missed contacts.awaiting_key_body because a comment sat between the id and
its message; the two key_pull_unavailable sites passed a block expression as the
message, which hid their text from the server-id-text check. Comments move above
the call, the operator detail is logged before refuse(), and the fallback text
is the client bundle's verbatim (the rewrite is CIRISClient#188).
localization_gate 9/9, guard --strict clean, contacts_chat 36/36.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* key_tier: the #783 warnings cite history (the fix landed in #784) — main red on log_citations

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* key_tier: #783 stays open (only its signing half landed in #784) — the warnings cite it as tracked

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
emooreatx added a commit that referenced this pull request Oct 16, 2026
…to peer (#177) (#786)

* formal: AddDevice.tla — the add-device flow (device-join on the first-contact plane) model-checked with TLC

Models a new device B joining as an occurrence of its owner's identity through
the owner's existing device A, peer to peer on edge's first-contact opaque plane
(CIRISEdge#683, kind 0x0000_0002), against an adversary with unlimited fresh
keys that sees every request and injects any answer but cannot forge
signatures. Five configurations: safety (8 invariants, 211,849 states, no
error), careless owner (Inv_NoAttackerOwned violated — the receiver-derived
matching code is load-bearing), liveness (holds), sybil flood without fair
admission (liveness violated — availability-only residual), sybil with fair
eviction (holds). Found: an answered request must free its pending slot, or a
denied flood starves B. README records verdicts and the implementation
requirements. For CIRISConstitution review (Eric's ask, 2026-10-16).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* formal/add_device README: what the model proves (Careful is an oracle, the code's load is the normative receiver derivation), owner presence named as the liveness assumption, a closed window refuses with no answer (CC review)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* add a device: a new device joins its owner's identity peer to peer, the owner consents on the existing device (CIRISServer#177)

The first-run wizard's "Add device" on B takes the person's fedID (QR,
long form or short form) and sends a device-join request over edge's
first-contact opaque plane (CIRISEdge#683, kind 0x0000_0002), signed by
B's node key. A holds it only while the owner has opened "Add a device";
both screens show a matching code A derives from (B's key, challenge).
On approve, the owner pen on A signs owner -> B and B's occurrence
(persist's content-only form), records both and re-wraps the self DEKs
to B. B applies the binding through its own /v1/setup/root (its claim
PIN still proves local presence) and stores the occurrence. B mints its
own keys; no key material crosses.

- FSD/ADD_DEVICE.md: flow, N1-N6, surfaces, refusal ids.
- src/add_device.rs: wire types, A's answerer and owner routes, B's
  setup routes and poll; per-sender, ceiling and closed-window refusals;
  a decided request frees its slot at once (TLC finding, formal/add_device).
- compose: edge gets the federation directory. First contact was dead in
  the server without it.
- owner capsule: an active occurrence of the owner opens it, so B's
  self room and owner flows work on the occurrence key.
- B's device keys use the default device custody (ML-DSA half sealed),
  so its pen opens through the normal signer path.
- harness: `python -m harness.native add_device` passes end to end:
  closed window refused, two requesters with distinct codes, approve B,
  B applied and in session, occurrence on both nodes, A's self file
  opens on B, the stranger denied.
- the eleven add_device.* ids are queued for the client bundle; ratchet
  19 -> 30. approve is named in SESSION_CLAIMS §2.3 as a re-wrap caller.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* 0.5.228 opens: add a device, the key that stays where it was sealed (#784), the contacts batch (#785)

Cargo.toml names the next tag (v0.5.227 is on 21869a6); the evidence
rows follow it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

* one_author_per_row: the add-device occurrence is a verify-produced signed occurrence at millisecond precision, like compose's

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WW3mUN8f2rUCYJVmyLwHGb

---------

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

Labels

ci:full Run the full CI matrix (macOS + Windows) on this PR

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant