# Specification conformance review — FSQ (JSQ) and JG25 **Date:** 5 September 2026 **Code reviewed:** `claude/shared_files_integration` at f1e59e9 (the branch this review branch was cut from). **Purpose:** the published protocol documents must describe exactly what the code puts on the air, so that a third party could decode OpenJSQ transmissions from the documents alone. That is the condition under which an unspecified digital code may be used in the U.S. amateur service (47 CFR §97.309(a)(4), §97.309(b): the technical characteristics are publicly documented; nothing is encrypted; nothing obscures meaning). Where a document and the code disagreed, the DOCUMENT was changed to match the code. No code, modem, or wire behaviour was changed in this review. **Method.** Section by section: quote the claim, cite the implementing code, mark **MATCH** (document and code agree), **DIFFERS** (document fixed to the code), or **UNDOCUMENTED** (on-air behaviour with no public description; added). Where a document describes something the code does not transmit, it is marked "reserved / not transmitted by this release" rather than deleted, unless plainly wrong. Test vectors pinned by the test suites are treated as authoritative over prose. The scope of this pass is FSQ and JG25; AX1200/OFQ2 is not re-reviewed here. Ground truth consulted: `modem/native` (FSQ modem, alphabet, wide modem), `transports/fsq_legacy` (framing, CRCs, base-26, envelopes), `core/src/fsq_mode.cpp`, `core/src/capability_token.cpp`, `core/src/multi_channel_session.cpp`, `core/src/fsq_protocol_session.cpp`, `core/src/inbound_message_adapt.cpp`, `core/src/protocol_triggers.cpp`, `engine/src/fsq_engine.cpp`, `modem/jg25` (modem, profile, header and body decode), `modem/api/include/openfsq/modem_config.hpp`, `transports/jg25_native` (envelope, file adapter, golden vectors), `engine/src/jg25_service_session.cpp`, `engine/src/station_action_service.cpp`, `transport/common` (file, mail, receipt codecs, file library), `desktop/engine_controller.cpp` and `desktop/library_labels.cpp` (the shared-folder answer texts that go on the air). --- ## 1. FSQ_FAST_PUBLIC_SPEC.md | § | Claim | Code | Verdict | |---|---|---|---| | 4 | Single-carrier, phase-continuous, 33-tone IFK+; not bonded/OFDM | `fsq_modem.cpp:42-48` (kToneCount 33, kMiddleTone 16), `render_transmit_audio` (one phase accumulator, one tone at a time) | MATCH | | 5, 5.1 | f(i) = fc + (i−16)·Δf, Δf = 8.7890625·k Hz | `fsq_modem.cpp:44` kToneSpacingHz 8.7890625; `tone_frequency_hz()` uses index − kMiddleTone; `fsq_mode.cpp:9` spacing = 33·3·12000/4096/33 | MATCH | | 6 | Base rates 1.953125 / 2.9296875 / 3.90625 / 5.859375 Bd; ratios 4.5 / 3 / 2.25 / 1.5 | `fsq_mode.cpp` spacing_to_baud_ratio(), throughput = spacing / ratio | MATCH | | 7 | 1 ≤ k ≤ 7, R = k·Rbase, Δf = k·8.7890625 | `fsq_modem.cpp` kMaximumFastMultiplier 7; `fsq_mode.cpp` derive() | MATCH | | 8 | Eleven registered modes, base/k/allocation table | `fsq_mode.cpp` kSpecs (FSQ-2 … FSQ-42) | MATCH | | 8 (planner BW) | k·(290.0390625 + 4·nominal baud) | `slot_plan.cpp:39-46` fsq_occupied_bandwidth_hz, kToneGridHz, baud_of | MATCH | | 8 (allocation) | 430·k Hz | `fsq_mode.hpp` kChannelAllocationPerCarrierHz 430 | MATCH | | 9, 9.1 | T(n+1) = (T(n) + s + 1) mod 33; inverse s = d − 1 | `fsq_modem.cpp` try_begin_transmit: `advanced = tone + symbol + 1; tone = advanced % 33`; receive `mod33(step − 1)` | MATCH | | 10 | Initial reference tone 16, no data symbol from it | `prepared_tones[0] = kMiddleTone` | MATCH | | 11 | Ts = 1/(k·Rbase), no inter-symbol gap | `render_transmit_audio` symbol_increment = baud / Fs | MATCH | | 12 | Phase accumulator never reset at tone changes | `transmit_phase_radians += 2π f / Fs`, wrapped only at 2π | MATCH | | 13 | Peak amplitude 0.80; ~5 ms attack/release on the whole transmission | `fsq_modem.cpp:47-48` kTransmitAmplitude 0.80, kTransmitRampSeconds 0.005; attack/release gains computed over the whole tone list | MATCH | | 14 | Initial symbols 0–28; continuations 29/30/31 | `fsq_alphabet.cpp` kUpperTable 29, kPunctuationTable 30, kExtendedTable 31 | MATCH | | 15.1–15.4 | Complete character tables | `fsq_alphabet.cpp` ascii_codeword(), utf8_entries, decode tables | MATCH (every entry checked, including (28,30) IDLE, BS = (27,31), DEL = (28,31), ±÷°×£ = (10..14,31)) | | 16 | Normalisation: CRLF→LF, CR→LF, unsupported → `?`, malformed UTF-8 → `?`, NUL → `?` | `encode_text()` | MATCH | | 20–24 | Receiver features not normative; guard tones 8 (k≤5), ceil(160 Hz/Δf) clamped to [3,8] above | `fsq_modem.cpp:98-107` bank_guard_tones_for | MATCH | | 21 | No in-band Fast-mode identification; negotiation is a higher layer | true of the modem; the higher layer is the `K` token letter, now documented in JSQ_PUBLIC_PROTOCOL_SPEC §78.2 | MATCH (cross-reference added) | | 26 | BOT = 0x20 0x0A; EOT = 0x20 0x20 0x08 0x20 0x20 | `fsq_legacy.hpp:21-22` kBot, kEot | MATCH | | 27 | Directed form `sender:hh dest trigger body .cccc`; CRC-16 suffix mandatory on k>1, optional on k=1 | `fsq_legacy.hpp:36`, `fsq_mode.cpp` fsq_mode_sends_payload_crc (interoperable ? preference : true) | MATCH | | 28 | CRC-8 poly 0x07 init 0 no reflect over sender | `fsq_legacy.cpp:211-226` source_crc8 | MATCH | | 29 | CRC-16/CCITT-FALSE over sender 00 dest 00 trigger 00 body | `fsq_legacy.cpp:303-321` logical_message_crc16 | MATCH | | 29.1 | Four lowercase base-26 letters after `.` | `encode_logical_crc16` / `decode_logical_crc16` | MATCH | | 32–34 | Test vectors: tones [16,4,10,30,18]; A?0 → [1,29,28,29,10,30]; CRC-8 0xfc; CRC-16 0x5f62 → `bkde` | Recomputed independently from the code's algorithms: 0xfc, 0x5f62, `bkde` | MATCH | | 27 (relay form) | Not stated: how a relayed frame is written | `fsq_protocol_session.cpp:395-402`: trigger `[`, body `origin]text`, text `dest[origin]text` | UNDOCUMENTED → added as §27.1 | | 27 (sounding) | Not stated: the bare sounding frame | `multi_channel_session.cpp:1376`: an empty SendRequest becomes BOT sender `:` hh EOT with no destination, trigger or body | UNDOCUMENTED → added as §27.2 | | 5 (Wide) | "not a bonded multi-lane mode" | `WideFsqModem` exists in `modem/native` but is constructed by no engine or desktop code; `advertise_wide` defaults false and nothing sets it | MATCH; note added that the bonded waveform is not transmitted by this release | Counts: MATCH 26, DIFFERS 0, UNDOCUMENTED 2 (added). ## 2. JSQ_PUBLIC_PROTOCOL_SPEC.md (FSQ++ parts and cross-bearer parts only) | § | Claim | Code | Verdict | |---|---|---|---| | 4.1 | Directed frame conceptual form | `fsq_legacy.hpp:36` | MATCH | | 5 | `jsqf1:` / `jsqm1:` / `jsqr1:` envelopes, common binary + base-26 | `jsq_file_adapter.hpp:35`, `jsq_mail_adapter.hpp:23`, `jsq_receipt_adapter.hpp:19`, adapters | MATCH | | 21, 23, 28 | File kinds 0–8 in order; OFFER result 0–11; STATUS result 0–5 | `jsq_file_protocol.hpp:66-121` | MATCH | | 33 | Binary kind byte first, big-endian fields, CRC-32C trailer | `transports/fsq_legacy/src/jsq_file_adapter.cpp:133` `put_u32(out, jsq_crc32c(out))` | MATCH | | 34 | Base-26: 4 bytes → 7 letters; tails 1/2/3 bytes → 2/4/6 letters; strict decode | `jsq_base26.hpp` | MATCH | | 36 | Mail kinds 0–8 | `jsq_mail_protocol.hpp:108-118` | MATCH | | 50 | `jsqm1:` carries the common mail bytes | `jsq_mail_adapter.cpp` | MATCH | | 51.1 | Token may ride a presence line | `multi_channel_session.cpp:1398-1399` appends `" " + token` to the presence status | MATCH | | 51 | Presence line form not stated | `multi_channel_session.cpp:1393-1401`: directed to `allcall`, trigger space, body ` `; a station with no status text sends the bare sounding instead | UNDOCUMENTED → added §51.2 | | 56 | Trigger table | `protocol_triggers.cpp:53-84` | MATCH (`%` maps to `image` but no image service answers it; noted) | | 56 | Store / retrieve argument grammar `#[name]text`, `+[name]` not stated | `inbound_message_adapt.cpp:267-285` compose_directed_body | UNDOCUMENTED → added §56.1 | | 56 | Relay request form `relay;destbody`, chained `r2;r3;dest…` not stated | `inbound_message_adapt.cpp` expected_first_hop_retransmission; `fsq_protocol_session.cpp:395-402` | UNDOCUMENTED → added §56.2 | | 56 | Automatic reply bodies (`snr=-3 db`, `snr=? db`, `Heard:` lines, `ack`, `not found`, `no qth on file`, `no qtc`, `[name]\ncontent`) not stated | `multi_channel_session.cpp:880-935, 1013-1040` | UNDOCUMENTED → added §56.3 | | 63.2, 65 | Integrity layering and minimum implementation | consistent | MATCH | | 74, 77 | Receipt DELIVERED body, `jsqr1:` | `jsq_receipt_protocol.hpp/.cpp` (CRC-32C trailer at :105) | MATCH | | 78 | `R1` letter | `capability_token.cpp` | MATCH | | 78.1 | `L1` letter; absence means absence; L0 = absence | `capability_token.cpp` (`byte >= 1`), format appends `L1` only when served | MATCH | | 78 | Token grammar and the letters `M`, `W`, `K` not documented anywhere public | `capability_token.hpp/.cpp`: letter+digits pairs, ≤8 pairs, ≤3 digits, order M W K R L, unknown letters skipped, product-name rule | UNDOCUMENTED → added §78.2 | | 78 | `W` (bonded lanes) | never emitted: `advertise_wide` defaults false and is set nowhere; no engine constructs `WideFsqModem` | documented as reserved / not transmitted | | 5, 78.1 | "Section 5.5F" cited twice but does not exist: the `jsq-lib` request, listing document, fetch form and refusal texts were undocumented | `multi_channel_session.cpp:784-870`, `file_library.hpp:249-268`, `file_library.cpp` (listing magic, fingerprint), `desktop/engine_controller.cpp` (listing sent as a JSQ file named `jsq-library--.lst`), `desktop/library_labels.cpp:44-72` (refusal texts) | UNDOCUMENTED → added §5.5F | | 12.2, rev. 1.5 | JG25 bits 8/9 mentioned; JG25 wire otherwise out of this document's scope | `jg25_envelope.hpp` | MATCH; pointer to JG25_SPECIFICATION added in §2 | Counts: MATCH 16, DIFFERS 0, UNDOCUMENTED 6 (added: §5.5F, §51.2, §56.1, §56.2, §56.3, §78.2). ## 3. JG25_SPECIFICATION.md | § | Claim | Code | Verdict | |---|---|---|---| | 3.1 | Gray dibits 00→0, 01→1, 11→2, 10→3 | `jg25_modem.cpp` pair_to_symbol | MATCH | | 3.3 | MSB-first bytes and bits | bytes_to_bits | MATCH | | 4 | Seven modes, baud, tones | `jg25_profile.hpp` kModes | MATCH (the `111` reserved row is not transmitted) | | 5.1–5.3 | Acquisition 600 Bd, tones 500/1100/1700/2300, 48- and 96-symbol preambles, 20-symbol sync | `jg25_profile.hpp` kAcqTones, kAcqBaud, kNormalPreamble, kRobustPreamble, kSync | MATCH (sequences identical digit for digit) | | 6.1 | Header 350 Bd, tones 950/1350/1750/2150, 16 info + 16 CRC bits, K=7 r=1/2 + 6 tail bits, 76 coded bits, 8-row interleaver, 38 symbols | kHeaderTones, kHeaderBaud, make_header_symbols, conv_encode, block_interleave(…, 8) | MATCH; interleaver ordering now written out exactly | | 6.2 | word = mode<<13 \| fec<<11 \| length(11) | make_header_symbols | MATCH | | 6.3 | CRC-16 poly 0x1021 init 0xFFFF no final XOR, MSB first | `jg25_header_decode.hpp:35-44` | MATCH (stated as CRC-16/CCITT-FALSE; "no reflection" added) | | 6.4 | K=7, generators (171,133) octal, state 0, 6 zero tail bits | G0 0x79, G1 0x5B, conv_encode | MATCH; register convention now written out | | 7 | F0 none; F1 puncture 111001; F2 1110; F3 mother; 32-row body interleaver | puncture_bits, make_body_symbols | MATCH; "provisional" wording replaced by the exact rule | | 8 | CRC-32C then FEC then interleave then Gray; whitening disabled | begin_transmit_frame, make_body_symbols | MATCH; CRC-32C parameters added (reflected 0x1EDC6F41 = 0x82F63B78, init/xorout 0xFFFFFFFF, check 0xE3069283); whitening marked not transmitted | | 8 | Frame-length limits not stated | `packet_types.hpp:20` kMaximumModemFrameBytes 384; header length field carries body+CRC ≤ 2047 | UNDOCUMENTED → added | | 8 | Odd coded-bit count padding, segment timing restart at each baud boundary | bits_to_symbols pads a final lone bit with 0; append_cpfsk restarts symbol timing per segment, phase continuous | UNDOCUMENTED → added | | 10–15 | "JG.25 link-layer frame": 5-octet base header, ver/class/E/A/P control octet, Context ID, Sequence, TLV extensions, compact 5-octet base-37 addressing, OPEN/ACK frames, 32-frame selective-repeat window, compact ACK, resync markers, bursts | **None of this is transmitted.** The frame body carried by the PHY is the JG25 JSQ bearer envelope v1 (`jg25_envelope.hpp`): magic 0x4A, version 1, type, flags, header_length, text callsigns, per-type fields, TLV extensions, payload | DIFFERS → sections rewritten to the envelope; the earlier design kept as Appendix C, marked not transmitted | | 16 | Adaptation ladder | local policy, not wire; the only adaptation on the air is the mode/FEC the header announces | MATCH (kept, labelled local policy) | | 18 | "Numeric SERVICE IDs not frozen" | frozen: the packet-type registry 0x01–0x31 | DIFFERS → replaced by the registry | | 19 | Capability bits 0–9; beacon carries presence flags only | `jg25_envelope.hpp` kJg25Cap*, kJg25Presence* | MATCH; presence flag bits 0–3 added | | 19 | Heard-list entry format not stated | encode_jg25_heard_entries: relayed u8, callsign len+bytes, minutes_ago u16 (0xFFFF = unknown/too long), snr i8 (127 = none) | UNDOCUMENTED → added | | 20–22 | Durable objects "TBD", mail ops "provisional", file layout "TBD" | the common JSQ file/mail/receipt codecs ride `file_envelope` 0x30, `mail_envelope` 0x31, `delivery_receipt` 0x21 | DIFFERS → replaced by references to JSQ_PUBLIC_PROTOCOL_SPEC Parts III, V, XVI | | 25 item 13 | ACTION_RESPONSE status folding | `Jg25ActionStatus` seven values | MATCH (kept) | | App. B | Wire summary | as above | DIFFERS → rewritten | Counts: MATCH 15, DIFFERS 4 (fixed), UNDOCUMENTED 4 (added). The test file `transports/jg25_native/tests/test_jg25_envelope.cpp` freezes one golden byte vector per packet type; four of them are reproduced in the document as worked examples. ## 4. JSQ_TRANSPORT_SERVICE_SPEC.md | § | Claim | Code | Verdict | |---|---|---|---| | 2.1, 3.1 | "a future JG25 frame", "JG25 when integrated" | JG25 is a shipping bearer (`transports/jg25_native`, `engine/src/jg25_service_session.cpp`) | DIFFERS → wording fixed | | 9 | `file.shared` = serves the folder now; carried per bearer (AX/JG bit, FSQ `L`) | `capability_token.cpp`, `kJg25CapSharedFilesAvailable`, `desktop/engine_controller.cpp` sharedFilesAvailableFor | MATCH | | 12.1 | Action set and legacy triggers | `protocol_triggers.cpp`, `station_action_service.cpp` | MATCH | | 20, 21 | Presence / heard records (semantic) | consistent | MATCH | | 37 | Reference mapping diagram omits JG25 | JG25 native provider exists | DIFFERS → added | | App. C | Provider table lists JG25 native | consistent | MATCH | Counts: MATCH 4, DIFFERS 2 (fixed), UNDOCUMENTED 0. ## 5. PROTOCOL_COMPATIBILITY.md | Claim | Code | Verdict | |---|---|---| | 12000 Hz, 4096-sample symbol at 3 Bd, 33 tones, 3-bin spacing | `fsq_mode.cpp:9`, `slot_plan.cpp:21` | MATCH | | CRC-8 poly 0x07 init 0, two lowercase hex digits | `fsq_legacy.cpp:211` | MATCH | | "Callsign validity: length 3–20" | `fsq_legacy.cpp:347-415`: a callsign is 1–15 characters of letters, digits, `-`, `/` with at least one letter or digit; a STATION callsign needs at least one letter and one digit; a group is `@` + 1–30 of letters, digits, `/`; `allcall` and `cqcqcq` are reserved broadcast destinations | DIFFERS → fixed | | Trigger table | `protocol_triggers.cpp` | MATCH (note added that `!`/`~` repeat the last transmission, not the argument — public spec §79) | | Reply formats not stated | `multi_channel_session.cpp` | UNDOCUMENTED → cross-reference to public spec §56.3 | Counts: MATCH 3, DIFFERS 1 (fixed), UNDOCUMENTED 1 (referenced). ## 6. docs/help/OpenJSQ_Help.html Read from the operator's updated working copy (not committed by this review). Its on-air statements — "Classic FSQ fits in 430 Hz", "the wide Fast modes reach 3 kHz", "1200-baud packet", "JG25's four tones span almost the entire audio passband" — are consistent with the code and the specifications. No change. --- ## Needs a code decision Nothing in this pass required a code change to make the documents true, and nothing found reads as obscuring meaning. Two items are recorded for the maintainer because the DOCUMENT now states them as facts a third party will rely on: 1. **JG25 bit 9 and the FSQ `L1` letter are asymmetric by design** (bit 9 is per recipient, `L1` is broadcast); already stated in the public spec §78.1. No action. 2. **`%` (image) is parsed but never answered** by any service; the public spec §56 now says so. Removing it from the trigger table would be a code change; leaving it parsed is harmless. ## Summary | Document | Sections changed | A third party can now decode … from this document alone | |---|---|---| | FSQ_FAST_PUBLIC_SPEC.md | 5 (note), 21 (cross-ref), 27.1, 27.2 (new) | **yes** — every FSQ and FSQ Fast transmission: tones, timing, IFK+, varicode, framing, both CRCs, relay and sounding forms; verified against the code's own test vectors | | JSQ_PUBLIC_PROTOCOL_SPEC.md | 2 (JG25 pointer), 5.5F, 51.2, 56.1–56.3, 78.2 (new) | **yes** — every FSQ++ service body: the envelopes, base-26, shared codecs, the capability token, presence, relay, store/retrieve, automatic replies, shared-folder listing and fetch | | JG25_SPECIFICATION.md | front matter, 3.3, 4, 6.1, 6.3, 6.4, 6.5 (new), 7, 8, 10–15 (rewritten as the envelope), 16, 18, 19, 20–22, 24A (new), 25, App. B, App. C (new: the v0.2 design, not transmitted), App. D (new: golden vectors) | **yes** — every JG25 transmission from the preamble to the last payload byte: acquisition, header, FEC, interleavers, CRCs, the envelope and every packet type with its fields, four golden byte vectors | | JSQ_TRANSPORT_SERVICE_SPEC.md | 2.1, 3.1, 37 | n/a — semantic document; it names no bytes, and now names JG25 as a shipping bearer | | PROTOCOL_COMPATIBILITY.md | callsign rule, trigger notes, reply cross-reference | **yes** for what it covers (the legacy FSQCall surface); the exact frame is in FSQ_FAST_PUBLIC_SPEC | ## Commits on `claude/spec_fcc_review` (off f1e59e9) | Commit | Document | |---|---| | 96beb41 | this review (committed first) | | 9dd7464 | PROTOCOL_COMPATIBILITY.md | | cb0742f | JSQ_TRANSPORT_SERVICE_SPEC.md | | e72a8a6 | FSQ_FAST_PUBLIC_SPEC.md | | 369b671 | JSQ_PUBLIC_PROTOCOL_SPEC.md (1.6-draft) | | efff8f8 | JG25_SPECIFICATION.md (v0.3) | Docs only; no file outside `docs/` was changed on this branch.