From 4ab96b2a2d21db594f9919798577727367cce403 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 9 Sep 2026 00:52:21 +0200 Subject: [PATCH 1/5] M5.8: add local anchor registry --- .../restore_anchor_registry.go | 126 ++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 cmd/number-station-tools/restore_anchor_registry.go diff --git a/cmd/number-station-tools/restore_anchor_registry.go b/cmd/number-station-tools/restore_anchor_registry.go new file mode 100644 index 0000000..f3c769c --- /dev/null +++ b/cmd/number-station-tools/restore_anchor_registry.go @@ -0,0 +1,126 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "sort" + "sync" + "time" +) + +type restoreAnchorRegistryEntry struct { + ID string `json:"id"` + ExportedAt string `json:"exported_at"` + ReceiptCount int `json:"receipt_count"` + HeadHash string `json:"head_hash,omitempty"` +} + +type restoreAnchorRegistryData struct { + Entries []restoreAnchorRegistryEntry `json:"entries"` +} + +type restoreAnchorRegistryStatus struct { + Anchored bool `json:"anchored"` + LastAnchor *restoreAnchorRegistryEntry `json:"last_anchor,omitempty"` + LocalReceiptCount int `json:"local_receipt_count"` + CurrentHeadHash string `json:"current_head_hash,omitempty"` + ReceiptsSince int `json:"receipts_since_last_anchor"` + Status string `json:"status"` +} + +type restoreAnchorRegistry struct { + mu sync.Mutex + path string + data restoreAnchorRegistryData +} + +func openRestoreAnchorRegistry(path string) (*restoreAnchorRegistry, error) { + r := &restoreAnchorRegistry{path: path, data: restoreAnchorRegistryData{Entries: []restoreAnchorRegistryEntry{}}} + body, err := os.ReadFile(path) + if os.IsNotExist(err) { + return r, nil + } + if err != nil { + return nil, err + } + if err := json.Unmarshal(body, &r.data); err != nil { + return nil, err + } + if r.data.Entries == nil { + r.data.Entries = []restoreAnchorRegistryEntry{} + } + return r, nil +} + +func (r *restoreAnchorRegistry) save() error { + body, err := json.MarshalIndent(r.data, "", " ") + if err != nil { + return err + } + tmp := r.path + ".tmp" + if err := os.WriteFile(tmp, body, 0o600); err != nil { + return err + } + return os.Rename(tmp, r.path) +} + +func (r *restoreAnchorRegistry) record(anchor restoreReceiptAnchor) (restoreAnchorRegistryEntry, error) { + if err := validateRestoreReceiptAnchor(anchor); err != nil { + return restoreAnchorRegistryEntry{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + entry := restoreAnchorRegistryEntry{ID: id(), ExportedAt: time.Now().UTC().Format(time.RFC3339Nano), ReceiptCount: anchor.ReceiptCount, HeadHash: anchor.HeadHash} + r.data.Entries = append(r.data.Entries, entry) + if err := r.save(); err != nil { + r.data.Entries = r.data.Entries[:len(r.data.Entries)-1] + return restoreAnchorRegistryEntry{}, err + } + return entry, nil +} + +func (r *restoreAnchorRegistry) list(limit int) []restoreAnchorRegistryEntry { + r.mu.Lock() + defer r.mu.Unlock() + out := append([]restoreAnchorRegistryEntry(nil), r.data.Entries...) + sort.Slice(out, func(i, j int) bool { return out[i].ExportedAt > out[j].ExportedAt }) + if limit <= 0 { + limit = 50 + } + if limit > 200 { + limit = 200 + } + if len(out) > limit { + out = out[:limit] + } + return out +} + +func (r *restoreAnchorRegistry) status(receipts *restoreReceiptStore) (restoreAnchorRegistryStatus, error) { + anchor, err := receipts.anchor() + if err != nil { + return restoreAnchorRegistryStatus{}, err + } + r.mu.Lock() + defer r.mu.Unlock() + result := restoreAnchorRegistryStatus{LocalReceiptCount: anchor.ReceiptCount, CurrentHeadHash: anchor.HeadHash, Status: "never-anchored"} + if len(r.data.Entries) == 0 { + return result, nil + } + last := r.data.Entries[len(r.data.Entries)-1] + result.Anchored = true + result.LastAnchor = &last + verification := receipts.verifyAnchor(restoreReceiptAnchor{Version: restoreReceiptAnchorVersion, ReceiptCount: last.ReceiptCount, HeadHash: last.HeadHash}) + if !verification.Valid { + result.Status = "mismatch" + return result, errors.New("last registered anchor does not match local receipt chain") + } + result.ReceiptsSince = anchor.ReceiptCount - last.ReceiptCount + if result.ReceiptsSince == 0 { + result.Status = "current" + } else { + result.Status = "extended" + } + return result, nil +} From d0dac3b9447409e96f101f991391db95fb607622 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 9 Sep 2026 00:52:47 +0200 Subject: [PATCH 2/5] M5.8: expose anchor registry API --- .../recording_bundle_api.go | 60 ++++++++++++------- 1 file changed, 39 insertions(+), 21 deletions(-) diff --git a/cmd/number-station-tools/recording_bundle_api.go b/cmd/number-station-tools/recording_bundle_api.go index 5fdeb9b..1267055 100644 --- a/cmd/number-station-tools/recording_bundle_api.go +++ b/cmd/number-station-tools/recording_bundle_api.go @@ -38,6 +38,7 @@ func registerRecordingBundleHandlers(mux *http.ServeMux, rs *recordingStore, aud func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordingStore, audioDir string) { receipts, receiptStoreErr := openRestoreReceiptStore(rs.path + ".restore-receipts.json") + anchors, anchorRegistryErr := openRestoreAnchorRegistry(rs.path + ".restore-anchors.json") mux.HandleFunc("GET /api/recording-archive/receipts", func(w http.ResponseWriter, r *http.Request) { if receiptStoreErr != nil { @@ -65,8 +66,8 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin }) mux.HandleFunc("GET /api/recording-archive/receipts/anchor", func(w http.ResponseWriter, _ *http.Request) { - if receiptStoreErr != nil { - http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError) + if receiptStoreErr != nil || anchorRegistryErr != nil { + http.Error(w, "restore anchor service is unavailable", http.StatusInternalServerError) return } anchor, err := receipts.anchor() @@ -74,11 +75,45 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin http.Error(w, err.Error(), http.StatusConflict) return } + if _, err := anchors.record(anchor); err != nil { + http.Error(w, "could not register exported anchor", http.StatusInternalServerError) + return + } w.Header().Set("Content-Type", "application/json; charset=utf-8") w.Header().Set("Content-Disposition", `attachment; filename="number-station-restore-receipt-anchor.json"`) writeJSON(w, http.StatusOK, anchor) }) + mux.HandleFunc("GET /api/recording-archive/receipts/anchors", func(w http.ResponseWriter, r *http.Request) { + if anchorRegistryErr != nil { + http.Error(w, "restore anchor registry is unavailable", http.StatusInternalServerError) + return + } + limit := 50 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + parsed, err := strconv.Atoi(raw) + if err != nil || parsed < 1 || parsed > 200 { + http.Error(w, "limit must be between 1 and 200", http.StatusBadRequest) + return + } + limit = parsed + } + writeJSON(w, http.StatusOK, anchors.list(limit)) + }) + + mux.HandleFunc("GET /api/recording-archive/receipts/anchors/status", func(w http.ResponseWriter, _ *http.Request) { + if receiptStoreErr != nil || anchorRegistryErr != nil { + http.Error(w, "restore anchor service is unavailable", http.StatusInternalServerError) + return + } + status, err := anchors.status(receipts) + if err != nil { + writeJSON(w, http.StatusConflict, status) + return + } + writeJSON(w, http.StatusOK, status) + }) + mux.HandleFunc("POST /api/recording-archive/receipts/anchor/verify", func(w http.ResponseWriter, r *http.Request) { if receiptStoreErr != nil { http.Error(w, "restore receipt store is unavailable", http.StatusInternalServerError) @@ -131,17 +166,7 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin http.Error(w, err.Error(), http.StatusConflict) return } - receipt, err := receipts.add(restoreReceipt{ - Mode: "selected", - PlanToken: planToken, - SelectedRecordingIDs: selectedIDs, - ImportedRecordings: result.ImportedRecords, - ImportedAnnotations: result.ImportedAnnotations, - Duplicates: result.Duplicates, - Conflicts: result.Conflicts, - Unmatched: result.Unmatched, - SkippedByPolicy: result.SkippedByPolicy, - }) + receipt, err := receipts.add(restoreReceipt{Mode: "selected", PlanToken: planToken, SelectedRecordingIDs: selectedIDs, ImportedRecordings: result.ImportedRecords, ImportedAnnotations: result.ImportedAnnotations, Duplicates: result.Duplicates, Conflicts: result.Conflicts, Unmatched: result.Unmatched, SkippedByPolicy: result.SkippedByPolicy}) if err != nil { http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError) return @@ -159,14 +184,7 @@ func registerRecordingRestoreHandler(mux *http.ServeMux, db *store, rs *recordin http.Error(w, err.Error(), http.StatusBadRequest) return } - receipt, err := receipts.add(restoreReceipt{ - Mode: "full", - ImportedRecordings: result.ImportedRecords, - ImportedAnnotations: result.ImportedAnnotations, - Duplicates: result.Duplicates, - Conflicts: result.Conflicts, - Unmatched: result.Unmatched, - }) + receipt, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: result.ImportedRecords, ImportedAnnotations: result.ImportedAnnotations, Duplicates: result.Duplicates, Conflicts: result.Conflicts, Unmatched: result.Unmatched}) if err != nil { http.Error(w, "restore succeeded but receipt persistence failed", http.StatusInternalServerError) return From d351dc48c5c1faf93c96cd2c094dab9d0f89fc88 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 9 Sep 2026 00:53:07 +0200 Subject: [PATCH 3/5] M5.8: test anchor registry and status --- cmd/number-station-tools/m5_8_test.go | 76 +++++++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 cmd/number-station-tools/m5_8_test.go diff --git a/cmd/number-station-tools/m5_8_test.go b/cmd/number-station-tools/m5_8_test.go new file mode 100644 index 0000000..53353f4 --- /dev/null +++ b/cmd/number-station-tools/m5_8_test.go @@ -0,0 +1,76 @@ +package main + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "path/filepath" + "testing" +) + +func TestRestoreAnchorRegistryTracksGrowth(t *testing.T) { + dir := t.TempDir() + receipts, err := openRestoreReceiptStore(filepath.Join(dir, "receipts.json")) + if err != nil { t.Fatal(err) } + registry, err := openRestoreAnchorRegistry(filepath.Join(dir, "anchors.json")) + if err != nil { t.Fatal(err) } + if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) } + anchor, err := receipts.anchor() + if err != nil { t.Fatal(err) } + if _, err := registry.record(anchor); err != nil { t.Fatal(err) } + status, err := registry.status(receipts) + if err != nil { t.Fatal(err) } + if !status.Anchored || status.Status != "current" || status.ReceiptsSince != 0 { t.Fatalf("status=%#v", status) } + if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) } + status, err = registry.status(receipts) + if err != nil { t.Fatal(err) } + if status.Status != "extended" || status.ReceiptsSince != 1 { t.Fatalf("status=%#v", status) } +} + +func TestRestoreAnchorRegistryPersists(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "anchors.json") + registry, err := openRestoreAnchorRegistry(path) + if err != nil { t.Fatal(err) } + anchor := restoreReceiptAnchor{Version: 1, ReceiptCount: 1, HeadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"} + entry, err := registry.record(anchor) + if err != nil { t.Fatal(err) } + reopened, err := openRestoreAnchorRegistry(path) + if err != nil { t.Fatal(err) } + entries := reopened.list(50) + if len(entries) != 1 || entries[0].ID != entry.ID || entries[0].HeadHash != anchor.HeadHash { t.Fatalf("entries=%#v", entries) } +} + +func TestRestoreAnchorExportRegistersHistoryAPI(t *testing.T) { + db := restoreTestDB(t) + dir := t.TempDir() + recordingPath := filepath.Join(dir, "recordings.json") + rs, err := openRecordingStore(recordingPath) + if err != nil { t.Fatal(err) } + receipts, err := openRestoreReceiptStore(recordingPath + ".restore-receipts.json") + if err != nil { t.Fatal(err) } + if _, err := receipts.add(restoreReceipt{Mode: "full", ImportedRecordings: 1}); err != nil { t.Fatal(err) } + mux := http.NewServeMux() + registerRecordingRestoreHandler(mux, db, rs, filepath.Join(dir, "audio")) + + exportReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchor", nil) + exportW := httptest.NewRecorder() + mux.ServeHTTP(exportW, exportReq) + if exportW.Code != http.StatusOK { t.Fatalf("export status=%d body=%s", exportW.Code, exportW.Body.String()) } + + historyReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchors", nil) + historyW := httptest.NewRecorder() + mux.ServeHTTP(historyW, historyReq) + if historyW.Code != http.StatusOK { t.Fatalf("history status=%d body=%s", historyW.Code, historyW.Body.String()) } + var entries []restoreAnchorRegistryEntry + if err := json.Unmarshal(historyW.Body.Bytes(), &entries); err != nil { t.Fatal(err) } + if len(entries) != 1 || entries[0].ReceiptCount != 1 { t.Fatalf("entries=%#v", entries) } + + statusReq := httptest.NewRequest(http.MethodGet, "/api/recording-archive/receipts/anchors/status", nil) + statusW := httptest.NewRecorder() + mux.ServeHTTP(statusW, statusReq) + if statusW.Code != http.StatusOK { t.Fatalf("status=%d body=%s", statusW.Code, statusW.Body.String()) } + var status restoreAnchorRegistryStatus + if err := json.Unmarshal(statusW.Body.Bytes(), &status); err != nil { t.Fatal(err) } + if status.Status != "current" || status.ReceiptsSince != 0 { t.Fatalf("status=%#v", status) } +} From 287073dcb1dee404c253184614de47146083753f Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 9 Sep 2026 00:53:19 +0200 Subject: [PATCH 4/5] M5.8: document anchor registry --- docs/M5_8.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 docs/M5_8.md diff --git a/docs/M5_8.md b/docs/M5_8.md new file mode 100644 index 0000000..f387f04 --- /dev/null +++ b/docs/M5_8.md @@ -0,0 +1,19 @@ +# M5.8 — Local restore-anchor registry + +M5.8 adds local operational history around the M5.7 external receipt-chain anchor. + +## Scope + +- Every successful `GET /api/recording-archive/receipts/anchor` request records metadata for the exported anchor in a separate local registry. +- The registry stores only an ID, UTC export time, receipt count and head hash. It does not store archive/audio payloads. +- `GET /api/recording-archive/receipts/anchors?limit=50` returns newest registered exports first. Limit must be 1–200. +- `GET /api/recording-archive/receipts/anchors/status` compares the latest registered anchor with the current verified receipt chain. +- Status is `never-anchored`, `current`, `extended`, or `mismatch`. +- `receipts_since_last_anchor` reports chain growth since the latest registered anchor. +- Registry persistence is a separate atomic JSON file next to recording data with mode `0600`. + +## Trust boundary + +The registry is operational metadata, not an independent trust anchor. A local attacker able to rewrite the appliance data can also rewrite this registry. M5.7 security still depends on retaining the downloaded anchor outside the appliance trust boundary. M5.8 only makes it easier to see when an anchor was requested/exported and how far the local receipt chain has grown since then. + +An HTTP export being registered means the server accepted and generated the export response; it cannot prove that the client safely retained the downloaded file. From e5c07ee2d12fa77778a81ef6b88c951541a18472 Mon Sep 17 00:00:00 2001 From: pos Date: Wed, 9 Sep 2026 00:53:42 +0200 Subject: [PATCH 5/5] M5.8: document local anchor history --- README.md | 119 ++++++++---------------------------------------------- 1 file changed, 16 insertions(+), 103 deletions(-) diff --git a/README.md b/README.md index 30bfd18..5217a77 100644 --- a/README.md +++ b/README.md @@ -3,93 +3,21 @@ Self-hosted, local-first tools for numbers stations, shortwave monitoring and signal-analysis workflows. -## Current milestone: M5.7 - -The application currently provides: - -- local station catalogue -- structured observation logbook -- persistent UTC schedules and frequencies -- server-side `on now / next` schedule evaluation in UTC -- schedule-to-observation logging -- recording metadata linked to observations -- managed local WAV/FLAC upload under `/data/audio` -- automatic size, SHA-256, duration, sample rate and channel metadata -- compact waveform previews for managed PCM WAV recordings -- compact 64-bin frequency magnitude previews for managed PCM WAV recordings -- compact 24×32 time-frequency mini-spectrograms for managed PCM WAV recordings -- compact local signal fingerprints and deterministic recording similarity search -- local fingerprint clustering with configurable similarity threshold -- exact file-duplicate flagging from matching SHA-256 values inside clusters -- persistent local cluster review/classification with notes and UTC review time -- membership-stable cluster identifiers so reviews only attach to the reviewed member set -- inline local playback for managed recordings with HTTP byte-range seeking -- synchronized playback cursor across waveform and mini-spectrogram previews -- click/drag seeking directly on waveform and mini-spectrogram previews -- keyboard seeking on focused previews with arrow, Home and End keys -- temporary A-B loop regions with repeated playback and highlighted selection -- persistent recording point bookmarks and timestamp-interval annotations -- visual point/interval annotation markers directly on waveform and spectrogram previews -- edit existing annotations while preserving their stable annotation IDs -- direct Bookmark creation by right-clicking a waveform/spectrogram position -- keyboard Bookmark creation with `B` on a focused seek preview -- fixed annotation categories: `call-up`, `station ID`, `message`, `tone`, `noise`, `fade`, `other` -- per-recording annotation filtering by category with category-specific marker styling -- cross-recording annotation search by type, label and notes with direct jump-to-recording/timestamp -- versioned JSON annotation export/import with conservative recording matching and duplicate suppression -- flat CSV annotation export for archival and offline analysis -- versioned metadata-only recording/annotation manifest export with checksums and provenance -- full portable `tar.gz` archival export containing `manifest.json` plus verified managed WAV/FLAC files -- safe restore/import of M5.0 archives with staged extraction, checksum validation and collision protection -- dry-run restore planning with per-recording `import`, `duplicate`, `conflict` and `unmatched` classification -- selective restore of operator-chosen `import` candidates from a validated plan -- deterministic SHA-256 plan tokens binding selective restore to the reviewed archive and local state -- strict rejection of stale plan tokens after archive, recording, annotation or observation changes -- strict rejection of selected duplicates, conflicts, unmatched observations and unknown recording IDs -- persistent local restore receipts for successful full and selective restores -- SHA-256 receipt hash chaining with `previous_hash` and `receipt_hash` -- read-only receipt-chain verification with tamper detection and chain head reporting -- deterministic external receipt-chain anchor export with receipt count and chain-head hash -- verification of saved/offline anchors against the current chain, including valid later extensions -- read-only restore audit history with mode, UTC time, plan token, selection and result counts -- per-recording annotation import/skip counts before restore -- local rebuild of waveform, frequency, spectrogram and fingerprint data from restored managed audio -- local JSON persistence under `/data` -- embedded web UI and JSON API -- dependency-free Go build and non-root OCI runtime - -Cluster reviews support `same transmission`, `same station`, `false positive`, -and `duplicate capture`. They are stored locally alongside recording metadata and -do not require AI, an API key, or an external service. - -Managed playback reads audio directly from the appliance. Browser-native codec -support determines whether a WAV/FLAC recording can be played; M5.7 does not -transcode audio or send it to an external service. Saved timestamp annotations -persist in `recordings.json`, can be searched across the archive, exported as JSON -or CSV, and safely imported from the versioned JSON format. - -M5.7 extends the M5.6 tamper-evident receipt chain with a small external anchor -format. `GET /api/recording-archive/receipts/anchor` downloads a deterministic JSON -object containing `version`, `receipt_count` and `head_hash`. Store that file on a -separate machine, offline medium, Git repository, backup system or other independent -location. `POST /api/recording-archive/receipts/anchor/verify` verifies the saved -anchor against the current local chain. A matching prefix remains valid if legitimate -new receipts have been appended after the anchor was created; rewriting an anchored -prefix changes its hash and is detected. No external provider or API key is required. - -The anchor is evidence only when an independent copy is retained outside the same -trust boundary as the receipt store. It is not a digital signature, trusted timestamp -or proof against an attacker who can replace both the local chain and every external -copy of its anchor. - -The application does not require an SDR, receiver, API key or external data -provider for these workflows. - -See [docs/M5_7.md](docs/M5_7.md) for the current scope. +## Current milestone: M5.8 -## Run with Go +The application currently provides a local station catalogue, structured observations and UTC schedules; managed WAV/FLAC recording ingest and playback; waveform, frequency, spectrogram and fingerprint analysis; clustering and human review; timestamp annotations with search and transfer; portable metadata manifests and verified `tar.gz` archives; safe dry-run, selective and full restore; token-bound restore planning; and tamper-evident restore receipts. + +M5.7 added deterministic external receipt-chain anchors. M5.8 adds a separate local registry of anchor export requests so the operator can see when the chain was last anchored and how many receipts have accumulated since then. The registry stores only anchor metadata (ID, UTC export time, receipt count and head hash), never archive or audio payloads. + +`GET /api/recording-archive/receipts/anchors/status` reports `never-anchored`, `current`, `extended`, or `mismatch`, plus `receipts_since_last_anchor`. `GET /api/recording-archive/receipts/anchors?limit=50` returns anchor-export history newest first. A successful anchor download request is registered automatically. + +The local registry is operational metadata, not an independent trust anchor. M5.7's security benefit still requires keeping the downloaded anchor outside the appliance trust boundary. Registration proves that the server generated an export response; it cannot prove that the client safely retained the file. -For a host run, select writable data paths: +Core workflows remain local-first and require no SDR, API key or external provider. + +See [docs/M5_8.md](docs/M5_8.md) for the current scope. + +## Run with Go ```sh NUMBER_STATION_TOOLS_DATA=./number-station-tools.json \ @@ -121,6 +49,8 @@ Then open . - `GET /api/recording-archive/receipts?limit=50` - `GET /api/recording-archive/receipts/verify` - `GET /api/recording-archive/receipts/anchor` +- `GET /api/recording-archive/receipts/anchors?limit=50` +- `GET /api/recording-archive/receipts/anchors/status` - `POST /api/recording-archive/receipts/anchor/verify` - `POST /api/recording-archive/plan` - `POST /api/recording-archive/import-selected?recording_id=...&plan_token=...` @@ -142,24 +72,7 @@ Then open . ## Direction -Number Station Tools is intended to grow into a workbench for: - -- station identifiers, aliases and historical metadata -- UTC transmission schedules and frequencies -- structured observation logging -- message and group transcription -- WAV/FLAC recording archive and interactive playback -- waveform, frequency, spectrogram, fingerprint and signal-analysis workflows -- categorized timestamp annotations, bookmarks and operator notes on recordings -- cross-recording annotation search, portable transfer and timeline navigation -- portable recording manifests, verified archival bundles, token-bound dry-run planning, policy-controlled restore and externally anchorable tamper-evident audit receipts -- human review and classification of signal matches and repeated transmissions -- optional SDR and network-receiver integrations -- optional data-provider imports -- local-first archival and search - -Core functionality should remain useful without API keys, and receiver, -recording and observation data should remain local by default. +Number Station Tools is intended to grow into a local-first workbench for station metadata, schedules, observation logging, message transcription, recording archival/playback, signal analysis, annotations, portable verified archives, policy-controlled restore, and independently anchorable tamper-evident audit history. Optional SDR/network-receiver and data-provider integrations may be added later without making them requirements for core use. ## License