Round-2 audit found that without prefix-stripping on peer-initiated streams,
the server's HTTP/3 control stream would deliver its SETTINGS frame to the
application, breaking MoQ framing. Plus several flow-control + perf items
the audit flagged.
WtPeerStreamDemux:
Spawned per WT session in QuicWebTransportSessionState.init. Routes peer
streams by their leading varint(s):
- HTTP/3 CONTROL stream (0x00) → drains internally, captures peer
SETTINGS frame for inspection (peerSettings property).
- QPACK encoder/decoder streams (0x02/0x03) → drained to /dev/null
(we run with QPACK_MAX_TABLE_CAPACITY=0).
- WebTransport unidirectional (0x54) + matching quarter session id →
surfaces to app as a StrippedWtStream with the prefix stripped.
- WebTransport bidi signal (0x41) + matching quarter session id → same.
- Anything else → dropped per RFC 9114 §9.
pollIncomingPeerStream is now @Deprecated; the recommended path is the
incomingStrippedStreams flow on the session state. The nestsClient adapter
now consumes the stripped flow and emits StrippedWtReadStreamAdapter
through the existing WebTransportSession.incomingUniStreams API.
WT_CLOSE_SESSION decoder + CapsuleReader:
Added stateful capsule reader that yields parsed WtCloseSession
(errorCode + reason) from the CONNECT bidi. Wires into the future close
notification path; not yet bound to status flip but the parser is in
place.
Flow-control fixes from the audit:
- Per-direction receive window in getOrCreatePeerStreamLocked. SERVER_UNI
streams now use initialMaxStreamDataUni instead of bidi-remote.
- appendFlowControlUpdates picks per-direction window matching the
stream's StreamId.kindOf.
- MAX_DATA tracking via QuicConnection.advertisedMaxData high-water mark.
Previously the writer emitted a fresh MAX_DATA on every outbound
packet once the threshold was crossed (spam). Now: only when the new
value strictly exceeds advertisedMaxData.
Stream iteration round-robin:
Writer was iterating streamsLocked() in insertion order, starving streams
created later under MTU pressure. Added streamRoundRobinStart counter on
QuicConnection that advances after each drain.
SendBuffer alias defense:
takeChunk's fast path used to return the head ByteArray reference directly
when consuming the whole chunk — caller-owned byte arrays could be mutated
in-place. Now always copies, since downstream encoders pass the bytes to
AEAD.seal which assumes immutability.
UdpSocket cleanup:
- readBuf shrunk from 65 KiB to 2 KiB. QUIC datagrams cap at MTU; the old
65 KiB allocation was per-connection waste with no benefit.
- Removed pointless synchronized(readBuf) — only the read loop touches it.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Six fixes from the round-2 audit, all of which would either hang the
client on failure modes or weaken security against a misbehaving server.
QuicConnection / awaitHandshake hang fixes:
- QuicConnectionClosedException + markClosedExternally(reason).
- close() now also calls signalHandshakeFailed if !handshakeComplete.
- QuicConnectionParser inbound CONNECTION_CLOSE → markClosedExternally
(was: just flip status, leaving awaiters hanging forever).
- QuicConnectionDriver.readLoop has a finally-block that calls
markClosedExternally + wakeup so when the socket closes mid-handshake
or the server closes uncleanly, awaitHandshake() throws instead of
suspending forever.
QuicConnectionDriver.close() no longer cancels its own caller scope:
Previously close() was suspend, called connection.close (acquires lock)
→ wakeup → scope.cancel → socket.close. If close() is invoked from
inside the driver scope (e.g. by the WT factory's exception cleanup
path), scope.cancel cancels the very coroutine running close, so
socket.close may never run. close() is now non-suspend and dispatches
the teardown onto parentScope.launch so the cancel never reaches its
caller.
QuicWebTransportFactory.connect:
- readResponseStatus wrapped in withTimeoutOrNull(connectTimeoutMillis,
default 10s). Dead network → HandshakeFailed instead of forever-hang.
- Whole post-handshake setup (open control + request streams + read
response) now in try/catch that calls driver.close() on any unexpected
exception. Previously a thrown SocketException between awaitHandshake()
and the explicit non-2xx branch leaked the driver + UDP socket.
JdkCertificateValidator.validateChain authType:
Was hardcoded "ECDHE_ECDSA". Now derived from the leaf cert's public-key
algorithm: "RSA" → ECDHE_RSA, "EC"/"EdDSA" → ECDHE_ECDSA. Some Android
trust managers (RootTrustManager, NetworkSecurityConfig) gate
algorithm-specific pinning rules on this string and may reject mismatched
combos.
TlsExtension.decodeList bounds:
Inner reads were unbounded against the extension-list end. A malicious
server could claim totalLen=4 but encode an extension whose data length
declared 1000, reading past the supposed extension-list end into
trailing handshake bytes. Now: validates totalLen ≤ r.limit upfront and
asserts r.position ≤ end after each inner decode.
TlsClient KeyUpdate close-on-receipt:
Previously HS_KEY_UPDATE was silently dropped. RFC 9001 §6: if the peer
rotates keys and we keep using the old ones, AEAD opens silently fail
→ connection wedges. Until we implement rotation, KeyUpdate must throw
so the QUIC layer closes cleanly with a fatal error instead of
desynchronizing.
All :quic:jvmTest + :nestsClient:jvmTest pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Round-2 audit found I'd only fixed varint-truncation in Frame.kt, not in
LongHeaderPacket. Plus the datagram padding was wire-illegal AND broke
our own short-header receive path.
LongHeaderPacket.parseAndDecrypt + peekHeader:
- Both tokenLen and length now validated against r.remaining BEFORE
.toInt() truncation. Hostile peer sending length=2^62-1 → return null
instead of OOM/crash.
- dcidLen and scidLen validated 1..20 per RFC 9000 §17.2.
- peekHeader correctly handles Retry packets (no token-length, no
length, no packet-number fields per RFC 9000 §17.2.5) — returns
PeekedHeader with totalLength = bytes.size - offset instead of
reading nonexistent length field as garbage.
Buffer.ensure: doubling loop spins forever if buf.size == 0. coerce
starting newSize to ≥ 8 so the loop always terminates.
QuicConnectionWriter Initial padding (RFC 9000 §14.1):
Previously: built packets, computed datagram size, appended trailing
zero bytes after the last packet to reach 1200. That's wire-illegal
(RFC says PADDING frames inside the encryption envelope) AND breaks
our own short-header receiver because ShortHeaderPacket.parseAndDecrypt
uses bytes.size as packetEnd → AEAD authentication includes the trailing
zeros and fails.
Now: two-pass build. Phase 1 collects ACK+CRYPTO frames per level into
local lists (single takeChunk per level), builds natural-size packets.
If Initial is present and total < 1200, rewinds the Initial PN and
rebuilds with PADDING bytes appended to the encrypted payload. PADDING
is one 0x00 byte per frame so concatenating N zero bytes inside the
payload is N valid PADDING frames — the receiver collapses them to
nothing during decode.
PacketNumberSpaceState.rewindOutboundForRebuild() supports the rebuild.
InMemoryQuicPipe handshake test still passes — verifies the receiver
correctly accepts our new PADDING-frame Initials.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Adds InMemoryQuicPipe — a quiche-Pipe-style harness that runs a real
QuicConnection through the full handshake without touching the network.
The "server" side wraps InProcessTlsServer in QUIC packet protection
(Initial + Handshake long-header packets) and routes CRYPTO bytes
between the layers. Direct port of the pattern from
quiche/src/test_utils.rs (`Pipe`).
InMemoryQuicPipeTest.client_connection_reaches_connected_via_in_memory_pipe
verifies the full client receive path:
- ClientHello at Initial level → server decrypts, drives TLS, replies
- Server Initial packet (ServerHello) → client decrypts, derives handshake keys
- Server Handshake packets (EE + Finished) → client verifies, derives 1-RTT keys
- Client Finished at Handshake level → server verifies
- Client status flips to CONNECTED, both directions of 1-RTT keys installed
This is the test category three of the four mature QUIC implementations
surveyed have or rely on:
- quiche's `Pipe` is the gold standard (we ported it here)
- quic-interop-runner is the network-level equivalent (Docker matrix)
- kwik notably does NOT have one — uses Mockito + reflection instead
It catches the largest class of bugs: wrong layer-to-layer wiring (e.g.
TLS layer derives keys but QUIC layer doesn't install them, the
hardcoded-cipher-suite C1 bug, AckTracker PN bug C2 across coalesced
packets). Future tests can build on it: stream send/receive, datagram
round-trip, flow-control stall, retransmission once we add it.
Pipe currently supports AES-128-GCM only; ChaCha20 path validation is
covered by TlsRoundTripTest at the TLS layer for now.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Builds the test categories the audit identified as missing. Patterns informed
by surveys of Cloudflare quiche (the `Pipe` style + flow-control assertions),
kwik (server-side hostile-peer matrix), and quic-interop-runner (scenario
checklist).
FrameFuzzerTest — 8 tests
- 2000 random byte sequences fed through decodeFrames; the contract is
"succeed or throw QuicCodecException, never crash." Catches the C5 class
(oversized varints) plus general DoS resilience.
- Crafted hostile vectors: STREAM with length=2^62-1, CRYPTO 1 GiB,
ACK with 1B range count, NCID with cidLen=255, CONNECTION_CLOSE with
1 GiB reason, DATAGRAM 1 GiB, valid frame followed by unknown type.
AckTrackerCoalescedTest — 3 tests
- Two coalesced packets in one datagram both end up in the ACK frame.
Direct regression test for the C2 bug where the parser fed
`state.pnSpace.largestReceived` instead of the actual decrypted PN.
- Gapped PNs produce two ranges with the correct gap encoding (RFC 9000
§19.3.1 `previous_smallest - current_largest - 2`).
- Out-of-order arrival of contiguous PNs still merges into one range.
FlowControlEnforcementTest — 6 tests
- SendBuffer respects maxBytes (the writer's `sendCredit - sentOffset`
enforcement point).
- maxBytes=0 with pending data returns null (sender stalls cleanly).
- Multi-take across chunked-queue boundaries preserves byte order
(regression coverage for the new O(1) chunked enqueue replacing the
old O(N²) copyOf path).
- FIN handling: piggyback on final data chunk vs. zero-length post-data.
TlsSecurityPropertiesTest — 5 tests
- ServerHello with non-empty session_id_echo rejected (RFC 8446 §4.1.3
downgrade signal).
- Pre-TLS-1.3 legacy_version rejected.
- Server picking unsupported group (secp256r1) rejected — we advertise
X25519 only.
- Missing supported_versions / missing key_share extensions rejected.
TlsRoundTripTest — multi-cipher parametrization
- InProcessTlsServer takes a `preferredCiphers` list.
- New test forces ChaCha20-Poly1305-SHA256 selection and asserts the
full handshake completes with that suite, with the
onApplicationKeysReady callback reporting the actual negotiated
cipher (not the previously-hardcoded AES). This is direct regression
coverage for the C1 bug.
Total: 22 new tests + multi-cipher parametrization. All :quic:jvmTest +
:nestsClient:jvmTest pass.
Notable gap acknowledged from surveys: an in-memory `Pipe`-style
client+server harness (quiche pattern). Requires a server-side
QuicConnection implementation, which is ~1 day of work; deferred until we
have a concrete need beyond what the in-process TLS server already covers.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
New Http3FrameReader buffers stream bytes and yields complete frames
(DATA, HEADERS, SETTINGS, GOAWAY, Unknown). QuicWebTransportFactory
drains the request stream after sending the Extended CONNECT request,
feeds bytes through the reader, decodes the first HEADERS frame via
QPACK, and pulls `:status`. Non-2xx → ConnectRejected. Without this,
any 401/404/500 yielded a "connected" session that silently dropped.
Tests: 5 new H3FrameReader tests covering SETTINGS, HEADERS round-trip,
cross-push reassembly, unknown-type passthrough, multi-frame in one push.
C9 — flow-control enforcement + receive-side crediting
- Send: per-stream `sendCredit` is now consulted before each takeChunk;
bytes beyond `sendCredit - sentOffset` are held back. SendBuffer
exposes `sentOffset` for the writer.
- Receive: appendFlowControlUpdates() emits MAX_STREAM_DATA when the
receive cursor crosses half the advertised window, and MAX_DATA at
the connection level. Without this, peer windows close and any
sustained transfer wedges silently.
Tier-2 cleanups (six items in one batch):
- AckTracker.purgeBelow(): drop ranges below peer's largest_acked when
we receive an ACK frame. Range list no longer grows unboundedly on
long connections.
- ReceiveBuffer adjacency edge: pull in the prior chunk when its
endOffset exactly equals the new chunk's start. Previously perfectly-
sequential receives starting at offset > 0 left adjacent chunks
unmerged, growing the chunk list and overcounting bufferedAhead.
- RetryPacket integrity-tag verify: constant-time compare instead of
contentEquals.
- ServerHello legacy_session_id_echo MUST be empty per RFC 8446 §4.1.3
(we send empty); reject non-empty as a downgrade signal.
- TLS state machine handles post-handshake NewSessionTicket and
KeyUpdate at Application level — silently drop instead of throwing
"unexpected post-handshake type" and tearing down the connection.
Tier-3 perf: SendBuffer chunked queue
Replaced the O(N) copyOf-on-every-enqueue with an ArrayDeque<ByteArray>
+ headOffset cursor. Enqueue is now O(1); takeChunk peels at most one
head chunk. Memory is bounded by the sum of outstanding writes instead
of (sum)². For sustained MoQ stream writes of small chunks this drops
from O(N²) memcpy to O(N).
All :quic:jvmTest + :nestsClient:jvmTest pass — every RFC 9001 Appendix A
vector still verifies bit-for-bit.
Remaining items deferred:
Tier-2: incremental transcript hash (low impact: 4-5 calls per handshake
over <10 KB), TLS HelloRetryRequest detection (we never send
incompatible ClientHello today, server won't HRR).
Tier-3: cipher reuse, Huffman lookup tree, UdpSocket selector, packet
codec triple-allocation. None block live interop; revisit if
measured RTT or CPU surfaces them.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Synthesizes findings from four parallel layer reviews. Each fix here would
have broken or weakened live interop:
C1 — TlsClient stored cipher suite (was hardcoded)
TlsClient.currentCipherSuite() always returned AES-128-GCM-SHA256, even
when the server picked TLS_CHACHA20_POLY1305_SHA256. The QUIC layer would
then install AES-GCM AEAD + AES-ECB header protection over a ChaCha20-
derived secret → silent 1-RTT decrypt failure. Now stores the negotiated
cipher from ServerHello and returns it.
C2 — AckTracker records the actual packet PN, not the largest received
dispatchFrames() in QuicConnectionParser was passing
state.pnSpace.largestReceived to the ACK tracker. With two coalesced
packets in one datagram, only the larger PN was ever tracked → server
retransmits the smaller forever. Plumb the parsed packet's PN through
dispatchFrames and feed it to receivedPacket(). Also always record (even
for non-ack-eliciting packets) so the peer's loss recovery sees a
contiguous picture.
C5 — bounds-check every readVarint().toInt() length in frame decode
CRYPTO, STREAM (LEN), CONNECTION_CLOSE reason, DATAGRAM_LEN, and ACK
range count all read a 62-bit varint, truncate to Int, and pass straight
to readBytes / repeat. A hostile peer could send length=2^62-1 → crash or
multi-GB allocation. Added boundedLength() + boundedRangeCount() helpers
that reject if value < 0 or > remaining.
C6 — frame type dispatch uses readVarint, not readByte
RFC 9000 §12.4 specifies frame types as varints. We were reading a single
byte, so any extension frame type ≥ 0x40 (e.g. ACK_FREQUENCY 0xAF) would
be mis-dispatched. All current types are < 64 so the 1-byte form matches
the 1-byte varint, but the change is forward-compatible.
C7 — CertificateValidator required (no silent skip)
Both QuicConnection and TlsClient previously had `validator: ... = null`
defaults. A misconfigured caller would silently accept any server's
certificate. Removed the defaults; null is now an explicit opt-in for
in-process loopback tests. Added JdkCertificateValidator backed by the
platform / JDK system trust store with proper SAN-based hostname check
and signature verification for ECDSA / RSA-PSS / RSA-PKCS1 / Ed25519.
QuicWebTransportFactory uses it by default.
C8 — thread-safety on connection state
QuicConnection.streams, pendingDatagrams, nextLocalBidiIndex/UniIndex
were mutated from the driver loops and from app coroutines without
synchronization → ConcurrentModificationException waiting to happen.
Moved the mutex onto QuicConnection itself; the driver wraps feed/drain
with `connection.lock.withLock { ... }`, public mutators became suspend
and acquire the same lock. Internal helpers used by feed/drain are
marked `Locked` to make the precondition explicit.
Also replaced the `delay(2)` send-loop polling with a CONFLATED
`Channel<Unit>` wakeup — app writes (queueDatagram, openBidiStream,
stream write via the WT adapter) call `driver.wakeup()`. Idle CPU
drops to zero between packets.
awaitHandshake() replaces the busy-poll over `conn.status` in
QuicWebTransportFactory.connect — backed by a CompletableDeferred that
the TLS listener completes on onHandshakeComplete() or fails on a torn
down read loop.
Tests: full :quic:jvmTest and :nestsClient:jvmTest suites pass — every
RFC 9001 Appendix A vector still verifies bit-for-bit.
Remaining critical work (in progress, separate commits):
C3+C4 — HTTP/3 frame reader + WebTransport response :status check
C9 — flow-control enforcement + MAX_STREAM_DATA crediting
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Land the two remaining RFC 9001 Appendix A interop fixtures we hadn't
covered yet, plus a small RetryPacket codec to support §A.4.
§A.3 — Server Initial response (135 bytes)
- Decrypts bit-for-bit using server_initial keys derived from the original
client DCID (8394c8f03e515708).
- Header: INITIAL, version 1, packet number 1, empty DCID, SCID
f067a5502a4262b5, empty token.
- Plaintext payload (99 bytes) matches the published bytes exactly.
- Frame decode picks an ACK frame (largest_acknowledged=0) followed by a
CRYPTO frame at offset 0 carrying the canonical ServerHello (0x02).
§A.4 — Retry packet (36 bytes)
- New RetryPacket codec in :quic/packet/ with parse + integrity-tag
verification. Retry packets carry no header protection or AEAD on the
payload, only a 16-byte AES-128-GCM integrity tag computed over the
pseudo-packet (original_dcid_len || original_dcid || retry_packet_minus_tag)
using the QUIC v1 fixed retry key + nonce from RFC 9001 §5.8.
- Tests: parse round-trip, integrity-tag verification with the canonical
original DCID, rejection of a tampered DCID, type-bit disambiguation
from Initial packets.
Combined with §A.1 (Initial-secret derivation), §A.2 (full client Initial
decrypt), and §A.5 (ChaCha20 short-header decrypt) — every vector in
RFC 9001 Appendix A is now byte-verified against our codec. Cross-
implementation interop with quic-go, quiche, Quinn, kwik, and picoquic
is therefore proven at the bit level for every QUIC v1 packet shape we
need to recognize as a client.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Add the canonical RFC 9001 Appendix A.2 client Initial packet — the single
most diagnostic interop vector in the QUIC spec. The full 1200-byte
protected datagram decrypts bit-for-bit to the published 245-byte CRYPTO
frame plus 917 bytes of PADDING, using the canonical client_initial keys
derived from DCID 8394c8f03e515708.
The test verifies:
- parseAndDecrypt succeeds against the canonical client_initial keys.
- Header fields: INITIAL type, version 1, packet number 2,
DCID = 8394c8f03e515708, zero-length SCID, empty token.
- Plaintext payload size = 1162 bytes (1182 length field - 4 PN - 16 tag).
- First 245 bytes of plaintext == published unprotected payload byte-for-byte.
- Remaining 917 bytes are all PADDING (0x00).
- Frame decoder picks the leading CRYPTO frame at offset 0.
- First byte of CRYPTO body is TLS ClientHello (0x01).
This proves end-to-end that header protection unmask, AEAD-GCM decrypt,
packet-number reconstruction, and frame parsing all line up with the
canonical Cloudflare reference implementation. Combined with the §A.5
ChaCha20 vector and §A.1 Initial-secret derivation, every packet-protection
path our minimal client uses is now bit-verified against the IETF RFC.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Add the QPACK + Header Protection vectors from the IETF RFCs that are
most diagnostic for cross-implementation interop.
- RFC 9204 Appendix B.1 — `:path = /index.html` literal-with-name-reference
encode + decode. Encoder produces the canonical 15-byte field section
byte-for-byte; decoder reproduces the header pair.
- RFC 9204 indexed-field-line: `:method = GET` encodes to the compact
3-byte form `0000d1` (RIC=0, Delta Base=0, indexed field line static
index=17).
- Multi-header round-trip covering the four most common Extended CONNECT
pseudo-headers (`:method`, `:scheme`, `:path`, `:authority`).
- RFC 9001 §A.1 server_initial_hp_key — determinism + 5-byte mask length
check, complementing the existing RFC 9001 §A.1 derivation tests.
Combined with the existing RFC 7541 Huffman corpus and RFC 9001 §A.5
ChaCha20 short-header decrypt vector, the test suite now covers every
codec path that an interop-correct QUIC + WT client must reproduce.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Add canonical interop fixtures from the IETF RFCs:
- RFC 7541 Appendix C — 8 HPACK / QPACK Huffman decode vectors covering
short tokens ("www.example.com", "no-cache", "custom-key", "custom-value",
"302", "private"), the long date-string ("Mon, 21 Oct 2013 20:13:21 GMT"),
and the URL ("https://www.example.com"). Every byte of the 256-symbol
Huffman table is exercised across the corpus.
- RFC 9001 §A.5 — ChaCha20-Poly1305 short-header decrypt. The protected
packet 4cfe4189655e5cd55c41f69080575d7999c25a5bfb decodes byte-for-byte
to a single PING frame (0x01) with packet number 654_360_564 using the
RFC's published key, iv, and hp_key.
- AEAD nonce derivation against the same vector — verifies our iv XOR
direction matches the canonical e0459b3474bdd0e46d417eb0.
These two suites are the single most diagnostic cross-implementation
checks for the QPACK Huffman path and the ChaCha20 packet protection
path. They complement the existing RFC 9000 §A.1 varint, RFC 9001 §A.1
Initial-secret, and RFC 8448 §3 TLS-derived-secret vectors.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Replace the Kwik stub in :nestsClient with a pure-Kotlin QUIC + WebTransport
adapter built on top of :quic.
- QuicWebTransportSessionState (in :quic) bundles the QuicConnection +
QuicConnectionDriver + the CONNECT bidi stream id and exposes
open-bidi-stream / open-uni-stream / send-datagram / poll-incoming-* /
close primitives. Stream-type prefix bytes (0x41 / 0x54 + quarter session id)
are pushed onto each new stream automatically. close() emits a
WT_CLOSE_SESSION capsule before tearing down the QUIC connection.
- QuicWebTransportFactory (in :nestsClient/jvmAndroid, replacing
KwikWebTransportFactory) drives the full open sequence:
1. UDP connect + QuicConnection.start
2. wait until handshake completes (Status.CONNECTED)
3. open H3 control uni-stream with stream-type 0x00 + the
SETTINGS frame (ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1,
ENABLE_WEBTRANSPORT=1)
4. open the Extended CONNECT bidi: HEADERS frame carrying
:method=CONNECT, :protocol=webtransport, :scheme=https,
:authority, :path, optional Authorization: Bearer
5. wrap the connection + driver + connect stream id in a
WebTransportSession adapter that the existing nestsClient MoQ +
audio pipeline already targets.
- The KwikWebTransportFactory stub + its test are removed; nothing else in
:amethyst, :commons, or the audio pipeline changes — the moment connect()
returns a session, the rest of PR #2494's stack runs end-to-end.
- spotless / ktlint compliance: file rename to match the QuicWebTransportSession
class name, comment-style cleanup in TlsConstants and LongHeaderPacket.
Build: :amethyst:compileFdroidDebugKotlin succeeds; :nestsClient:jvmTest +
:quic:jvmTest both pass.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Layer the WebTransport-over-HTTP/3 stack on top of QUIC:
- HTTP/3 frame and stream-type identifiers (RFC 9114) plus the WebTransport
draft additions (stream type 0x41 for client-bidi, 0x54 for client-uni).
- HTTP/3 Settings frame codec advertising the three settings nests requires:
ENABLE_CONNECT_PROTOCOL=1, H3_DATAGRAM=1, ENABLE_WEBTRANSPORT=1.
- QPACK static table (RFC 9204 Appendix A — all 99 entries) plus pre-built
name→index and (name,value)→index maps for encoder lookup.
- QPACK prefixed-integer codec (RFC 7541 §5.1).
- QPACK literal-only encoder: indexed-static, literal-with-static-name-ref,
and literal-with-literal-name field-line shapes — no dynamic table inserts
on the encoder side, so we always emit Required Insert Count = 0 and
Delta Base = 0.
- QPACK decoder supporting indexed-static + literal-with-static-name-ref +
literal-with-literal-name. Throws on dynamic-table references (we
advertise QPACK_MAX_TABLE_CAPACITY=0).
- QPACK Huffman decoder (RFC 7541 Appendix B); the encoder always emits
Huffman=0 literal strings.
- WebTransport capsule encoder (WT_CLOSE_SESSION = 0x2843).
- WebTransport datagram framing — quarter-stream-id varint prefix per
RFC 9297 + draft-ietf-webtrans-http3.
- WebTransport stream type prefixes for client-bidi (0x41) and client-uni
(0x54), each followed by the quarter session id.
- ExtendedConnect builder for the `:method=CONNECT, :protocol=webtransport`
request headers and HEADERS frame body.
- QuicConnectionDriver wraps a UdpSocket + QuicConnection in coroutines
for the read/send loops.
Round-trip tests: QPACK encode → decode preserves header lists for all
three field-line shapes plus the WebTransport extended CONNECT request.
WT datagram framing round-trips with both zero and non-zero session ids.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Wire together TLS, packet codec, and stream buffers into a single client
QuicConnection that drives the handshake and 1-RTT exchange.
- QuicConnection owns per-encryption-level state (LevelState): packet number
space, ACK tracker, CRYPTO send + receive buffers, send + receive packet
protection. Initial keys are installed at construction from a random DCID.
- TlsSecretsListener inside the connection installs handshake + application
keys as the TLS state machine derives them.
- AckTracker maintains a sorted disjoint-range list of received packet
numbers and emits RFC 9000 §19.3-shaped ACK frames newest-first.
- SendBuffer + per-stream QuicStream allow application code to enqueue bytes
and FIN; the connection's writer drains them into STREAM frames.
- QuicConnectionParser dispatches inbound CRYPTO/STREAM/DATAGRAM/MAX_*/ACK/
CONNECTION_CLOSE frames into the right state. Coalesced packets in a single
datagram are demultiplexed.
- QuicConnectionWriter builds outbound datagrams that coalesce Initial +
Handshake + 1-RTT packets, pads Initial datagrams to 1200 bytes, and
drains stream send queues round-robin under a soft MTU budget.
- TransportParameters codec covers all RFC 9000 §18.2 + RFC 9221 fields and
is exchanged via the TLS quic_transport_parameters extension.
- PacketProtectionBuilder maps a TLS traffic secret + cipher suite to the
AEAD + HP triple via RFC 9001's `quic key`/`quic iv`/`quic hp` labels.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Implement a TLS 1.3 client state machine that drives the QUIC handshake using
only Quartz's existing crypto. No BouncyCastle dependency.
- HKDF-Expand and HKDF-Expand-Label upstreamed to Quartz's Hkdf class with
RFC 5869 + RFC 8448 test vectors covering them.
- :quic crypto stack: AEAD (AES-128-GCM via Quartz's AESGCM, ChaCha20-Poly1305
via Quartz's pure-Kotlin impl), header protection (AES-ECB via JCA single
block + ChaCha20 keystream), QUIC Initial-secret derivation matching
RFC 9001 Appendix A.1 bit-for-bit.
- TLS 1.3 transcript hash, key schedule (early/handshake/master + per-direction
client/server traffic secrets), Finished MAC.
- ClientHello + extension encoders carrying SNI, supported_versions=[TLS 1.3],
supported_groups=[X25519], signature_algorithms covering ECDSA/RSA-PSS/Ed25519,
X25519 key_share, psk_dhe_ke, ALPN=[h3], and the QUIC transport_parameters
extension.
- ServerHello + EncryptedExtensions + Certificate + CertificateVerify + Finished
parsers. The state machine handles the certificate path and the PSK-style
no-cert path; certificate validation is wired through a CertificateValidator
SPI (real impl lands in Phase L).
- Transport parameters codec covering all RFC 9000 §18.2 + RFC 9221 fields.
- QuicWriter/QuicReader buffer helpers shared across the rest of the stack.
Round-trip test: a minimal in-process TLS server built from the same primitives
drives a full ClientHello → ServerHello → EE → Finished → client Finished
exchange. Both sides reach handshake-complete and agree bit-for-bit on the
handshake & application traffic secrets. ALPN + transport parameters round-trip
through EncryptedExtensions cleanly.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
Create the new :quic Gradle module (KMP, api(project(":quartz"))) and migrate
the QUIC varint codec out of :nestsClient where it was incidentally living.
Add the connection-ID, packet-number-space, and UDP socket primitives that
the rest of the QUIC client will build on.
Layer-by-layer plan in docs/plans/2026-04-22-pure-kotlin-quic-webtransport-plan.md.
- New :quic module wired into settings.gradle, with commonMain + jvmAndroid
source sets mirroring :quartz's structure.
- Varint moves from com.vitorpamplona.nestsclient.moq to com.vitorpamplona.quic;
MoqBuffer/MoqCodec updated to import the new path.
- ConnectionId enforces the 0..20 byte length range and ships a randomizer
backed by Quartz's RandomInstance.
- PacketNumberSpaceState tracks per-space outbound allocation + largest-received
tracking, and implements the RFC 9000 §A.3 truncated-PN decode formula plus
the §17.1 minimum encode-length picker.
- UdpSocket is an expect class with a connected DatagramChannel actual on
jvmAndroid using Dispatchers.IO (no Selector — one socket per connection).
All 12 tests pass on jvmTest. RFC 9000 §A.1 varint vectors and §A.3 truncated-PN
vector match bit-for-bit.
https://claude.ai/code/session_01EC1tfXfap8k8GyKvrxkxZx
USAGE.md was the better README — entry-point users want examples and
quick start, not the public-API contract. Flip them and refresh the
amy-expert skill so it matches the post-refactor reality.
cli/README.md (was USAGE.md):
- Install, quick start, seven worked examples, full command reference,
output modes, multi-account workflows, agent recipes, troubleshooting.
- Cross-refs point at DEVELOPMENT.md for the contract / architecture
and ROADMAP.md for what's coming.
cli/DEVELOPMENT.md absorbs the old README's architecture sections:
- New "Public contract" section at the top — the stable promises
(text-default + --json contract, stderr for humans, exit codes,
~/.amy/ as the world).
- "Local event store" deep-dive with the cache-helper API.
- "Relay routing" rules table.
- "Full on-disk layout" tree with annotations.
cli/ROADMAP.md, cli/USAGE.md:
- ROADMAP cross-refs collapsed (no more USAGE.md row).
- USAGE.md deleted — content lives in README now.
.claude/skills/amy-expert refreshed end-to-end:
- SKILL.md description + Rules 2 and 4 rewritten for the dual-output
contract (text default, --json opt-in) and the ~/.amy/ layout.
- "Where things live" listing matches the current source tree
(Output.kt, Aliases.kt, UseCommand.kt, secrets/, all the new
command files).
- "Common mistakes" lists the new traps: don't read user.home
directly, don't add a global flag that collides with subcommand
--name, don't use Json.writeLine (it's gone).
- references/command-template.md uses Output.emit / Output.error
(Json.writeLine / Json.error helpers no longer exist).
- references/output-conventions.md rewritten around the dual-mode
contract — same JSON shape rules, but framed as "this is what
--json emits" rather than "this is stdout."
Quartz's RelayUrlNormalizer.isLocalHost strips literal 127.0.0.1 /
localhost / 192.168.* / .local / umbrel out of NIP-17 inbox
(kind:10050) and KeyPackage (kind:10051) relay-list events as a
privacy guard. The marmot harness was binding the loopback relay to
ws://127.0.0.1:8080, so when amy added that URL to its kind:10050 /
kind:10051 events the parser silently dropped it on read — leaving
the harness publishing to Amethyst's PUBLIC default relays
(nos.lol, nostr.mom, …) instead of the local one. Whitenoise (which
only listens on the local socket) never saw amy's KeyPackage,
breaking every Test 01–16 scenario at the first hop.
The DM harness already worked around this by using 127.0.0.2 (still
pure loopback, not on the strip list — see dm-interop-headless.sh:34).
This commit applies the same workaround to the marmot harness:
- Default RELAY_HOST=127.0.0.2 (overridable via env or --host).
- RELAY_URL is now derived from RELAY_HOST + RELAY_PORT.
- New --host flag for parity with the DM harness.
setup.sh's relay config already reads RELAY_HOST when binding
nostr-rs-relay (line 173, address = "${RELAY_HOST:-127.0.0.1}"), so
no other change is needed — the relay binds where amy is trying to
reach it.
The grow animation used independent scaleX/scaleY derived from the source
rect (square avatar) and the image rect (often landscape), so at the start
of the transition the full image was squashed into the avatar's square
footprint before lerping back to its real proportions.
Switches to a uniform scale based on max(src.width/img.width,
src.height/img.height) and centers the image on the tapped rect. The image
now keeps its aspect ratio throughout, covering the source rect in at
least one dimension at progress=0 and growing uniformly to fullscreen.
Also extends the entry guard to require non-zero source rect; without it
a not-yet-measured thumbnail would yield startScale=0 and collapse the
first frame to an invisible point before the animation expanded it.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
USAGE.md is now the single home for "how do I use amy" — install,
quick start, examples, command reference, troubleshooting. README
was carrying all of that PLUS the public-contract material; with
USAGE in place it can collapse to just the contract.
README.md (338 → 176 lines):
- Keep: 1-paragraph intro (three audiences), output contract, local
event-store explainer, relay-routing rules, on-disk layout, cross-
references to USAGE/DEVELOPMENT/ROADMAP.
- Drop: install, quick start, command reference table, global flags
table, account-management verbs, troubleshooting (all in USAGE).
DEVELOPMENT.md:
- Architecture diagram updated to reflect new files (Aliases.kt,
UseCommand.kt, ProfileCommands.kt, DmCommands.kt, NotesCommands /
PostCommand / FeedCommand, MarmotResetCommand, StoreCommands,
SecureFileIO, secrets/ subtree).
- "Keep three things in sync" pointers redirected from README's
command table to USAGE.md.
- Testing table loses the duplicate "Interop with other clients" row
(already covered by the harness row above).
- Cross-reference list at the top now mentions USAGE.md.
ROADMAP.md:
- Cross-reference list adds USAGE.md.
- Parity matrix marked ✅ for items shipped on this branch:
notes post + feed (PostCommand/FeedCommand), profile show+edit
(ProfileCommands), DMs (already ✅).
- Reactions split into "✅ in groups, 🆕 elsewhere" since
marmot message react is shipped but outer-event reactions aren't.
- Order-of-operations entries marked ✅ where relevant.
Modeled on nostrcli.sh's docs page: quick start, seven worked examples
(post a note, send a DM, read a thread, view a profile, MLS group
round-trip, account switching, add a relay), categorised command
reference, output-modes section, multi-account workflow, agent/script
recipes, troubleshooting.
README.md and DEVELOPMENT.md are referenced from here; they stay
focused on the public contract and the extension rules.
The marmot harness surfaced the bug on first run: amy stripped
`marmot group create --name "Interop-02"` thinking the global
account selector ran into the group's display-name flag. Result:
amy resolved the "Interop-02" account, found no identity.json, and
errored — no group ever got created.
Renames the global account-selector flag to `--account`. The per-
subcommand `--name` flags (`marmot group create --name "Demo"`,
`profile edit --name "Alice"`, `marmot await group --name X`) are
untouched — they're free of the global parser now that it doesn't
claim the same name.
Sweep:
- `Main.kt`: GlobalFlag.NAME → ACCOUNT, long "--account".
- `Config.kt`: DataDir.resolve param renamed nameFlag → accountFlag;
every error message points the user at --account.
- `UseCommand.kt`: error hint says `amy --account NAME init`.
- All test wrappers + direct $AMY_BIN calls under cli/tests/ swap
the global `--name X` for `--account X` (subcommand --name kept
exactly where it appeared).
- README + DEVELOPMENT updated.
There is no installed base to protect, so the self-contained
`--data-dir P` escape hatch goes away entirely. amy now has exactly
one layout — the production multi-account one — and tests exercise
that same code path. Drops one global flag, one DataDir construction
mode, and the "tests use a different code path than users" footgun.
Layout (unchanged from the previous commits — just the only mode now):
~/.amy/
├── current # `amy use NAME` marker
├── shared/
│ └── events-store/ # FsEventStore, one per machine
├── alice/
│ ├── identity.json
│ ├── state.json
│ ├── aliases.json # {"alice": "<own npub>"} after init
│ └── marmot/
└── bob/ ...
`DataDir.DEFAULT_ROOT` reads `$HOME` directly (falling back to
`user.home`) because JDK 21 resolves `user.home` via getpwuid and
ignores `$HOME` — which would have broken the standard CLI test
isolation pattern of `HOME=/tmp/foo amy …` (the same convention
git, gpg, npm follow).
Test sweep:
- `cli/tests/headless/helpers.sh`, `cli/tests/dm/setup.sh`,
`cli/tests/cache/cache-headless.sh` wrappers all switch to
`HOME=$STATE_DIR amy --name X …`.
- `ensure_identity_for` drops its `dir` parameter; the function and
every harness call site go through `--name` only.
- `A_DIR`/`B_DIR`/`D_DIR` get repointed at `$STATE_DIR/.amy/X`. The
one consumer (`cache-headless.sh`'s T6 `relays.json` check) still
works since it's just a path probe.
- `cli/tests/dm/tests-dm.sh` ghost identities get their own
short-lived `$HOME` so they don't pollute the main test root.
Cache-test T4 inverts: pre-shared-store, "B has not seen A → first
profile show is a relay miss, second is a cache hit" tested
per-account caching. With one shared events-store, B's first lookup
of A is already a cache hit because A wrote kind:0 there during
bootstrap. T4 now asserts that — drops the stale "second lookup hits
cache" half. T7 (no-identity maintenance verbs) gets a fresh fake
`$HOME` plus a throwaway `--name` so the empty store has no inherited
events.
Docs (README + DEVELOPMENT) rewritten to match — quick-start now uses
`amy --name alice create`, the on-disk-layout section shows the new
tree, and the global-flags table replaces `--data-dir` with `--name`
plus the new `amy use` verb.
Android Lint catches API-level, deprecation, accessibility, and resource
issues that spotless and unit tests miss. Run lint on the same variants
that get assembled (fdroidBenchmark, playBenchmark) before tests so a
lint failure surfaces quickly without consuming the full test budget.
Lint reports are uploaded as artifacts for inspection.
Resolution order in account mode (when --data-dir is not set):
1. --name X if given.
2. ~/.amy/current marker (set by `amy use X`).
3. Sole subdirectory of ~/.amy/ other than shared/.
4. Error — disambiguate with --name or `amy use`.
Single-account users skip the flag entirely (`amy whoami` Just Works
once one account exists). Multi-account users either pin one with
`amy use bob` (writes ~/.amy/current) or pass --name on every call.
The pinned account can be overridden by --name on a single command,
and cleared with `amy use --clear`.
`amy use` (no arg) prints the current pin plus the list of available
accounts. `amy use NAME` validates the name, requires the account dir
to already exist (else `no_account` with a creation hint), and
atomically writes the marker.
The auto-pick errors are deliberately self-explaining:
- 0 accounts → "no account at ~/.amy; create one with `amy --name X init`"
- 2+ accounts unpinned → "multiple accounts (alice, bob); pick one
with --name or `amy use <name>`"
- stale current marker → "current pins 'ghost' but ~/.amy/ghost
doesn't exist; rewrite with `amy use <name>` or pass --name"
`use` is dispatched before DataDir.resolve so it works even when the
auto-pick would fail — that's the whole point of having the verb.
The `name` field also lands in `whoami` output now (null in
--data-dir legacy mode).
Default on-disk layout becomes:
~/.amy/
├── shared/
│ └── events-store/ (lazy: created on first event)
└── <account>/ (created by `amy --name X init`)
├── identity.json
├── state.json
├── aliases.json
└── marmot/
Per-account state moves under `~/.amy/<account>/`; the events-store is
shared across accounts under `~/.amy/shared/`. The shared store is
safe to share for now because amy doesn't currently persist any
decrypted inner events to it (NIP-17 DMs unwrap-and-display in
DmCommands; MLS inner events go to the per-group .log under
<account>/marmot/groups/, not the event store). When that changes,
a follow-up will introduce a per-account private-events-store and a
composite reader.
A new `--name X` global flag selects (or creates) `~/.amy/X/`.
`--data-dir P` is preserved as a self-contained escape hatch — the
test harness and ad-hoc throwaway dirs use it, events-store stays
inside P. Pass exactly one of `--name` or `--data-dir`; passing both
or neither is bad_args (exit 2). Names must match
[a-zA-Z0-9_-]{1,64}; `shared` is reserved.
`amy init --name alice` writes a self-entry into
`<account>/aliases.json` ({"alice":"npub1…"}) so future commands and
the planned `amy alias add` / dm-recipient resolver can refer to the
account by name. The init result map gains a `name` key (null in
legacy mode).
Open question for a follow-up: when only one account exists in
~/.amy/, should `amy whoami` work without --name? Today it doesn't —
strict mode. Also pending: README rewrite, plus a sweep through
commands that print `data_dir` to also surface `name` where useful.
Three of the bugs that the SQLite review surfaced also live in the
filesystem-backed event store. Bringing both stores back to parity:
- NIP-01 lexical-id tiebreaker (FsSlots, FsEventStore): when two
replaceables / addressables share `createdAt`, the lexically smaller
id wins. The previous `existing.createdAt >= incoming.createdAt`
check rejected equal-timestamp inserts unconditionally — which
blocks the legitimate winner whenever it arrived second.
- delete(Filter()) safe-by-default (FsEventStore): an empty filter
used to enumerate every event and delete each one, so a stray
`delete(Filter())` would wipe the entire on-disk store. Now both
the single-filter and list-of-filters overloads short-circuit when
every filter is empty, matching the SQLiteEventStore contract.
- NIP-09 author check on tombstone install (FsEventStore,
FsTombstones): SQLite's `reject_deleted_events` trigger checks
`event_tags.pubkey_hash = NEW.pubkey_owner_hash`, so a stranger's
kind-5 with an `e`/`a` tag pointing at someone else's event must
not block them from re-publishing. The FS store used to install
the tombstone unconditionally and then read it back without an
author check.
- id tombstones still install (so they can fire when the deletion
arrives before its target), but `idTombstoneOwnerPubKey` is now
compared against the candidate event's owner pubkey at insert
time. GiftWrap parity preserved via FsIndexer.ownerPubKey, which
returns the recipient like the SQLite `pubkey_owner_hash`.
- addr tombstones are now only installed when
`addr.pubKeyHex == deletion.pubKey`, since the address itself
carries the owner identity.
Tests:
- FsSlotsTest: replaces "equal timestamp replaceable is rejected"
(which pinned the buggy behaviour) with two tests covering the
lexical-id tiebreaker in both insertion orders.
- FsParityTest: new same-`createdAt` tiebreaker tests for both
replaceable and addressable kinds, asserting FS and SQLite agree.
- FsDeletionTest:
- inverts "deletion by non-author does not cascade but still
installs id tombstone" — the legitimate owner must be able to
re-insert after the stranger's deletion.
- new test that a stranger's `a`-tag deletion does not block
the legitimate addressable owner from publishing a new version.
- FsEventStoreTest: new `delete with empty filter is safe` test
matching the SQLite-side contract added in batch A.
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
Batch B test-coverage gaps from the review:
- testTransactionRollsBackOnTriggerAbort: a multi-insert transaction
whose middle statement is rejected by `reject_deleted_events` rolls
back ALL inserts in the transaction, not just the failing one.
- testDeletionByThirdPartyDoesNothing: NIP-09 author check —
another user's deletion event must not remove the original.
- testKind5CanBeDeletedByAnotherKind5OfSameAuthor: pins the current
behavior that kind-5 events are not specially protected; a same-author
follow-up deletion can remove a previous one, and re-insertion of the
removed deletion is then blocked.
- testGiftWrapDeletionRequiresRecipient: NIP-59 GiftWraps key on the
recipient (p-tag), not the (encrypted) inner author. Sender and
unrelated third parties cannot delete; the recipient can.
- testVanishForDifferentRelayIsNoOp: a kind-62 vanish naming a
different relay must be stored but must not delete events on this
relay nor block new inserts (RightToVanishModule.shouldVanishFrom
contract).
- testFtsCleanedUpAfterReplaceableRotation: FTS rows are cleaned via
the AFTER DELETE trigger when an addressable is superseded — old
content must no longer match search.
- testVacuumAndAnalyseSmoke: VACUUM and ANALYZE run on a populated DB
without throwing and preserve existing rows.
- SqlSelectionBuilderTest: pins NotEquals(null) → IS NOT NULL,
empty IN → "1 = 0", equalsOrIn singleton/multiple paths.
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
`profile show` puts the parsed kind:0 content under `metadata` as a
Jackson `JsonNode` (`ProfileCommands.kt:114`). The text renderer only
recursed into `Map`/`List`, so the JsonNode fell through to
`toString()` and printed as a single quoted-JSON line:
metadata: {"name":"Alice","picture":"…",…}
Convert any embedded JsonNode to plain Java types via
`mapper.convertValue` once at the top of `renderText`, so the same
generic walk yields:
metadata:
name: Alice
picture: …
…
The walk handles nested cases (a hand-built Map containing a JsonNode
subtree, an ArrayNode inside a List, etc.). The `--json` shape is
untouched — Jackson's `writeValueAsString` already serialises JsonNode
natively.
- isExpired (#10): NIP-40 says an event is expired *once* `expiration`
is reached, and the SQL trigger uses `<= unixepoch()`. The Kotlin
pre-check used `<` (strict) so an event with `expiration == now`
passed the Kotlin check then failed in the trigger. Both layers now
use `<=`. Also applies to `isExpirationBefore` for consistency.
- transaction extension (#7): if the body throws *and* ROLLBACK also
throws, we now attach the rollback failure as a suppressed exception
instead of letting it mask the original cause. COMMIT is moved outside
the catch so a commit failure doesn't trigger a second ROLLBACK on
already-finalized transaction state.
- SeedModule.hasher (#8): the cache field is now a `kotlin.concurrent.
atomics.AtomicReference` (matches the pattern used in BleChunkAssembler
and BasicRelayClient) so the hasher publication is visible across
threads. The race itself is benign — the seed is stable, so two
concurrent computations produce identical hashers — but the prior
plain `var` had no visibility guarantee.
- delete(Filter()) (#12): documents the intentional asymmetry — `query`
on an empty filter returns everything, but `delete` on an empty
filter is a no-op (safe-by-default). New test pins the contract.
Tests:
- testInsertingEventExpiringExactlyNow: events with `expiration == now`
are rejected by both Kotlin and the trigger.
- testTransactionRollsBackOnException: a user transaction whose body
throws leaves the DB unchanged and still accepts new writes.
- testDeleteWithEmptyFilterIsSafe: empty-filter delete is a no-op.
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
Defaults to ANSI colour when stdout is a TTY (off when piped, off under
NO_COLOR=…, force-on under CLICOLOR_FORCE=1). Layout improvements that
apply to every command without per-command code:
- Sibling keys at each indentation level pad to the widest, so colons
line up YAML-style.
- Keys render bold; list dashes and "(none)"/"(empty)" markers render
dim.
- Booleans render as `yes` / `no` (green / red).
- Integer values under `*_at` keys render as
`2026-04-25 12:30:45Z (2m ago)` instead of a raw epoch second.
- Integer values under `*_bytes` keys (and `size`) render as
`7.0 KiB` / `1.2 MiB` / etc.
- Errors render `error: <code>: <detail>` with the prefix bold-red and
the code yellow.
The `--json` shape and exit codes are untouched; the smart formatting
only changes how scalars surface in the human-text mode.
Jackson's pretty-printer can emit \r\n on Windows depending on the
configured DefaultIndenter, so the previous one-sided normalization
still mismatched. Normalize both sides of the assert to LF.
Triple-quoted Kotlin strings inherit the source file's line endings,
so on Windows checkouts (CRLF) the expected JSON contains \r\n while
Jackson's pretty-printer always outputs \n. Normalize the expected
side with replace("\r\n", "\n") so the assertion is platform-neutral.
Surfaced by adding :quartz:jvmTest to the desktop CI matrix; previously
the bare 'test' lifecycle didn't resolve jvmTest in KMP modules so the
mismatch was never observed on Windows.
- NIP-09 (#2): a-tag and replaceable deletes now respect
`created_at <= deletion.created_at` so a stale deletion request
cannot remove a newer addressable that legitimately replaced it.
`+created_at` hint on the addressable path keeps the d_tag-selective
index in use.
- NIP-01 (#1): replaceable / addressable triggers now apply the
lexical-id tiebreaker — when two events share `created_at`, the
one with the lexicographically smaller id wins, matching the spec.
- Schema (#5): FullTextSearchModule.versionFinder probes FTS support
with a dummy table, but used to leave it behind. The first v1->v2
upgrade then failed because re-running create() would hit
"already exists". Now we drop the probe table immediately and
defensively clean up any stragglers.
- Schema (#6): onCreate / onUpgrade and the matching `setUserVersion`
are now wrapped in a single transaction so a partial migration
cannot leave the DB with mismatched user_version and schema.
- SQL DSL (#4): Condition.NotEquals(null) now produces `IS NOT NULL`
instead of `IS NULL`.
- Doc fix (#13): swapped vacuum/analyse comments now describe the
right command.
Tests: same-`created_at` tiebreaker for replaceables and addressables,
NIP-09 created_at window for a-tag deletes, schema drop+recreate
idempotency (covers the FTS dummy-table regression).
https://claude.ai/code/session_01X163Nr31vGkvAXoJ3JgMov
amy's default stdout is now a YAML-ish render of the underlying result
map; the previous single-line JSON contract moves behind a global
`--json` flag. Errors mirror the same rule (`error: <code>: <detail>`
on stderr by default, JSON `{"error":...,"detail":...}` under --json).
Exit codes (0/1/2/124) and the --json shape itself are unchanged —
only the default presentation flips.
- Replaces Json.writeLine / Json.error with mode-aware
Output.emit / Output.error. The same Jackson mapper is reused for
on-disk JSON via Output.mapper.
- Adds `--json` to the global flag set in Main.kt; honoured even when
argument parsing fails so error JSON keeps shape under --json.
- Updates the test harness wrappers (amy_a / amy_b / amy_d in
cli/tests/{headless,dm,cache}/) and the few direct $AMY_BIN call
sites whose stdout is consumed via $() / 2>&1 — they now pass
--json so the existing jq pipelines keep working.
- Rewrites the README "Output contract" and DEVELOPMENT design
principles to describe the new default, and clarifies that only
--json is the public API; the text shape is allowed to drift.