Skip to content

feat(validator): add the sendspin-validate skeleton with handshake and hello checks - #163

Merged
chrisuthe merged 7 commits into
Sendspin:mainfrom
chrisuthe:chrisuthe/task/add-the-sendspin-validate-skeleton-with-handshake
Oct 14, 2026
Merged

chrisuthe merged 7 commits into
Sendspin:mainfrom
chrisuthe:chrisuthe/task/add-the-sendspin-validate-skeleton-with-handshake

Conversation

@chrisuthe

Copy link
Copy Markdown
Member

Phase 1 of the client validator (spec §2, §5.1, §7–§9). sendspin-validate is a strict Sendspin server, built on the bundled aiosendspin, that a client app connects to. It records every frame, runs the first machine checks, and writes a report.

Part of Sendspin/backlog#84.

What's in it

  • 0002-noncompliance-observer.patch: SendspinServer(noncompliance_observer=...) reports every spec violation the server flags, with the connection number the frame observer uses, so the validator learns why a strict server rejected a client without parsing log text. It is upstream-shaped, with its own tests, and aiosendspin's suite passes with it applied (2972 tests). The validator spec's patch list now reads 0001 frame observer, 0002 non-compliance observer, then raw send, pairing driver and forget-client.
  • Connection modes:
    • Client-initiated: listens on 8927 (--port), announced as _sendspin-server._tcp with path=/sendspin.
    • Server-initiated: browses _sendspin._tcp, lists what it finds (including services it cannot connect to, with the reason), and dials the one the user picks. --connect ws://… skips mDNS.
    • Only the client under test is judged. Server-initiated mode doesn't listen, and client-initiated mode judges the first connection.
  • Strict server, unpaired admission only:
    • The server runs with allow_noncompliant_clients=False, and every client is admitted through unpaired access.
    • If the client has unpaired access disabled, the user is told that pairing arrives later. The run does not fail for it.
    • Server-initiated dials declare playback, so the validator wins admission over a server that holds the client idle. The user is warned first.
  • Recorder: decodes and numbers frames, and redacts long_term_psk, wrapped_psk, wrapped_nonce_B, pake_msg_*, *_kc and registered tokens or codes as each frame is recorded. It writes protocol.log.jsonl, and --verbose streams frames to stderr.
  • Prompter: instruct, report_check, ask_text, ask_choice and confirm, in a stdlib terminal implementation. Every prompt accepts s, r and ?; skip maps to SKIPPED. A hidden --auto-answer flag is for CI.
  • Checks: pure functions over one recorded connection, with stable ids.
    • CORE-INIT-001..005: client/init fields.
    • CORE-NOISE-001: Noise message 2 payload.
    • CORE-ORDER-001: ordering before activation.
    • CORE-HELLO-001..007: client/hello. The TXT name check is a WARN.
    • CORE-STATE-001..003: client/state.
    • CORE-TIME-001: client/time.
    • CORE-STRICT-001: every strict-mode rejection, cited to its rc1 spec line. All 127 reasons are mapped. A rejection that rc1 does not back is a TOOL_ERROR, not a FAIL.
  • Report: a summary table, plus report.json (sorted, recording the tool version, spec tag, bundled aiosendspin commit and patches, seed, mode and client identity), a self-contained report.html and protocol.log.jsonl, all written to ./sendspin-validate-<client>-<timestamp>/.
  • README: a "Client validator" section.

Testing

  • Every check has unit tests for pass and fail over hand-built frame sequences, plus tests for redaction at record time, the prompter, the report, storage, and the drift between the refs table and the vendored source.
  • The self-test runs the real sendspin-validate --auto-answer against the bundled aiosendspin client:
    • in both directions, requiring zero FAIL and zero TOOL_ERROR;
    • against a client that skips the handshake, which must FAIL CORE-INIT-001 and exit with status 1.
    • It runs in ci.yml's unit-test step. 449 tests pass on Python 3.12, and the vendor --check passes.
  • Manual run against sendspin-cpp-cli at origin/main 05f9944, headless (-o null), in server-initiated mode, picked from the validator's own mDNS list:
    • With --allow-unpaired: 19/19 PASS.
    • With the default (unpaired access off): 15 PASS and 4 N/A. The client then sent concurrent_attempt because another server on the LAN already held it, which is correct per spec.
    • No findings to file against the CLI.

Notes for review

  • publish.yml runs once on this PR. The sendspin-validate entry in pyproject.toml is on its path filter. No harness code changed.
  • The 0002 patch will also be opened against aiosendspin upstream.

…iosendspin

0002-noncompliance-observer.patch gives SendspinServer an optional
noncompliance_observer, called with every spec violation the server flags
in a client, before it tolerates the violation or rejects the client. Each
report carries the reason, the client id and the connection number the frame
observer uses. The client validator needs it to report why a strict server
rejected a client without parsing log text. The bundled tree is rebuilt with
vendor_aiosendspin.py 10.0.2.
The recorder is the bundled server's frame and non-compliance observer. It
numbers each frame, decodes the JSON ones, keeps only the type and size of
binary ones, and redacts pairing secrets (long_term_psk, wrapped_psk,
wrapped_nonce_B, pake_msg_*, *_kc and registered tokens or codes) as it
records, before anything reaches --verbose output or protocol.log.jsonl. A
frame it cannot process is kept as a tool error instead of being raised into
aiosendspin, which would swallow it.

The Prompter interface (instruct, report_check, ask_text, ask_choice,
confirm) is the steps' only channel to the user. The terminal prompter uses
the standard library only, accepts s, r and ? on every question, and escapes
control characters in client-supplied text. The auto-answer prompter is for
CI. A skipped or unsure human answer maps to SKIPPED, never PASS.
Each check is a pure function of one recorded connection, with a stable id and
the spec lines it enforces at 1.0.0-rc1: client/init fields
(CORE-INIT-001..005), the Noise message 2 payload (CORE-NOISE-001), nothing
but hello or goodbye before activation (CORE-ORDER-001), client/hello
(CORE-HELLO-001..007, the TXT name check a WARN), the initial client/state
(CORE-STATE-001..003) and client/time (CORE-TIME-001).

Every rejection by aiosendspin's strict mode is CORE-STRICT-001, cited to
the spec line of the rule it enforces. All 127 reasons the bundled copy can
report are mapped. A rule rc1 does not make, or a reason the table does not
know, is a TOOL_ERROR rather than a FAIL the spec cannot back. A check whose
evidence never arrived because the connection had already been rejected is
N/A, and a check that raises is a TOOL_ERROR.
sendspin-validate runs a strict bundled-aiosendspin server on port 8927,
advertised as _sendspin-server._tcp, and gets one client connected: the app
connects in, or the validator connects to a _sendspin._tcp client the user
picks from an mDNS browse, or to --connect ws://host:port/path. It admits the
client through unpaired access, explains when the app has that disabled,
watches the opening exchange through the initial client/state and time sync,
then judges the recording.

The run writes report.json (sorted, with the tool version, spec tag, bundled
aiosendspin commit and patches, seed, connection mode and client identity), a
self-contained report.html and protocol.log.jsonl, and prints a summary. The
Noise identity and pairing store live in the platform user-data directory;
--fresh replaces them. The self-test runs the real command against the
bundled aiosendspin client in both directions and requires no FAIL or
TOOL_ERROR.
…connection

A server-initiated dial now declares playback, so the validator wins
admission from a server that holds the client idle; with the discovery
reason the client kept the other server and answered concurrent_attempt.
Observation ends as soon as the validator answers the opening frame with
server/error or aiosendspin redials, instead of waiting out the step timeout
while it reconnects. The client's goodbye is reported however observation
ends. A self-test case drives a client that skips the handshake and expects a
CORE-INIT-001 FAIL and exit status 1.
In server-initiated mode the validator now only dials and does not listen,
so no other client can reach it; in client-initiated mode the first
connection is the one judged, and any other is named as not judged. Its
identity comes from that connection alone.

A listen failure is reported as a port problem only where the listener
starts. Discovery shows services it cannot connect to, with the reason, and
resolves them concurrently. A Noise message 2 rejection counts once, under
CORE-NOISE-001. Waits wake on recorded events instead of polling, file IO
runs off the event loop, a failing check logs its traceback, and the user is
told before the validator takes a client over from another server. The
unpaired-access store keeps only the approval it needs. The refs test now
fails when the bundled copy changes its composed reasons or when a reason
matches more than one pattern.
@chrisuthe
chrisuthe marked this pull request as ready for review October 14, 2026 19:35
@chrisuthe
chrisuthe merged commit f03b859 into Sendspin:main Oct 14, 2026
2 checks passed
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