Table of Contents

Dataset Collections — Design Note

Status: Design — no production code in this PR. This note is the contract the implementation slices (tracked by epic #655) build against. Open questions are collected in §10.

0. Scope of this note

Today the viewer treats data as files to open. The user finds an exchange set folder, an exchange-set ZIP, or a loose dataset in a file explorer, opens it, and the viewer loads it. Loading happens right away, or for large S-57 exchange sets it is deferred until the user pans over the area. The only thing the user knows about a file before opening it is its name and its parent directory. Recent files help only with loose files, because exchange sets and folders are never recorded.

That works for testing specific scenarios. It is not how people work with charts. They think in collections: "all ENCs for Alaska", "the NOAA S-102 tiles for the Southeast", "the IC-ENC test set", or "my inland corpus". They want to find what is available, see where it is and what it is, and load only what they need.

This note introduces dataset collections:

  • A collection is a named, persisted grouping of exchange sets and/or datasets. It is built from one or more sources.
  • Importing a collection indexes metadata about every dataset in it: product, name, edition and update, issue date, scale band, and coverage. It does not load the datasets.
  • The viewer gives quick access to the indexed items. There is a Library panel, coverage outlines on the map, and load, load-as-you-pan and download actions.

The first slices cover two scenarios together, so the design is proven for both local and online sources:

  1. Local sources — folders of exchange sets (S-100 CATALOG.XML and S-57 CATALOG.031) and loose datasets, referenced in place.
  2. The NOAA ENC online feed (ENCProdCat.xml) — browse coverage before downloading, then download individual cells.

The existing Catalog panel (S-128 only) is replaced by the Library. S-128 becomes one kind of collection source.


1. Background

1.1 What the viewer does today

Area Today Where
Open File menu (Open Dataset, Open Exchange Set folder/ZIP), drag-drop, and CLI positional args. Everything loads right away. MainWindow.axaml.cs (OpenDatasetAsync, RunExchangeSetAsync, OnDrop), ExchangeSetService.OpenAsync
Exchange set detection CATALOG.XML (S-100), CATALOG.031 (S-57), and loose-cell folders Services/ExchangeSetDetection.cs
Deferred loading S-57 only, and only above 50 cells. Viewport and usage-band gating with LRU eviction. Extents come from the CATD bounding boxes. Services/LazyLoading/ExchangeSetLazyLoadCoordinator.cs, LazyCellGate.cs, ExchangeSetCellRegistration
Recent files ViewerSettings.RecentDatasetPaths (10 entries). Only single files are recorded, never exchange sets or folders. Services/RecentFilesService.cs, DatasetLoaderService.cs:463
Metadata peek IDatasetMetadataReader → DatasetMetadata (spec, extent, CRS, display scale, time), with a disk cache Services/IDatasetMetadataReader.cs, Core/Metadata/
Catalog panel IDatasetCatalogSource → DatasetCatalogEntry, aggregated by DatasetCatalogAggregator. Fed only by loaded S-128 datasets. Coverage is a single ring. Nothing is drawn on the map. Viewer/Catalogs/, CatalogPanelViewModel, CatalogPanelView.axaml
Extent overlay Dashed rectangles for deferred or out-of-scale datasets DatasetExtentIndicatorController, S100DatasetExtentIndicatorLayer
Persistence settings.json, plus a separate JSON store for routes. All paths go through ViewerDataPaths (--data-dir / S100_DATA_DIR). ViewerSettings, Routing/Persistence/RouteStore.cs, ViewerDataPaths
HTTP GitHub release checks and basemap tiles only. There is no catalogue fetching. Services/Updates/GitHubReleaseClient.cs

Gaps this design must close:

  • The S-100 DataCoverage.BoundingPolygon is kept as an unparsed GML string (ExchangeCatalogueReader.cs:300).
  • S-57 CATALOG.031 CATD records have no edition, update or issue date. Those live in each cell's DSID record. The EncDotNet.S57 package exposes them as EditionNumber, UpdateNumber, IssueDate and UpdateApplicationDate.
  • The extent overlay can only draw rectangles, and catalogue coverage is never drawn.
  • ExchangeSetService opens a whole exchange set. There is no way to open a chosen subset of its datasets.

1.2 Prior art: the older EncDotNet viewer

The older S-57 viewer (EncDotNet/src/EncDotNet.Noaa, EncDotNet.ChartViewer) fetched ENCProdCat.xml and grouped cells by US state into "packages". A setup wizard and a Manage Charts dialog installed and uninstalled whole states. It diffed the selected states and correctly kept cells shared between states.

Lessons to carry forward:

  • Keep:
    • a provider abstraction (IChartPackageManager)
    • streamed downloads with progress and cancellation
    • skipping files that are already on disk
    • NOAA long names as titles
  • Fix:
    • The catalogue was never cached. It was fetched again on every action.
    • Coverage polygons were parsed but never used. Outlines were CATALOG.031 bounding boxes, and only for installed charts.
    • Editions were never compared, so there was no update detection.
    • A partly downloaded zip was later treated as complete.
    • Selection was per state only.
    • Chart counts were inflated for cells listed under several states.
    • Download sizes were computed and then thrown away.

1.3 External catalogue formats (verified 2026-09-24)

Family Instances Coverage Fields of interest
NOAA product catalogue XML https://charts.noaa.gov/ENCs/ENCProdCat.xml (≈10.6 MB, regenerated daily, ≈7,345 cells: 7,117 active, 228 cancelled). Per-area bundles *_ENCs.zip and *_ENCProdCat_19115.xml by state, Coast Guard district and region. Single cells at ENCs/<CELL>.zip. <cov><panel><type>E\|I</type><vertex><lat/><long/> — exterior and interior rings name, lname, cscale, status, states, coast_guard_districts, regions, zipfile_location, zipfile_datetime_iso8601, zipfile_size, edtn, updn, uadt, isdt
S-100 exchange catalogue (Part 17, 5.x) Local CATALOG.XML. Also online: NOAA S-102/S-104/S-111 on AWS Open Data (noaa-s102-pds etc.), served gzip-compressed with relative fileNames. GML boundingPolygon (EPSG:4326, lat-lon axis order) plus boundingBox fileName, edition, update, issue date/time, productSpecification, producingAgency, display scales, approximateGridResolution, temporalExtent
S-128 Catalogue of Nautical Products Ed 2.0.0 adopted July 2025 (IHO CL 31/2025). Trial instances from UKHO and PRIMAR. Already parsed by EncDotNet.S100.Datasets.S128. CoverageRing product number, edition, update, issue/update date, spec name/version, status
USACE inland ENC (variant of the NOAA format) ienccloud.us/ienc/products/catalog/IENCU37ProductsCatalog.xml bbox only name, river, river miles, edition, S-57/SHP/KML file links
OpenCPN chart_sources.xml A catalogue of ≈97 catalogues — A directory of feeds, not datasets. GPL source data: not bundled or fetched (issue #670); the viewer keeps its own curated list
Community chartcatalogs lists (RncProductCatalogChartCatalogs, a subset of NOAA's RNC catalogue) raw.githubusercontent.com/chartcatalogs/catalogs/master/<NAME>_Catalog.xml, CC0 none number, title, zipfile_location, zipfile_datetime_iso8601, target_filename. An entry is a download (one cell, several, or a whole exchange set; sometimes a bare .000)
SECOM (IEC 63173-2) REST: GetSummary / Get. Several anonymous S-124/S-122 servers verified 2026-10-06 (§7.5). Chart products need an MCP certificate. None in the summary. A WKT query filter, and service geometry in the registry dataProductType, containerType, info_*

2. Goals and non-goals

Goals

  1. A user can import a collection from:
    • a local folder (scanned recursively)
    • an exchange-set folder or ZIP
    • the NOAA ENC feed, optionally scoped to states, districts or regions (for example "NOAA ENC — Alaska")
    • an S-128 catalogue file
  2. Importing produces a persisted index. It survives restarts and can be refreshed on demand. The datasets themselves are not loaded.
  3. The Library panel lists collections and their items with metadata, and supports filtering by product, usage band, availability and text.
  4. Coverage outlines on the map, drawn from indexed polygons, with styling by availability and gating by band and scale.
  5. Load a chosen item or subset now, or register it for load as you pan. S-100 as well as S-57.
  6. Download NOAA cells (one, a selection, or those in view) into a viewer-managed folder. Afterwards they behave as local items.
  7. Local sources are references: files are never copied or moved.

Non-goals for the first slices

  • Copying local sources into a managed library at import time. This may become an import option later.
  • Update detection and bulk "update all". The data model carries edition and update so this can be added later (§9).
  • Other online feeds (NOAA S-102/S-104/S-111 on AWS, USACE, the OpenCPN catalogue list), SECOM, and S-128 export (§9).
  • Automatic re-indexing when files change on disk. Refresh is manual in the first slices.
  • Decrypting S-63 or S-100 Part 15 protected data at index time. Items are indexed from catalogue metadata. Protection is handled at load time, as it is today.

3. Concepts

Collection  ──1..n──▶  Source  ──indexes──▶  SourceIndex  ──0..n──▶  CollectionItem
 (named,                (local folder,        (snapshot +             (one dataset:
  persisted)             exchange set,         fingerprint,            metadata +
                         NOAA feed, S-128)     cached on disk)         coverage + location)
  • Collection — what the user names and manages ("Alaska ENCs"). It owns an ordered list of sources.
  • Source — where the items come from. Each kind has an indexer that turns it into items. Sources are persisted by reference: a path or a URL plus options, never the data itself.
  • Source index — the result of indexing one source. It holds the items plus a fingerprint used to decide whether a refresh is needed. For files that is the catalogue's mtime and size. For HTTP it is the ETag or Last-Modified. It is cached separately from collections.json, because it can be large (the NOAA index is about 7k polygons).
  • Collection item — one dataset. It is the neutral successor to DatasetCatalogEntry.
  • Availability — computed in the viewer, never persisted:
State Meaning
Listed Catalogue-only. The source describes the product but has no data for it (S-128 entries).
Online A remote location is known. No local copy.
Local A local copy exists and is not loaded.
Deferred Registered with the lazy loader. It loads when in view and in band.
Loaded Currently loaded in the session.
Missing Its local path no longer exists (moved or deleted reference).

The model leaves room for Outdated (the remote edition or update is newer than the local copy) without committing to it now.


4. Library model (EncDotNet.S100.Collections)

A new, UI-free project. The viewer, CLI and MCP server can all use it, for example to answer "which datasets in my library cover this point?" It references EncDotNet.S100.Core, EncDotNet.S100.ExchangeSets, EncDotNet.S100.Datasets.S57 and EncDotNet.S100.Datasets.S128. It deliberately does not reference Datasets.Pipelines, the "god assembly" noted in #598 Chunk C. Loose-file probing is injected (§5.3).

4.1 Types (sketch)

public sealed record DatasetCollection(
    Guid Id,
    string Name,
    IReadOnlyList<CollectionSource> Sources,
    DateTimeOffset CreatedAt);

// Polymorphic; persisted with a "kind" discriminator.
public abstract record CollectionSource(Guid Id, string? DisplayName);
public sealed record LocalFolderSource(Guid Id, string? DisplayName, string Path, bool Recursive = true)
    : CollectionSource(Id, DisplayName);
public sealed record ExchangeSetSource(Guid Id, string? DisplayName, string Path)   // folder, ZIP, or catalogue file
    : CollectionSource(Id, DisplayName);
public sealed record NoaaEncFeedSource(Guid Id, string? DisplayName, Uri CatalogUri, NoaaEncFilter Filter)
    : CollectionSource(Id, DisplayName);
public sealed record S128CatalogueSource(Guid Id, string? DisplayName, string Path)
    : CollectionSource(Id, DisplayName);
public sealed record LocalManifestSource(Guid Id, string? DisplayName, string Path, LocalManifestFilter Filter)  // *.s100collection.json (§5.6)
    : CollectionSource(Id, DisplayName);

public sealed record NoaaEncFilter(
    IReadOnlyList<string> States,              // "AK"
    IReadOnlyList<int> CoastGuardDistricts,
    IReadOnlyList<int> Regions,
    bool IncludeCancelled = false);            // empty lists = no filter on that axis

public sealed record SourceIndex(
    Guid SourceId,
    DateTimeOffset IndexedAt,
    string Fingerprint,
    IReadOnlyList<CollectionItem> Items,
    IReadOnlyList<IndexDiagnostic> Diagnostics);

public sealed record CollectionItem
{
    public required string Key { get; init; }            // stable within the source (§4.2)
    public required string ProductSpec { get; init; }    // "S-57", "S-101", "S-102", ...
    public string? ProductSpecVersion { get; init; }
    public required string Name { get; init; }           // "US5AK1AM", "102US00_..."
    public string? Title { get; init; }                  // NOAA lname, catalogue description
    public string? GroupKey { get; init; }               // exchange set this item belongs to, if any
    public int? Edition { get; init; }
    public int? Update { get; init; }
    public DateOnly? IssueDate { get; init; }
    public DateOnly? UpdateApplicationDate { get; init; }
    public int? CompilationScale { get; init; }
    public int? MinimumDisplayScale { get; init; }
    public int? MaximumDisplayScale { get; init; }
    public int? UsageBand { get; init; }                 // S-57 name digit / NOAA band
    public CollectionItemStatus Status { get; init; }    // Active, Cancelled, Unknown
    public GeoBounds? Bounds { get; init; }              // EPSG:4326; null = listed but not drawn
    public GeoCoverage? Coverage { get; init; }          // polygons; null = bounds only
    public required ItemLocation Location { get; init; }
    public IReadOnlyDictionary<string, string> Properties { get; init; } // producer, states, size text, ...
}

public abstract record ItemLocation;
public sealed record LocalItemLocation(
    string RootPath,                 // exchange-set root or folder; the IAssetSource root
    string RelativePath,             // base file
    IReadOnlyList<string> UpdateRelativePaths,
    bool RootIsZip) : ItemLocation;
public sealed record RemoteItemLocation(Uri Uri, long? SizeBytes, DateTimeOffset? LastModified) : ItemLocation;
public sealed record NoItemLocation : ItemLocation;      // S-128 "Listed" entries

// EPSG:4326 multi-polygon; each polygon = exterior ring + holes, (lat, lon) GeoPosition vertices.
public sealed record GeoCoverage(IReadOnlyList<GeoPolygon> Polygons);
public sealed record GeoPolygon(IReadOnlyList<GeoPosition> Exterior, IReadOnlyList<IReadOnlyList<GeoPosition>> Holes);

Notes:

  • Bounds is present for every catalogued item, so hit-testing, culling and lazy-load gating never depend on polygons. It is null only for a loose file whose extent cannot be read cheaply, for example an unprobed file or a projected S-102 grid. Such an item is listed but not drawn until it is loaded. (This was changed from "always present" during slice 1.) Coverage is optional because some sources have bounding boxes only (CATALOG.031 without a deeper scan, USACE).
  • Edition and update are typed as integers. S-128 carries them as text, and those values that don't parse go into Properties. This makes update comparison (§9) straightforward.
  • GeoCoverage replaces the viewer's single-ring DatasetCatalogCoverage.
  • Antimeridian-crossing coverage matters for Alaska and the Aleutians. Rings are stored as published. The overlay normalizes them for display (§6.2), and Bounds records a crossing as West > East, the same convention as BoundingBox.

4.2 Item identity

(collectionId, sourceId, item.Key) identifies an item. Key rules:

  • S-57: cell name (US5AK1AM).
  • S-100 exchange set: the exchange-set root's relative path plus the dataset's catalogue fileName.
  • Loose file: the path relative to the folder source.
  • NOAA: cell name.
  • S-128: product number.
  • Collection manifest: <group id>:<manifest-relative path>/ plus the key the item would have under a folder source at that path (§5.6).

The same physical dataset can appear in several collections. The viewer loads it once, keyed by its resolved absolute path (and the member path for ZIPs), and every item that resolves to that path shows as Loaded.

4.3 Indexers

public interface ICollectionSourceIndexer
{
    bool CanIndex(CollectionSource source);

    /// Cheap check: returns the current fingerprint, or null if it cannot be determined.
    ValueTask<string?> GetFingerprintAsync(CollectionSource source, CancellationToken ct);

    ValueTask<SourceIndex> IndexAsync(
        CollectionSource source, IProgress<IndexProgress>? progress, CancellationToken ct);
}

A CollectionIndexer facade picks the indexer and does the fingerprint short-circuit: if the fingerprint is unchanged, the cached index is reused.


5. Indexing

5.1 Local folder walk (LocalFolderSource)

  1. Walk the tree once. A directory that contains CATALOG.XML (or the alternate spellings in ExchangeSetDetection) or CATALOG.031 is an exchange-set root. It is indexed by §5.2 and the walk does not descend into it to look for loose files.
  2. A .zip that contains a catalogue is indexed as an exchange set with RootIsZip.
  3. Any other candidate file becomes a loose dataset, indexed by §5.3. Candidates are .000, .h5 and .gml. .xml counts only when it is not a catalogue or support file.
  4. S-57 update files (.001 and up) outside an exchange set are attached to their base cell as UpdateRelativePaths. This is the same grouping the loose-cell-folder path already does (ExchangeSetDetection.EnumerateLooseBaseCells).

Slice 1 implements the walk in LocalSourceScanner and leaves the viewer's ExchangeSetDetection untouched. Consolidating the two is part of slice 3 (§10 Q4).

5.2 Exchange sets

  • S-100 CATALOG.XML: ExchangeCatalogueReader with ExchangeCatalogueReadOptions.DiscoveryOnly. That option skips digital signatures and certificates. Their strict Part 15 validation otherwise fails the whole catalogue, and about 120 IC-ENC sets in the local corpus have malformed signature blocks. There is one item per DatasetDiscoveryMetadata. Edition and update come from the catalogue.
    • New: parse DataCoverage.BoundingPolygon GML (gml:Polygon / gml:exterior / gml:posList, EPSG:4326 lat-lon axis order) into GeoCoverage. Fall back to BoundingBox.
    • Display scales come from the existing ResolveMinimum/MaximumDisplayScale().
    • Updates are grouped per base using the same rules as S101ExchangeSetUpdatePlan.
  • S-57 CATALOG.031: S57ExchangeSetCatalog.ReadBaseCells. It gives the cell name, relative path, updates and the CATD bounding box.
    • New: S57DatasetHeader (in Datasets.S57) reads EDTN, UPDN, ISDT, UADT and the compilation scale for each base cell and its latest update. It copies only the DDR, DSID and DSPM records (a few KB, sized from each record's leader) and hands them to the upstream parser, so no upstream EncDotNet.S57 change was needed.
    • The peek is not cached per cell. The source-level fingerprint already skips re-indexing an unchanged source. Measured on 7.3k S-57 cells plus about 1k S-100 datasets: a full index takes 3.3 s, and an unchanged refresh check takes about 1 s.
    • Coverage is the CATD bounding box at first. M_COVR polygons are an optional "deep index" later, because they require parsing the cell.
    • The usage band comes from CellUsageBand.TryParse.

5.3 Loose datasets

The library defines a probe:

// Returns Core's DatasetMetadata (spec, extent, CRS, display scale, time); null = not a dataset.
public delegate DatasetMetadata? DatasetProbe(string path, CancellationToken cancellationToken);

The viewer supplies a probe backed by DatasetPipelineFactory.DetectProductSpec plus its existing CachingDatasetMetadataReader. That reuses the per-spec ReadMetadata code and its disk cache without dragging Pipelines into the library.

5.4 NOAA ENC feed (NoaaEncFeedSource)

  • Fetch with a conditional GET (If-None-Match / If-Modified-Since). Cache the raw XML under <data>/cache/collections/feeds/. The fingerprint is the ETag/Last-Modified pair.
    • A 304 response, or being offline, reuses the cached file.
    • Never fetch on every action. That was a lesson from §1.2.
  • Parse with a streaming XmlReader. The file is ≈10 MB, so there is no XmlSerializer tree. Map each <cell> to an item:
    • name → Name/Key
    • lname → Title
    • cscale → CompilationScale
    • edtn / updn / isdt / uadt
    • status → Status
    • usage band from the name
    • states / coast_guard_districts / regions / zipfile_size → Properties
    • <cov><panel> rings → GeoCoverage. Type E is an exterior ring and I is a hole.
    • zipfile_location / zipfile_size / zipfile_datetime_iso8601 → RemoteItemLocation
  • Filter by NoaaEncFilter at index time. The unfiltered raw feed is cached once and shared by every NOAA source.
  • Local copies: a cell downloaded for this source (§7.3) lives at <data>/downloads/noaa-enc/<CELL>/. Its location is resolved at runtime, so the persisted index stays remote-only: if a local copy exists, the item is Local, otherwise Online. No separate index is needed for downloads.
  • Longitudes (found in slice 2): NOAA writes western-Pacific coverage with continuous longitudes past −180, for example −219.5 for 140.5°E. Grid cells end exactly on −180. Rings are kept as published. GeoBounds.FromPositions normalises them by wrapping the west edge and carrying the span, so only cells that genuinely straddle ±180 are recorded as crossing: two of Alaska's 1,193.
  • Filter semantics: the selected states, districts and regions are unioned. Cancelled cells are excluded unless IncludeCancelled is set.
  • Revalidation: a cached catalogue confirmed within FeedCacheOptions.RevalidationInterval (15 minutes by default) is used without any request. After that, a conditional GET is sent. When the server cannot be reached, the cached copy is served with a warning, and its unchanged fingerprint keeps an existing index. Measured on the live feed: 7,117 cells indexed in 0.6 s, and a 304 revalidation in 10 ms.
  • To pick presets, the import dialog reads the distinct states, coast_guard_districts and regions values (with counts) from the feed after the first fetch, so nothing is hardcoded.

5.5 S-128 catalogue file (S128CatalogueSource)

S128Dataset.Open → S128ProductEntry → item:

  • Edition and update are parsed to integers, and the text is kept.
  • CoverageRing → GeoCoverage.
  • Status is mapped from S128ProductStatus.
  • Location is NoItemLocation unless the entry carries an online resource, in which case it is RemoteItemLocation.
  • The details that S128DatasetCatalogSource maps today move here unchanged.

5.6 Collection manifest (LocalManifestSource)

Added 2026-09-30. A *.s100collection.json file names groups of local paths, such as one per producing country (see Local collection manifests). It is the first local source with facets.

  • Indexing. LocalManifestIndexer re-reads the manifest on every refresh, so the source is a live reference. It then indexes each path of each selected group through LocalSourceIndexer.IndexPath, which is the same code a folder source uses.
  • Item keys and tags. Keys are prefixed with the group id and the path, so a folder listed by two groups gives two distinct items. Items carry group and groupName properties.
  • Groups on the index. SourceIndex.Groups records the selected groups and how many of their paths were missing. This lets the viewer show a group with no datasets.
  • Fingerprint. It hashes the manifest's content (not its timestamp, because sync clients touch timestamps), the selection, and the scan fingerprint of each selected path.
  • Errors. An unreadable manifest gives an error diagnostic and no fingerprint, and never an exception. A missing path gives a warning.

6. Viewer

6.1 Persistence

  • <data>/collections.json is authoritative, small, and edited by the user. It holds the collections and their sources. It is modelled on RouteStore: versioned document, debounced atomic save, and migration hooks.
  • <data>/cache/collections/<sourceId>.index.json.gz holds the derived cache, one per source. It is safe to delete, and re-indexing rebuilds it. It is registered in ViewerDataPaths' cache list so "clear caches" covers it.
  • <data>/downloads/noaa-enc/ holds managed downloads. It is not a cache, because users expect downloaded charts to survive "clear caches".
// collections.json
{
  "version": 1,
  "collections": [
    {
      "id": "7c1e…", "name": "Alaska ENCs", "createdAt": "2026-09-24T…",
      "sources": [
        { "kind": "noaaEncFeed", "id": "a31f…", "catalogUri": "https://charts.noaa.gov/ENCs/ENCProdCat.xml",
          "filter": { "states": ["AK"], "coastGuardDistricts": [], "regions": [], "includeCancelled": false } },
        { "kind": "localFolder", "id": "b90c…", "path": "/Users/…/Charts/AK", "recursive": true }
      ]
    }
  ]
}

6.2 Services

  • CollectionService (singleton):
    • Loads the store.
    • Owns the indexer and runs indexing in the background, with progress in the existing status/toast surface and cancellation.
    • Exposes an immutable snapshot of (collection → items) plus a coarse Changed event, the same pattern as IDatasetCatalogSource.
    • Computes availability by joining items with the loaded-dataset registry (DatasetsViewModel entries keyed by resolved path), the lazy-load coordinator, the download folder, and File.Exists.
  • CollectionLoadService turns items into loads (§7).
  • CollectionCoverageOverlay replaces the need to extend DatasetExtentIndicatorController:
    • It is a Mapsui MemoryLayer of polygons, added through IMapLayerCollection.AddOverlayLayer.
    • Web Mercator geometry is precomputed once per index snapshot.
    • Features are gated by band and scale, reusing LazyCellGate's band-for-scale rule. Only items appropriate to the current zoom are drawn, so the ≈7k NOAA outlines don't flood the view at small scales.
    • Styling by availability: Online = dashed and hollow, Local = solid outline, Deferred = outline plus a light tint, Loaded = no outline by default (the data speaks for itself), Missing = red dashed.
    • Hover and selection highlight. A map click hit-tests Bounds first, then the polygon, and selects the item in the Library panel. The reverse also works: selecting in the panel highlights the outline and offers "Zoom to".
    • Antimeridian: rings that cross ±180° are split or shifted, the same way DatasetExtentIndicatorController does for boxes.
  • Context menu on the map: "Datasets in library here…" lists the items whose coverage contains the clicked point, across all collections.

Slice 4 as built:

  • What is drawn: LibraryCoverageOverlayController outlines exactly what the Library list shows, after its filters. It is active only while the Library tab is showing, and the panel has a coverage toggle.
  • Band gating: a plain IsBandEligible rule (the lazy-load rule) was too sparse for browsing: at an Alaska-wide view only band 1 showed. Outlines use a two-band window instead: the finest band suited to the scale plus the next finer one.
  • Antimeridian: rings are unwrapped to continuous longitudes, then drawn at each ±360° shift that overlaps the world. That handles both NOAA's continuous longitudes and S-100's jumping rings without splitting polygons.
  • Map clicks: instead of a context menu, a plain map tap outside Pick Mode (MapInteractionController.PlainTapped) sets a location filter on the panel. The list shows every library dataset under the tap, most detailed first, and repeated taps cycle the selection.
  • Zoom to: "Zoom to" frames the selected dataset.
  • Not built: hover highlighting.

6.3 Library panel (replaces the Catalog panel)

UX refinement (2026-09-28): a design pass reworked the panel. Now:

  • Vocabulary: "dataset" everywhere; "package" for community entries; sentence-case states.
  • Rows: a swatch drawn like the map outline (LibraryOutlineStyles, shared with the overlay, which now outlines by the primary state), plus secondary-state tags.
  • Tree: kind tags (DIR / ZIP / WEB / LIST / FEED / S-128) and a status line only when off-normal. A shared feed's reachability comes from FeedHealth.
  • Filter: the count sits inside the filter box, with Local / Online / Updates segments.
  • Bulk bar: one bar with download progress and Cancel, backed by per-item and batch status on ILibraryDownloader.
  • Map-tap banner: a count, the position and Next.
  • Details: grouped fields with labelled actions.
  • Packages: community packages become "Unpacked" groups after download.
  • Shared feeds: "Connect to a shared feed…", named after the serving machine (machine in the feed JSON), with the token masked.
  • Empty state: three large targets.

Slice 3 as built:

  • The service is LibraryService, not CollectionService. It keeps immutable snapshots, a single background indexing queue, and a per-source gzipped index cache.
  • The tree shows collections and their sources. The dataset list is a virtualised ListBox with text and "show cancelled" filters, and availability is resolved lazily per row.
  • The Catalog panel's view model, view and Viewer/Catalogs types are deleted. MainViewModel no longer takes a catalogue panel.
  • The splitter keeps the persisted CatalogInnerSplit setting name so users' layouts carry over.
  • Drag-drop: a folder that is not openable goes to the Add to Library dialog. An opened exchange set gets an "Add to Library" notification action unless the library already covers it.
  • Viewer exchange-set detection now shares EncDotNet.S100.Collections.ExchangeSetLayout with indexing. This answers §10 Q4: the shared rules live in Collections.
  • Loaded/Deferred availability arrives with the load actions in slice 5.
  • The "Library" activity tab takes over the Catalog tab's slot. CatalogPanelViewModel, CatalogEntryViewModel, CatalogPanelView, DatasetCatalogAggregator and S128DatasetCatalogSource are retired. The details pane (key/value properties) is carried over.

  • Tree: Collection → Source → (exchange set group) → item. There is a flat mode for large feeds.

    • Each row shows name, title, product, band, edition and update, issue date, and an availability glyph.
  • Filters: text, product, usage band, availability, "in current view", and "show cancelled".

  • Commands:

    Target Commands
    Item or selection Load · Load as you pan · Download · Zoom to · Reveal in Finder/Explorer · Copy path/URL
    Collection or source Refresh index · Rename · Add source · Remove (the reference only; never deletes data) · Show/hide coverage
  • Collection manifests (2026-09-30):

    • A manifest source with two or more groups gets a third tree level, one node per group.
    • Selecting a group node lists only its datasets.
    • A missing group shows "Path not found".
    • Group nodes can't be renamed or removed. Choose groups… on the source (LibraryService.UpdateSource) changes the selection in place.
  • Loaded S-128 datasets: loading an S-128 dataset still renders it as a dataset. Its entries also appear under a transient "Session" group in the Library, so today's behaviour is kept, and a "Keep in library" action persists it as an S128CatalogueSource.

6.4 Import and entry points

  • File > Add to Library… with submenu Folder… · Exchange Set / ZIP… · NOAA ENC Feed… · S-128 Catalogue…. A "+" button in the Library panel offers the same choices.

    • Each prompts for a collection: a new one (the name defaults to the folder or preset name) or an existing one.
  • NOAA ENC Feed dialog:

    1. Fetch or refresh the feed.
    2. Pick states, districts or regions, with counts and total download size. Counts are de-duplicated across the selection.
    3. Enter a name.
    4. Create.

    Nothing is downloaded at this point.

  • Collection manifests are added through Collection manifest…, or by dropping one, into a one-page dialog. The dialog reuses the online wizard's scope and target views.

  • Drag-drop of a folder or ZIP asks "Open now" / "Add to library" / "Both". A remembered preference is fine.

  • Recents: exchange sets and folders are recorded in Open Recent as well. This is a small, independent fix.

  • The CLI and MCP reach collections later (§9). Keeping the library UI-free keeps that cheap.


7. Loading and downloading

7.1 Load now

CollectionLoadService.LoadAsync(items) groups items by RootPath, so each exchange set or folder gets one IAssetSource:

  • S-57 → ExchangeSetCellRegistration (IAssetSource over the root, relative path, updates, bounds) → DatasetsViewModel.AddRangeFromExchangeSet, then load.
  • S-100 exchange-set members → the existing AddFromExchangeSet path, fed the chosen DatasetDiscoveryMetadata only.
  • Loose → DatasetsViewModel.LoadFromPathAsync.

Slice 5 as built:

  • Subset open: instead of splitting OpenAsync, a separate IExchangeSetService.OpenSubsetAsync(request, defer) opens a subset. It registers the chosen items under one tracked set per root, reusing a set already opened from the File menu along with its header and entries.
  • Unreadable catalogues: when a catalogue cannot be read strictly (malformed signatures), the set falls back to the bare source, without verification.
  • LibraryLoadService: groups library items by root and catalogue, skips non-local, missing and unknown-spec items, and maps items to their entries so the panel can show LOADED and ON PAN.
  • Lazy gating: LazyCellGate.IsScaleEligible gates items without a band by their coarsest display scale. DatasetEntry.UsageBand is now parsed only for S-57 names, because S-101 names parsed as band 1.
  • Pre-existing bug fixed: a batch registration's collection Reset released a still-empty tracked set. That dropped the header and disposed the source of deferred S-57 exchange sets opened from the File menu. It is fixed with a TrackedExchangeSet.IsRegistering guard and covered by a regression test.
  • Entries loaded now: entries loaded immediately are taken out of the lazy loader, with IsDeferred cleared.

Prerequisite refactor: split ExchangeSetService.OpenAsync into "read catalogue" and "register these entries (with their shared header, protection and portrayal set-up)". Collections can then open a subset of an exchange set. Opening a whole exchange set from the File menu becomes "all entries" through the same path.

7.2 Load as you pan

"Load as you pan" on a collection, source or selection registers the items as deferred entries with ExchangeSetLazyLoadCoordinator. This is the same mechanism as large S-57 sets, generalized:

  • The gate uses UsageBand when it is present (S-57). Otherwise it uses the item's Minimum/MaximumDisplayScale (S-100), so LazyCellGate.ShouldBeLoaded is no longer S-57-only.
  • The threshold (CellThreshold = 50) stays a heuristic for opening from the File menu only. An explicit "Load as you pan" always defers.

Background pre-warm (#1266): PortrayalCacheWarmer renders the deferred cells off the map, one at a time and nearest the view first, so their Lua portrayal and pattern clip are already in the disk caches when the map reaches them.

  • When: only after the map has had nothing visible to load or draw for 750 ms. A running cell is cancelled when that changes or when the cell starts loading for real.
  • Same keys: it opens the cell through the loader's CreateExchangeSetCellProcessor and renders it under the map's current presentation with its own pipeline factory, renderer and Lua engine, so it never shares Lua state with a load on the map.
  • Resumable: a ledger beside the portrayal cache records each cell's file stamps, the presentation signature and the cache keys the render used. A cell counts as done only while the signature matches and every key is still cached.
  • Cache room: the portrayal cache cap is 1 GB (the NOAA Washington source fills about 115 MB). Pre-warm stops at three quarters of the portrayal and pattern-clip caps, so it never makes them evict entries in use.

7.3 Download (NOAA)

Slice 6 as built:

  • Downloader: EncDotNet.S100.Collections.Noaa.NoaaEncCellDownloader fetches the cell zip to a .partial file, extracts it into a staging folder, and swaps that in for any previous copy only once it is complete.
  • Layout discovery: it finds CATALOG.031 and <CELL>.000 plus its updates rather than assuming the layout.
  • Record: it writes .source.json with the edition, update and layout relative to the cell folder.
  • In the viewer: LibraryDownloadService downloads at most three at a time, with a cancellable progress notification, and localizes downloaded feed items: the item keeps its identity but gets a local location.
  • Update detection: basic detection landed early. A copy older than the feed shows UPDATE, and downloading again replaces it. "Update all" is "Download N" on a list that includes outdated cells.
  • Live check: real NOAA cells (with and without updates, including a western-Pacific cell) downloaded and re-indexed with matching edition and update.
  • Download zipfile_location to <data>/downloads/noaa-enc/<CELL>.zip.partial, streaming with progress and cancellation. Then extract into a temporary directory and rename atomically to <CELL>/. A partial download can never be mistaken for a complete one.
  • Bounded concurrency (2–4). The panel shows total size before bulk downloads.
  • Record edtn, updn and zipfile_datetime in <CELL>/.source.json. That is the local-edition record that later update detection uses (§9).
  • Once downloaded, the item is Local. Download-and-load is one action.
  • The cell zip contains ENC_ROOT/<CELL>/<CELL>.000 plus updates. The LocalItemLocation root is <CELL>/ENC_ROOT and the base is discovered the way loose-cell folders are.

7.4 Community lists and packages (#670)

As built:

  • Reader and source: ChartCatalogsProductCatalogReader reads RncProductCatalogChartCatalogs lists. A ChartCatalogsFeedSource (JSON kind chartCatalogsFeed) is scoped by a ChartCatalogsFilter over entry numbers. Repeated entries are listed once.
  • Packages: an entry is a download that may hold many cells, so its RemoteItemLocation carries a DownloadFolder (community/<list stem>) and a Package (the entry number, made file-system safe).
  • Downloading a package: EncCellDownloader saves the whole package under <folder>/<package>/ and records every .000 it holds, each with its own exchange-set root and updates, in .source.json. A download that is not a zip is kept only when its URL names a .000.
  • Indexing (slice 4, bounds after download): an entry that has not been downloaded is one online item without bounds. Once downloaded, ChartCatalogsFeedIndexer indexes the package folder with the local indexer and lists its cells. They carry their own bounds, editions and titles, and keep the package's remote location with packageTitle.
  • Fingerprint: it includes each selected package's record time, so a download re-indexes the source. The Library panel also queues that re-index as soon as a package download finishes.
  • UPDATE: packages have no edition, so a copy is outdated when the list's zipfile_datetime is later than the recorded publication date.
  • Excluded lists: CZ (empty), DE (HTTP 403) and the South China Sea list (update-only zips with no base cells).
  • Live check: RO Base1 expanded to 10 cells with bounds. Other checks passed on a Rhône bare .000, a EuRIS single-cell zip, and a Brazilian exchange set. The 1,385-entry EuRIS list indexes in ≈0.3 s.

7.5 SECOM (IEC 63173-2) feasibility (2026-10-06)

As built (#804, first slice):

  • Client. SecomClient in EncDotNet.S100.Collections.Secom calls Capability, GetSummary (paged; capped, with SecomSummaryList.Truncated set only when the cap is hit) and Get. It falls back from /v2 to /v1 and pins whichever answered. The parser (SecomJson) is tolerant of the drift listed under Gotchas below.
  • Source. SecomSource (JSON kind secom) with a SecomFilter (products matched on this side, plus an optional WKT area sent as geometry). SecomSourceIndexer:
    • lists each object as an online item whose RemoteItemLocation points at Get with Envelope = Secom and a single-file Layout (<identifier>.gml, .000 or .h5);
    • caps a source at 5,000 objects;
    • keeps the last list in memory (one minute) and on disk (served stale when the service is down);
    • probes downloaded copies for bounds and records their signature in the item's properties;
    • DescribeAsync counts objects per product for the Add to Library dialog.
  • Download. EncCellDownloader unwraps Get responses. It refuses encrypted objects and signatures that do not match. It decompresses single-file ZIP payloads, and records the SecomSignatureCheck in .source.json.
  • Signatures. SecomSignatureVerifier checks the hex signature, DER or raw r‖s, over the decoded data with the first publicCertificate (base64 DER or minified PEM). Signer trust is judged against SecomTrustAnchors (§7.8). It is kept in Collections rather than shared with ExchangeSetVerifier: the overlap is a few BCL calls, and the verifier's helpers are private and Part 15-specific (P-384 only). It verifies a digest (VerifyHash) rather than the data, so SHA3 works on every platform (#806): where the platform has no SHA3 (macOS as of .NET 10, dotnet/runtime#116511) the digest comes from Sha3, a FIPS 202 Keccak written from the standard and tested against hashlib vectors and, where available, the platform. Unsupported now means an unknown digitalSignatureReference. Baleen and ELMAN sign with SHA3-384; CCG and AIVN with SHA2-384.
  • Discovery. A URL that answers Capability is recognised as KnownCatalogueFormat.Secom, both in the directory's "Add a catalogue by URL" and in MCP add_library_source. The known-sources list carries the Canadian Coast Guard's S-124 service (ccg-s124-secom). The registry search (MSR) is not wired in yet.
  • Sync (#807). SecomSource.Sync keeps a local copy of every listed object. LibrarySync (EncDotNet.S100.Collections.Library) listens to CollectionLibrary.SourceIndexed. Per-kind rules are an ILibrarySyncPolicy: whether a source is synced, which items it wants locally, its managed folder, and when its listing is complete enough to prune. SecomSyncPolicy is the first; other online kinds will opt in. The sync works as follows:
    • Downloads new and changed objects through LibraryDownloads without a notification. It stops with NeededBytes when that would exceed LibrarySyncOptions.MaxBytes (100 MB).
    • Prunes copies in the service's managed folder that no source of that folder lists as current (so cancelled ones go too). It prunes only after a complete, fresh listing (a fingerprint and no warnings), and never a copy the host says is in use (IsInUse: open in the viewer).
    • Re-indexes the source only when it downloaded or pruned something, so the cycle settles after one extra pass. An index of a non-synced source of the same service re-syncs the synced ones, since it changes what they may prune.
    • Area filters: SecomClient detects the interface version unfiltered first, then treats a 404 on a filtered summary as empty (ELMAN answers an empty area with 404). WKT is longitude-first, verified against CCG.
    • Viewer: Add to Library offers "Keep a copy of every object" (on by default under the cap) and "Only objects in the current map view". The node's status line reads "Synced N of M · time" or why not.
    • MCP: add_library_source takes sync and inMapView, and list_library_sources reports sync.
    • Live check: CCG narrowed to Vancouver synced 75 warnings in ≈4.5 s, 74 with bounds.
    • Not yet: auto-loading synced objects. Each object is its own dataset, so loading a whole source would flood the Datasets panel.
  • Live check (2026-10-06). All four anonymous services listed and downloaded:
    • CCG: 1,735 S-124 objects, signatures valid. The signer certificate expired on 2026-05-05 and is reported as expired.
    • AIVN: 67,640 S-122 objects, so capped. Signatures valid.
    • Baleen and ELMAN: signatures SHA3-384, not checked on macOS at the time. Since #806 they verify there too (a captured Baleen object is a test fixture, cross-checked with OpenSSL 3).

Spec. Ed 1.0 (2022) is the only published edition. Ed 2 has been out as a draft for comment (BSI, February 2025 and February 2026) but is not yet a standard. GLA's SECOMLib (Java, Apache-2.0) is the reference implementation. Its master branch implements v2 only; v1 is on the v1 branch. There is no .NET, Python or published TypeScript library. The IEC text is paywalled, so cite section numbers only.

Interfaces a read-only client needs. Paths are relative to the service's registered endpoint URI. /api/secom is a convention, not a rule.

Interface Method and path
GetSummary GET /v2/object/summary
Get GET /v2/object?dataReference=
Capability GET /v2/capability
Ping GET /v2/ping

v1 has the same shapes under /v1. The query filters are containerType, dataProductType, productVersion, geometry (WKT), unlocode, validFrom/validTo, page and pageSize. Summary objects carry dataReference, dataProtection, dataCompression, containerType, dataProductType and info_* (identifier, name, status, description, last-modified date, product version, size). They carry no geometry.

Discovery. The MCP Maritime Service Registry has an anonymous search: POST https://msr.maritimeconnectivity.net/api/secom/v1/searchService with body {}. It returned 79 instances. Data quality is poor (localhost URIs, bare hostnames, longitudes such as −414°), so results need sanity filtering. The v2 search expects a signed envelope.

Reachable without a client certificate (probed 2026-10-06):

Service Products Notes
Canadian Coast Guard, https://s124.ccg-gcc.gc.ca/api/secom S-124 v1 and v2 Get/GetSummary. ECDSA-384 signed. The best test target.
Danish Maritime Authority Baleen (test), https://baleen-test.e-navigation.net/api/secom S-124 v2 only
ELMAN, https://pelagus.elmansrl.eu/s100exchanger/secom S-122, S-124 v2 only, about 200 items
AIVN, https://mpa.aivenautics.co.kr/api/secom S-122 v1 and v2
AMSA, Fintraffic, KHRA (S-101, S-124, S-128) — Capability and Ping answer anonymously. A GET summary returns 404, probably because they need a signed POST search or an access request.

UKHO, NOAA and PRIMAR do not serve S-100 over SECOM. No S-101, S-102, S-104 or S-111 is openly downloadable over SECOM today.

Security.

  • Mutual TLS. It uses Maritime Connectivity Platform (MCP) PKI certificates: secp384r1, SHA384withECDSA, with the MRN and ship attributes in SAN otherName OIDs. None of the anonymous servers above asked for a client certificate. Some servers present MCP-issued server certificates; the MCP root is not in OS trust stores, so SECOM requests trust it themselves (§7.9).
  • Payload signatures. The signature in exchangeMetadata.digitalSignatureValue is computed over the decoded data bytes and is hex-encoded. The signer certificate travels as single-line PEM, along with the root thumbprint.
  • Request envelopes (v2 POST search, subscriptions, uploads). These are signed over a canonical string: field values joined with ., instants as epoch seconds, bytes as base64.
  • Encryption. The optional encryption is AES-CBC with a session key exchanged through the EncryptionKey interface. Part 15 protection can still apply to the dataset inside.
  • Developer certificates. Free from the MCC test environment, but approval is manual.

Gotchas.

  • data is base64 inside JSON. A download is a JSON call, not a file URL, so EncCellDownloader cannot be reused as-is.
  • Servers drift between v1 and v2.
    • Product names: S124 vs S-124.
    • The summary list: informationSummaryObject vs summaryObject.
    • pagination may be absent.
    • Ping fields differ, and timestamps come compact or ISO.
    • The parser must be tolerant.
  • No bounds before download. Items have no bounds until they are downloaded. Use the same "bounds after download" pattern as §7.4, optionally with the service's registry geometry as an outline.
  • FeedCache does not fit. It is GET and URI-keyed only.

Plan.

  1. #804: a read-only source.
    • A tolerant v2/v1 client with an injectable handler.
    • A SecomSource and indexer.
    • A downloader that decodes payloads.
    • Signature checks that reuse the ECDSA and certificate-chain code pulled out of ExchangeSetVerifier.
    • An Add to Library entry, and add_library_source support.
  2. Later: an identity and a "keys & certificates" store. An MCP client certificate, mutual TLS, signed v2 POST search, access requests and encryption. Design the store with the Part 15 permit/key UX as one shared surface. This is what enables the chart products (KHRA S-101/S-128, KRISO S-102).

7.6 Show on map and one row per source (#809)

As built (first slice):

  • One Datasets row per source. Library loads carry a LibrarySourceLabel (source id and name): LibraryLoader.Plan / LoadAsync(…, sourceOf) → LibraryOpenGroup.Source → ExchangeSetSubsetRequest.LibrarySource. DatasetsViewModel gives every exchange set of one source the same ExchangeSetHeader, named for the source:

    • the header keeps a set of asset sources, and its datasets are those of any of them;
    • counts are summed;
    • Close closes every set;
    • producer and issue date show only while the header holds a single set;
    • the signature badge merges the sets' results, showing "mixed" when they differ.

    Each set still loads and releases on its own. So 84 downloaded SECOM warnings, or a folder of exchange sets, are one row.

  • Show on map. CollectionSource.ShowOnMap is a persisted flag on every kind, omitted from JSON when off. The viewer's LibraryShowOnMapService handles it:

    • After each index of a shown source: it opens the source's local, current items to load as you pan, labelled as above, without a notification. It closes the ones the source no longer has (gone, cancelled, or no longer local), through the new IExchangeSetService.CloseEntries / ILibraryLoader.Close.
    • Turning it off, or removing the source, closes everything it opened.
    • Closing a synced source's datasets re-syncs it, so copies kept because they were open are pruned now.
    • Restart: shown sources come back, because the library indexes every source at start-up.
    • Defaults and controls:
      • a synced SECOM source defaults to shown;
      • the Library tree's source menu has a Show on map toggle (a collection node toggles all its sources);
      • MCP add_library_source takes showOnMap, and list_library_sources reports it.
  • Coverage markers. An item whose coverage is under 6 px on screen is drawn as a 13 px ring and hit by taps within it, so point-sized S-124 warnings are visible on the coverage overlay.

  • Sync for every online kind (second slice).

    • Model. Sync moved from SecomSource to CollectionSource, next to ShowOnMap; #808's "sync": true still reads.
    • Policies. ILibrarySyncPolicy.Supports says which kinds a policy handles, and IsSynced means it supports the kind and Sync is on.
    • OnlineSyncPolicy covers NOAA/USACE ENC feeds, community lists, S-100 feeds, remote S-100 catalogues and forecast feeds. It keeps every listed, non-cancelled item downloaded and current: newer editions and updates, newer packages, and a forecast's latest run, which replaces the last in place. It never prunes, because these kinds share managed folders (noaa-enc, …) with downloads made by hand. Only SECOM prunes.
    • Size cap. LibrarySyncOptions.MaxBytes (100 MB) applies, so a large scope reports "Not synced: needs …" instead of downloading.
    • Status line. A synced forecast feed or remote catalogue keeps its own line unless the sync needs attention (too large, or failures); other synced sources show "Synced N of M".
    • Viewer controls:
      • Keep downloaded in the Library tree's node menu, shown for online sources; on a collection it applies to its online sources;
      • Add to Library has "Keep everything downloaded and current", off by default (SECOM keeps its own option);
      • MCP add_library_source sync works for every online kind, and set_library_source_options sets sync / showOnMap on an existing source or a collection's sources.

7.7 SECOM service registry (#822)

As built:

  • SecomRegistry (EncDotNet.S100.Collections.Secom) reads the MCC Maritime Service Registry's anonymous SECOM search (POST …/api/secom/v1/searchService). It caches the listing on disk for an hour and serves the cached copy, with a reason, when the registry is down.

  • Cleaning. On 2026-10-07 the registry listed 79 instances:

    • deleted instances are dropped;
    • endpoints that aren't absolute http(s) URLs on a real host are dropped (localhost, bare host names, MRNs typed into the URL field, the reserved example domains);
    • duplicates (same endpoint and product) are collapsed, keeping released over provisional;
    • names and MRNs are trimmed, and products normalised;
    • geometry is GeoJSON, often registered with longitudes off by whole turns (CCG −414…−522°). Each part is shifted back, so CCG covers −162…−54°. Empty geometry means no area.

    The result is 61 usable services, 43 of them S-100 data services.

  • Probe. ProbeAsync calls Capability, then one GetSummary page:

    • Open: an anonymous summary works.
    • NeedsCertificate: a 401 or 403, or a service that answers Capability as SECOM but refuses an anonymous summary (KHRA).
    • UntrustedServer: a TLS AuthenticationException. Before #829 this was typically an MCP-issued server certificate (KRISO gmdrt, AMSA); those are now trusted (§7.9), so it means a certificate under no trusted root, expired, or naming another host.
    • Unreachable: a dead host, a timeout, or no answer as SECOM (websites, REST endpoints).

    Live counts on 2026-10-07: Open 6, NeedsCertificate 6, UntrustedServer 17, Unreachable 14.

  • Viewer. The Add online catalogue directory has Show SECOM services, which reads the registry only when asked.

    • S-100 data services are listed under "SECOM service registry" through KnownCatalogueSources.FromRegistry; provisional registrations show the pilot chip.
    • Choosing one probes it and shows a reachability chip and, when it can't be read, why. Next continues only for an open service, into the normal SECOM Add to Library step.
  • MCP. list_secom_services { product?, probe? } is read-only; add an open service with add_library_source url=<endpoint>.

7.8 SECOM signer trust (#823)

  • Anchors. SecomTrustAnchors holds trusted roots, each with a name shown for it, plus intermediate CAs. Services send the signer certificate without its chain, so the anchors supply the intermediate.
    • BuiltIn is the MCP MCC instance's chain: "MCP Root Certificate" and "MCP Identity Registry" (mcp-idreg-new), named "MCP MCC".
    • It is embedded from the MCP documentation repository (MIT). The source URL, revision, file checksum and certificate fingerprints are in Secom/TrustAnchors/README.md, and a test checks the fingerprints.
    • The MCC test instance is not trusted, because anyone can get a certificate from it.
    • Hosts add roots with With(...). The keys & certificates settings surface comes later.
  • Chain. FindAnchor builds the chain with custom root trust and ignores time validity. Revocation is checked separately (§7.11). The stated publicRootCertificateThumbprint must name a CA in the chain, by SHA-1 or SHA-256:
    • CCG states the root.
    • DMA (Baleen) states the issuing intermediate.
  • Expiry stays separate from trust. A signer whose certificate has expired, but which chains to a trusted root, is still attributable: CCG's certificate expired on 2026-05-05 and reads "valid · trusted (MCP MCC) · signer certificate expired".
  • Recording. .source.json keeps the SecomSignatureCheck and the certificates the object carried, with its stated thumbprint.
  • Re-checking existing downloads: on read, not on sync. Trust needs only the certificates, not the data: the signature was already checked when the object was downloaded. So EncCellDownloader.TryGetDownloaded judges the signer again, against its current TrustAnchors and the current time, each time a record is read. Adding an anchor therefore needs no re-download.
    • Records written before #823 carry no certificates. They read "valid · signer trust not checked" until the object is downloaded again; a sync re-downloads it when the service publishes a newer version.
  • Item details. The signature property reads one of:
    • "valid · trusted (MCP MCC)";
    • "valid · signer not trusted";
    • "valid · signer trust not checked";
    • for a trusted signer, " · signer certificate revoked" or " · revocation not checked" appended (§7.11);
    • any of these with " · signer certificate expired" appended;
    • or "invalid", "unsigned", "not checked (unknown algorithm)", or "not checked (data encrypted)" for a protected object, whose signature covers the data before encryption (#923).
  • Later.
    • User-added anchors in the keys & certificates UX (done in §7.13).
    • Revocation (CRL) followed in #833 (§7.11).

TLS trust for the same roots followed in #829 (§7.9).

7.9 SECOM server trust (#829)

  • Why. 17 of the registry's S-100 services failed the TLS handshake: their server certificates are MCP-issued. On 2026-10-08 AMSA's (navwarn.amsaconnectivity.net) and KRISO's (*.gmdrt.org) chained to exactly the MCP MCC intermediate and root of §7.8.

  • SecomServerTrust. Its CreateHandler() handler is given only to SECOM clients. In the viewer that is:

    • the SECOM indexer;
    • the registry and its probes;
    • the SECOM client of the URL detector;
    • EncCellDownloader.SecomHttpClient, through LibraryDownloads.ManagedFolders(…, secomHttp).

    NOAA, feeds, basemaps and update checks keep the system's trust.

  • Rule. A connection is allowed when the system trusts it, or when the system rejects it because of the chain and all of these hold:

    • the chain reaches a SecomTrustAnchors root, with the server's intermediates or the anchors' own;
    • every certificate is within its validity period (unlike signers);
    • the certificate names the host;
    • the certificate and its CAs are not revoked (#833, §7.11).
  • Host names, never skipped.

    • A certificate the system trusts but that names another host is refused, never widened.
    • For an anchor-trusted certificate the host is checked here, the same way on every platform:
      • DNS names in the subject alternative name, where a wildcard covers exactly one left-most label;
      • IP addresses there, for an IP host;
      • only when no DNS name is listed, the subject CN, matched exactly.
    • AMSA's device certificate carries the host only in its CN; its SAN holds just the MRN otherName. RFC 6125 discourages a CN fallback and platforms differ, so the rule is ours: the fallback applies only under an MCP anchor and only without DNS names. It is the same as OpenSSL's default.
  • Outcomes. Each host's decision is kept (ResultFor(host)): SystemTrusted, AnchorTrusted (with the anchor's name), NotTrusted, Expired, WrongHost or (#833) Revoked.

  • Probes and the viewer.

    • SecomRegistry (given the validator) attaches the decision to SecomProbeResult.ServerTrust, and replaces the platform's TLS message with the reason.
    • The directory row explains a refusal ("does not name its address", "has expired") or says "Its server certificate is from MCP MCC."
    • list_secom_services reports serverCertificate and serverCertificateAnchor.
  • Not changed. A service that also wants a client certificate now probes as NeedsCertificate rather than UntrustedServer. mTLS with the user's MCP identity followed in #832 (§7.10).

7.10 SECOM client identity (#832)

  • Why. After §7.9, "Needs a certificate" was the most common state: 20 of 43 registry services answer Capability anonymously but share data only with an MCP client certificate (mutual TLS).

  • SecomClientIdentity is an MCP certificate with its private key.

    • Load(path, password) reads PKCS#12 or PEM (certificate and key, optionally encrypted). PEM is re-loaded through PKCS#12, because macOS and Windows can't use a key attached only in memory for client authentication.
    • It reports Subject, Mrn (the subject UID, as MCP identity registries issue it), Anchor (when it chains to a SecomTrustAnchors root) and its validity period.
    • It is held in memory only.
  • Presented by SecomServerTrust's handlers.

    • SetIdentity sets, replaces or clears it at runtime.
    • Each handler builds an inner HttpClientHandler for the current identity and swaps it on the next request after a change. Replaced handlers are kept until the outer one is disposed, so requests still running finish; their idle connections close on their own.
    • CreateHandler(presentIdentity: false) never presents it.
    • Non-SECOM clients never see it: they don't use these handlers.
  • Probes go anonymously first, so an Open service stays Open. Only when that says NeedsCertificate, and an identity is set, they probe again presenting it:

    • OpenWithCertificate: the summary answers.
    • CertificateRefused: still refused.

    A service that refuses the handshake itself (the server certificate was accepted on that connection, yet it failed) counts as NeedsCertificate, "asks for a client certificate when connecting", rather than Unreachable. SecomProbeResult.Identity names the identity presented.

  • Interim supply, until the keys & certificates UX (§7.13 now stores identities):

    • --secom-identity <path>, with the password from SOUNDCHARTS_SECOM_IDENTITY_PASSWORD;
    • the MCP tool set_secom_identity {path?, password?, clear?}, which refuses an expired or not-yet-valid identity.

    Both are session-only: nothing writes a key or password to settings.

  • Viewer.

    • The directory shows "Readable with your certificate" (and Next continues) or "Certificate refused", naming the identity.
    • The directory is made anew each time it opens, so it probes with the current identity.
    • SECOM indexing and downloads use the same handlers, so an authorised source lists and downloads like an open one.
  • Not yet.

    • Signed request envelopes (v2 POST search, subscriptions).
    • Encrypted data.
    • Persisted identities.
    • Live validation, which waits on an approved MCC organisation and providers accepting its certificate.
  • Correction (2026-10-09): SECOM 2.0 POST summary. Most "Needs a certificate" services were not refusing a certificate.

    • SECOM 2.0 (as GLA's SECOMLib implements it) keeps the GET …/v2/object/summary and adds enveloped, signed POST forms: POST …/v2/object/search/summary (GetSummary) and POST …/v2/object/search (Get).
    • KHRA and KRISO implement only the POST forms. KRISO answers the GET with "No endpoint GET …/object/summary"; KHRA with Spring's route-not-found.
    • AMSA answers the GET with a bare 404. SECOMLib uses 404 for "nothing found", so it may just be an empty result.
    • The probe now tells them apart:
      • NeedsSecom2Search: after a 404 summary, an empty POST to …/v2/object/search/summary is answered with anything but 404.
      • CertificateRefused is kept for active refusals (401, 403, or a TLS-level refusal) of the identity.
    • serviceVersion is no guide: KRISO states its own versions (0.1.0, 1.0.0).
    • Supporting the signed POST GetSummary and Get is the next slice.

7.11 SECOM certificate revocation (#833)

  • What the MCP endpoints do (checked 2026-10-08). MCP certificates name a CRL distribution point and an OCSP responder. CCG's signer and KRISO's weather.gmdrt.org server certificate both name http://api.maritimeconnectivity.net/x509/api/certificates/crl/urn:mrn:mcp:ca:mcc:mcp-idreg-new and the matching …/ocsp/… responder.

    • The intermediate's CRL answered 200 in about 0.6 s: about 9.5 KB, 133 entries, with a signature that verifies against the built-in intermediate. Neither CCG's nor KRISO's serial was listed.
    • It is PEM (application/x-pem-file), not the DER RFC 5280 requires, so a DER-only reader rejects it.
    • It is re-signed on every request: thisUpdate is now and nextUpdate is now + 7 days. It has no CRL number.
    • The root's CRL, which the intermediate names, is PEM, has no entries, and is valid for a year.
    • OCSP answered "good" for CCG. The CA itself signs the responses, and they carry no nextUpdate.
    • A copy of the CRL is the test fixture mcp-idreg-new-crl.pem.
  • CRL, read by us; OCSP later. SecomRevocation fetches and checks CRLs itself instead of using X509RevocationMode.Online:

    • platform revocation behaves differently on each OS, and not every platform accepts PEM CRLs;
    • one CRL covers every certificate its CA issued, so indexing a source costs one fetch per CA, where OCSP would cost one request per certificate.

    It reads DER or PEM. A CRL is used only when:

    • its issuer name is the issuing CA's subject;
    • its signature verifies with that CA's key (ECDSA or RSA, SHA-2);
    • it is current: thisUpdate has passed, allowing 5 minutes of clock skew, and nextUpdate has not.

    Every certificate in the chain except the root is checked against its issuer's CRL, so a revoked intermediate revokes everything under it. Only chains that reach a SecomTrustAnchors root are checked. The system's own trust (SystemTrusted) keeps the system's revocation policy.

  • Online fetch, offline cache. CRLs are kept in memory and on disk, as crl-<hash>.crl in the feed cache directory, next to the SECOM and registry caches.

    • A copy is fetched again after 24 h (RefreshAfter), even while it is still current, because MCP's week-long nextUpdate would otherwise hide a week of revocations.
    • When a refetch fails, a copy that is still current is used.
    • Offline, the disk copy serves until its nextUpdate.
  • Timeouts. Each fetch is abandoned after 5 s (FetchTimeout). After a failure, the URL is left alone for 5 minutes (RetryAfterFailure), so an absent responder costs one timeout per CA per interval, not one per object or per handshake. One fetch per URL runs at a time; concurrent checks wait for it instead of fetching too. Responses over 8 MB are refused.

  • Soft-fail, except when revoked. When no current CRL can be had, the result is NotChecked, it is shown, and the caller carries on. Charts and warnings are public safety information, and an absent CRL responder should not hide them. A listed certificate is always Revoked:

    • TLS: the connection is refused as SecomServerTrustOutcome.Revoked. The probe says "Its certificate from MCP MCC has been revoked.", and the directory row explains it. An anchor-trusted connection whose revocation could not be checked is allowed. SecomServerTrustResult.Revocation says so, the row adds "Whether it has been revoked could not be checked.", and list_secom_services reports serverCertificateRevocation. Revocation is checked last, so a certificate refused for another reason costs no fetch.
    • Signatures: a revoked signer does not refuse the download. The data still matches its signature. It is shown instead, in SecomSignatureCheck.SignerRevocation, and the signature property reads "valid · trusted (MCP MCC) · signer certificate revoked".
  • Separate from trust and expiry. Revocation has its own field (SignerRevocation, SecomServerTrustResult.Revocation) and its own outcome (Revoked), and it is checked only for a trusted chain. In the signature property it sits between trust and expiry:

    • "valid · trusted (MCP MCC)" means every check passed, including revocation;
    • "valid · trusted (MCP MCC) · revocation not checked";
    • "valid · trusted (MCP MCC) · signer certificate revoked · signer certificate expired".
  • Re-checked on read, from the cache. As with trust (§7.8), a recorded download's signer is judged again whenever its record is read, from the certificates in .source.json.

    • Reads use SecomRevocation.CacheOnly and never wait on the network, because download status is read from the UI.
    • Index runs (SecomSourceIndexer, in the background) and downloads may fetch.
    • .source.json records the result as revocation. A signer once found revoked stays revoked when no CRL is at hand, since revocation is permanent.
    • Records written before #833 read "revocation not checked" until their CRL is fetched.
  • Composition (viewer). One SecomRevocation is shared, with a plain HttpClient, because CRLs are plain HTTP, and the feed cache directory. It goes to SecomServerTrust, SecomSourceIndexer and LibraryDownloads.ManagedFolders.

  • Caveats.

    • A CA may drop an entry once the certificate expires (RFC 5280 §3.3), so "not revoked" is weaker for an expired signer. MCP's CRL still lists revocations from 2025.
    • Without a trusted timestamp, a revocation applies whenever the object was signed.
  • Later.

    • OCSP, as a fallback when a certificate names no CRL, or for freshness.
    • Delta CRLs and certificateHold removal; MCP uses neither.

7.12 SECOM 2.0 signed requests (#838)

  • Interfaces. These are what GLA's SECOMLib implements (Apache-2.0, read 2026-10-09). SECOM 2.0 keeps the GET forms and adds POST forms whose filter travels in a signed envelope:

    • POST …/v2/object/search/summary for GetSummary;
    • POST …/v2/object/search for Get.

    KHRA and the KRISO hosts have only the POST forms.

  • Envelope.

    • Fields: the filter fields in declared order, then envelopeSignatureCertificate (base64 DER), a SHA-384 envelopeRootCertificateThumbprint of the root (lower-case hex), and envelopeSignatureTime (yyyy-MM-ddTHH:mm:ssZ).
    • envelopeSignature is hex DER ECDSA over the UTF-8 canonical string: the values joined with ., where absent is empty, times are epoch seconds, lists are joined with , and container types are numbers.
    • SecomEnvelopeSigner builds both bodies. The root comes from SecomTrustAnchors (MCP Root for an MCC identity); a certificate under no anchor names itself.
    • envelopeSignatureCertificate carries the signer and the CAs below the root (for an MCC identity, the MCP Identity Registry intermediate). SECOMLib validates exactly the certificates sent against its trust store, which may hold only the root.
  • Algorithm. A search envelope names none; SECOMLib checks it with the server's configured algorithm.

    • The default follows the key: SHA-384 for P-384 (the MCP curve), SHA-256 otherwise.
    • Corrected on 2026-10-13 (#882). The first default was SHA3-384, because DMA signs its data with ecdsa-384-sha3. With a real MCC identity, AMSA and KHRA rejected SHA3-384 envelopes and accepted SHA-384 ones, carrying the intermediate. KRISO answers 401 to both, an access question.
  • Page size. Summaries are read 100 at a time, SECOMLib's default. AMSA answers 400 to a signed summary asking for 250.

    • SecomServerTrust.EnvelopeSignatureReference, or set_secom_identity {signatureAlgorithm}, overrides it.
  • Client. SecomClient switches to the POST forms (UsesPostInterfaces) when a GET summary answers 404 and an empty POST summary is answered with anything but 404, 405 or 501.

    • A 501 means the service implements no GetSummary at all (KRISO's port call, TCS, …): Unreachable, "cannot be listed".
    • Without an identity a POST request throws SecomIdentityRequiredException.
  • Index and download.

    • The indexer remembers the POST mode with the cached listing, and gives items RemoteEnvelope.SecomPost.
    • EncCellDownloader then posts a signed Get, built from the item's GET URI by SecomClient.CreatePostGetRequest, through the SECOM client.
    • The host gives both SecomServerTrust.CreateSigner, so they sign with whatever identity is current.
    • Data signatures may state SHA-384 root thumbprints; SecomTrustAnchors accepts SHA-1, SHA-256 and SHA-384.
  • Probe.

    • Without an identity: NeedsSecom2Search ("needs your MCP identity").
    • With one, it lists a page signed:
      • OpenWithCertificate;
      • CertificateRefused for 401 or 403;
      • otherwise NeedsSecom2Search with the service's answer.
  • Live on 2026-10-09, with a throwaway self-signed identity:

    • The format is accepted: KHRA's earlier "missing required data" became 403, and KRISO answers 401. Both are certificate refusals an approved MCC identity should pass.
    • AMSA answers 500.
    • Census: Open 6, CertificateRefused 16, NeedsSecom2Search 1 (AMSA), UntrustedServer 1, Unreachable 19. Without an identity, NeedsSecom2Search is 14.

7.13 SECOM identity store: Keys & certificates (#845)

  • Why. §7.10's identity lived for one run (--secom-identity or set_secom_identity). The viewer now keeps MCP identities and the trusted authorities as settings, on a Settings → Keys & certificates page with three tabs: Identities, Trusted authorities and System IDs. System IDs is layout only until Part 15.

  • Where it lives. The design handoff put the page in a dialog opened from the narrow Settings column. Settings is now a full-width panel with categories (#846), so the page is a category there.

  • Keys stay in the platform key store (ISecomKeyStore): X509Store(StoreName.My, StoreLocation.CurrentUser).

    • macOS: the login Keychain.
    • Windows: the user's certificate store. The key is loaded with PersistKeySet | UserKeySet, not exportable.
    • Linux: .NET's own store. These are PKCS#12 files under ~/.dotnet/corefx/cryptography/x509stores/my, protected only by the account's file permissions, because .NET uses no system keyring on Linux. The page's note names the store, and on Linux says this.
    • Runs whose settings aren't saved (--ephemeral, read-only settings) keep keys in memory, so nothing outlives the run.

    After import, a key is never shown, copied or exported, and neither the key nor its password is written to settings, logs or MCP output.

  • Settings hold references only. ViewerSettings.SecomIdentities holds SecomIdentityReference entries with these fields:

    • id (sc-ident: and 8 hex digits of the SHA-1 thumbprint);
    • display name, MRN, subject and issuer;
    • thumbprint, validity and kind (vessel, organisation, device, service, person or test);
    • key-store handle;
    • RevokedAt once a check finds the identity revoked.

    SecomIdentityInUse names the one in use.

  • N stored, one in use. SecomIdentityStore imports, uses, replaces and removes identities. Using one calls SecomServerTrust.SetIdentity. Removing the identity in use clears it and deletes the key-store entry. §7.15 makes the choice per source.

  • Start-up (SecomCredentials, before any SECOM client exists):

    1. The user's anchors are applied.
    2. The identity in use is presented: the command-line one if given, otherwise the stored one loaded from the key store.

    A --secom-identity identity wins for its run. The page shows it as a temporary "From command line" row with no actions.

  • Revocation. SecomServerTrust.CheckIdentityRevocation checks the identity in use against its CAs' CRLs (§7.11). It runs at start, when the page opens, and once a day. A revoked identity is cleared at once and IdentityRevoked is raised. The store records RevokedAt and stops using it. The page shows the banner, and a notification links to the page.

  • Status. A row reads "Ends in N days" (amber) within 30 days, and "Ended" or "Revoked" in red. The same goes into the Settings category list as a status line under "Keys & certificates", and the tab counts show !. "Ends in N days" is recomputed when the date turns.

  • Trusted authorities (TrustedAuthorityStore):

    • The built-in root (MCP MCC) can be turned off but not removed. DisabledBuiltInAuthorities lists the SHA-256 hashes of roots turned off.
    • Roots added from PEM files are kept as PEM text in TrustedAuthorities. They are public certificates.
    • Every change replaces SecomServerTrust.Anchors. That forgets past server decisions and makes handlers reconnect. Downloads judge signers through EncCellDownloader.TrustAnchorsSource, so the change reaches them too.
  • MCP. set_secom_identity also takes a reference id (path: "sc-ident:…"). That makes the stored identity the one in use, as the page's Use does. A path still loads a file for the session only. The result includes reference for a stored identity.

  • Reset all settings also deletes stored identities' keys.

7.14 From a certificate problem to its fix (#880)

  • Coded diagnostics. IndexDiagnostic.Code says what kind of problem a diagnostic is, so hosts don't parse messages. A SECOM source that can't be listed carries a SecomDiagnosticCodes value:

    • NeedsCertificate: 401 or 403 with no identity set;
    • NeedsSignedRequest: SECOM 2.0's signed requests with no identity;
    • CertificateRefused: 401 or 403 with an identity set;
    • ServerUntrusted: a TLS AuthenticationException.

    The code is on the Error diagnostic, or on the Warning when a cached list is shown instead.

  • Library status. LibraryNodeText.Status gives such a source the directory's words: "Needs a certificate", "Needs a signed request", "Your certificate was refused" or "Server certificate not trusted". It reads "… · showing the last list" when a cached list is shown.

  • The way to the fix. The Library row and the directory row offer a link, and the revocation notification uses the same route:

    • "Set up an identity…" for no identity;
    • "Manage identities…" for a refused or not-accepted one;
    • "Trusted authorities…" for a server under no trusted authority.

    The link opens Settings → Keys & certificates on the right tab (KeysNavigator). The wizard closes first. Expired, wrong-host and revoked server certificates get no link: the user can't fix those.

  • Re-read on change. SecomTrustRefresh re-reads only the affected SECOM sources:

    • on IdentityChanged, those with an identity code;
    • on AnchorsChanged, those with ServerUntrusted.

    Sources that read fine are left alone.

7.15 Choosing an identity per source (#883)

  • Why. §7.13 stored any number of identities but presented one to every service. Providers accept one kind of identity (an organisation's but not a vessel's, say), and every identity presented discloses an MRN to the provider. So each SECOM source now chooses its own, and nothing is ever tried on its behalf.

  • The setting. SecomSource.Identity is default, none or an sc-ident: reference (SecomIdentitySetting), saved in collections.json. It's a reference only: no key, password or thumbprint copy. New sources start on default. Sources saved before the setting existed read as default, which keeps round 1's behaviour.

  • The default. Exactly one identity can be the default (ViewerSettings.SecomDefaultIdentity). The first import sets it. On load, round 1's SecomIdentityInUse becomes it. The host tells the trust with SecomServerTrust.DefaultIdentityId; s100 mcp serve's set_secom_identity and --secom-identity make their identity the default (SetIdentity(identity)).

  • Resolution, per read. The host registers every identity it holds (SetIdentity(id, identity)); the viewer loads each from the key store at start. Resolve(setting) runs each time a source is read:

    • default: the current default, or none when no default is set;
    • none: no client certificate and no signing;
    • a reference: that identity, or none when it isn't registered (removed, revoked, or never imported here).
  • Per request. Requests carry their identity in HttpRequestMessage.Options (SecomServerTrust.PresentIdentity; SecomClient.Identity tags its own). CreateHandler() keeps one inner SocketsHttpHandler per identity, so connections made with one identity are never reused with another. An untagged request presents none. The indexer, the downloader (the source's setting rides on RemoteItemLocation.Identity) and the registry probe tag their requests.

  • Never another identity. Only the setting's identity is presented, only when the server asks, and only once per read:

    • a 401 or 403 is final: the v2 → v1 fallback doesn't run for it;
    • a connection with an identity that closes before answering (a TLS refusal) isn't made again. SocketsHttpHandler retries such a request up to three times on new connections; a ConnectCallback allows one new connection per request and host.

    Tests over a local TLS server assert a single handshake for a refused identity, in both the 401 and the handshake-refusal forms.

  • What the server asked. HttpClientHandler can't tell, so the inner handlers set SslOptions.LocalCertificateSelectionCallback. It's called with a server certificate only when the server asks for a client certificate, with the issuers the server accepts; before the handshake (no server certificate yet) it returns nothing, so the identity isn't committed early. Server trust moves to SslOptions.RemoteCertificateValidationCallback; its sender is the SslStream, whose TargetHostName gives the host. Both callbacks merge into one SecomHandshake per identity and host (HandshakeFor). TLS session resumption is off on these handlers: a resumed session keeps the client certificate (or none) it was made with, and Windows' Schannel resumed a host's anonymous session for a request with an identity. On Windows, an anonymous handshake's request isn't seen when the server also accepts no certificate; 401/403 answers still classify the read.

  • The presentation record. Each read of a SECOM source records a SecomPresentation on SourceIndex.Presentation:

    • the setting, the identity it resolved to and what was presented (the MRN, or none);
    • whether the server asked for a certificate, and its acceptable issuers;
    • whether requests were signed (SECOM 2.0);
    • the outcome: not asked, accepted, not presented, refused, stopped (untrusted server) or not reached; with the code and the time;
    • the identities this source has seen refused. Refusals are remembered per source (secom-refusals.json in the feed cache) until the same identity is accepted there.

    The source's fingerprint includes the identity and its outcome, so a change of identity indexes again even when the list is the same. Listings are cached per identity.

  • Codes. SecomDiagnosticCodes.Classify now uses what the handshake showed. A failed connection after the server certificate was accepted and a client certificate asked for is CertificateRefused (an identity was presented) or NeedsCertificate. A rejected signed request is the new SignedRequestRefused ("Signed request not accepted"); the Library says "Your certificate was refused" for it too.

  • Hints. SecomIdentityHints.Judge gives the picker's hint, and SecomPresentation.HintFor applies it when the service asks:

    • Refused here when the source refused that identity;
    • Issuer accepted when the identity's chain (through the trust anchors) holds an issuer the server named;
    • Issuer not accepted otherwise.

    SECOM 2.0 signed-request services (KHRA, KRISO) name no issuers, nor do some TLS servers. The fallback is the MCP instance: with the service's MRN (from the registry), the MRNs' instance parts (urn:mrn:mcp:<type>:<instance>:…) must match; without it, the identity's trust anchor must be the server certificate's. A server under a public CA gets no hint. Nothing is chosen from a hint.

  • Re-read scope (SecomTrustRefresh, extending §7.14):

    • a source's own setting changed: that source only, as any UpdateSource;
    • the default changed: the sources on default;
    • an identity added, replaced or removed: the sources that resolve to it (set to it, or on default while it's the default);
    • trusted authorities changed: the sources with an untrusted server, as before.
  • Replace and remove. SecomIdentityUsage lists the sources that use an identity, directly or via the default. The viewer's store moves sources set to a replaced identity to the new one, and sources set to a removed one to none; removing the default takes a new default (or none). Revocation (§7.13) now checks every registered identity and removes a revoked one.

  • MCP. set_library_source_options {identity} sets a SECOM source's setting (or every SECOM source of a collection), and returns it; list_library_sources shows it. The tools are shared with s100 mcp serve (#864). The CLI has no SECOM identity store yet (deferred), so there a reference names no identity and presents none, unless set_secom_identity loaded that identity for the session.

  • The picker (viewer, IdentityPickerViewModel and Views/Secom/IdentityPicker) is shared by the add flow and source details. Its closed value is the setting's name and sub-line. Its menu lists Default · {name}, None · anonymous and each identity (kind, MCP instance, "ends in N days"), then "Manage identities…". When the service asks, items carry the hints; an identity whose issuer isn't accepted can't be chosen, and Default takes its identity's hint. With no identities it offers only None and the way to import. It never picks: only the user's choice changes the setting.

  • Add online catalogue, four steps. A SECOM service gets a Connection step between the catalogue and what to include; other catalogues skip it (the step reads "Not needed" and can't be clicked).

    • The step shows the service card (name, host, MRN from the registry and the vocabulary tag), the picker (new sources start on Default) and a toned status block with the handoff's copy for each of the eight states and its action: "Choose identity…" (or "Use {name}" when exactly one identity is acceptable, which still only proposes), "Import identity…", "Choose another identity…", "Trusted authorities…" or "Try again".
    • Each change of identity probes again with that identity only (SecomRegistry.ProbeAsync(uri, identity)).
    • The primary button reads Continue when the service reads, and Add and wait otherwise, with a note on what it waits for. Add and wait doesn't read the service: SecomScope.Wait scopes it to the directory entry's product, and the source is added with no datasets. It lists through the re-reads above once an identity is chosen or imported, or its server's authority trusted, or when it can be reached.
    • The shared add path (#863) takes identity and wait too, so add_library_source (viewer and s100 mcp serve) can add a service before it can be read; without wait it still refuses.
    • The directory no longer blocks Next on a registry service that can't be read.
  • The Library. A SECOM row's second line reads SECOM · {Default (name) | Default (not set) | None | name} · {N datasets | no datasets yet}. Its status line keeps the as-built words, with links: "Choose identity…" (or "Set up an identity…" with none), "Choose another identity…", "Trusted authorities…", and, for a readable source whose identity ends within 30 days, "Certificate ends in N days" with "Manage identities…". An identity link selects the source and opens its picker; a row action never changes the setting by itself.

  • Source details. With a SECOM source selected and no dataset, the details pane shows its Connection: the picker; the presentation line ("Presented {name} · refused {d MMM, HH:mm}", "· accepted", "Service asked for a certificate · none presented", "Stopped before presenting an identity", "Service doesn't ask for a certificate"); Reads as, Datasets, Setting (identity: …, in mono, as stored) and, for an untrusted server, Reason; and the row's action as a button. Choosing an identity saves the setting (that source is re-read), and once it reads, "Re-read with {name} · {N datasets}".

  • The Identities tab (Settings → Keys & certificates):

    • The intro says sources use the default unless their setting names another identity, or None. The empty state adds that the first import becomes the default, and the note that sources store default, none or a reference.
    • A row shows its kind badge, then an inverted DEFAULT pill for the default; its sub-line is "Issued by {issuer} · ends {date}".
    • "Used by N sources" lists one chip per source, via default or chosen (SecomIdentityUsage), or "Not used by any source". The tab follows the Library, rebuilding when a source's setting changes.
    • Actions: Details · Make default (other usable identities; the sources on Default are re-read) · Replace… · Remove. Round 1's Use / Stop using and the IN USE badge are gone.
  • Remove identity (RemoveIdentityDialogViewModel, 440 px): "{N} sources use this identity, directly or as the default. Each switches as listed." For the default it asks for a new default first (the other usable identities, then None; the first preselected). The affected list says what happens to each source: "switches to None" for those set to it, "Default · switches to {new default | None}" for those on Default. Confirming updates those settings, sets the new default, deletes the key-store entry and re-reads the affected sources.

  • Import, step 3: "{N} sources need a certificate from an issuer {name} chains to. Set them to {name}? Other sources keep their setting." One checkbox per waiting source (a last read that wanted a certificate or a signed request, or was refused), ticked by default; sources whose service named issuers the identity doesn't chain to, or that refused it before, aren't offered. Round 1's "use for SECOM" checkbox is gone: the first import becomes the default, and Make default changes it.

  • Breaking changes (preview packages): SecomServerTrust.SetIdentity now also takes an id, IdentityChanged is IdentitiesChanged, CreateHandler(presentIdentity) is CreateHandler(), CheckIdentityRevocation returns a result per identity, SecomSourceIndexer.Signer is Trust, EncCellDownloader.SecomSigner is SecomTrust, LibraryDownloads.ManagedFolders takes the trust, SecomRegistry.ProbeAsync and SecomSourceIndexer.DescribeAsync take an identity setting, SecomDiagnosticCodes.Classify takes the identity and what the handshake showed, ILibraryEditor.SetOptionsAsync takes a SourceOptionsChange, and LibraryCatalogueReaders.Secom takes an identity setting.

7.16 SECOM exchange-set objects (#916)

  • Why. AMSA, the first service to return data to our MCP identity (#882), serves every warning series as an exchange-set object (containerType 1). They were listed without a download.
  • What a Get returns (AMSA, captured 2026-10-13). The data is a plain ZIP of an S-100 exchange set: S100_ROOT/CATALOG.XML, CATALOG.SIGN and S-124/DATASET_FILES/*.gml. It's neither compressed nor protected, and its SECOM signature verifies under MCP MCC. The catalogue lists the current warnings and a cancellation record, without a file, for each one withdrawn (#919). Three of AMSA's five sets held only cancellations.
  • Decision: a package, unpacked into its datasets. An exchange-set object is indexed as a package (§7.4): one online item until it's downloaded, then one item per current dataset of its catalogue, grouped under the package and keeping its RemoteItemLocation. This keeps per-dataset bounds, titles and loading, and reuses the package rows, announcements and pruning. A set with only cancellations downloads as an empty package: its record names the catalogue, and its item stays a package entry with "no current datasets".
  • Download. EncCellDownloader checks the SECOM signature, extracts the archive (unwrapping one inner ZIP if the object is also flagged compressed), and records the current datasets the catalogue lists whose files arrived, with S-101 updates folded into their base.
  • Names. dataReference is stable, but info_identifier carries a generation timestamp that changes on every read. Names drop a trailing _<ISO timestamp>, so they, and the download folders, stay put. An exchange set's description lists its files, so it isn't used as a title.
  • Updates. AMSA gives no lastModifiedDate, and its info_size isn't a byte count. For an undated object, a hash of its summary (stable identifier, name, description, size, status, product version) is RemoteItemLocation.Revision; it's recorded with the download, and a different revision makes the copy outdated. AMSA's description lists the set's files, cancellations included, so a warning issued or cancelled changes it. The revision is part of the fingerprint, so the change re-indexes the source.
  • AMSA's answers (2026-10-14). AMSA updated the service after our report:
    • Unchanged exchange sets are no longer regenerated, so identifiers stay put.
    • info_lastModifiedDate is now the catalogue's timestamp. A copy downloaded under a revision is outdated once, then judged by date.
    • A refused certificate chain answers 400 with a SECOM response object ({"message": "The requested SECOM trust anchor is not recognised or is not valid", "responseCode": 2}). Failures now carry the service's message, and a signed request refused this way reads as SignedRequestRefused.
    • The chain is built with intermediates they configure, so a client sending only its own certificate is accepted too.
    • info_size is in kilobytes, per SECOM §5.7.6.2. CCG gives bytes (an object listed at 4,885 decoded to exactly 4,885 bytes), so sizes stay unused for exchange sets.
    • 100 is their maximum page size. A summary with page=0 returns only maxItemsPerPage, per SECOM §5.5. SecomClient asks it when it matters: when the first page is refused with 400 (it reads again at the service's size), or when more pages are needed (a larger size, up to 1,000, means fewer requests). The answer is trusted only when it has no items and a positive size (CCG echoes the requested size with items), and it's kept per HttpClient and service.
    • They suggest subscriptions for updates.
  • Not yet.
    • Encrypted exchange sets (Part 15 protection) and SECOM-protected objects (#923).
    • Subscriptions.

8. Implementation slices

Each slice is a PR. Slices 1–2 are library-only and testable without the UI.

# Slice Contents Depends on
1 Collections core + local indexers New EncDotNet.S100.Collections project: model, JSON (de)serialization, ICollectionSourceIndexer, folder walk, S-100 CATALOG.XML indexer (with GML polygon parsing), S-57 CATALOG.031 + DSID peek indexer, loose-file indexer (probe), S-128 file indexer. Tests over the committed samples. — (done: header read added in Datasets.S57)
2 NOAA ENC feed indexer Conditional-GET fetcher and raw cache (injectable HttpMessageHandler), streaming parser, filter, facets (state/district/region counts). Tests over a trimmed committed fixture. No network in tests. 1
3 Viewer: store, service, Library panel collections.json store, CollectionService (background indexing, availability), Library panel replacing the Catalog panel (S-128 session group + "Keep in library"), Add to Library commands and NOAA feed dialog, drag-drop prompt. 1, 2
4 Coverage overlay Polygon overlay, band/scale gating, availability styling, antimeridian handling, map ↔ panel selection sync, "Datasets in library here". 3
5 Load and load-as-you-pan ExchangeSetService subset split, CollectionLoadService, lazy-gate generalization to S-100 display scales. 3
6 NOAA download Managed download folder, atomic extract, progress, cancellation and size, download-and-load. 3, 5
7 Small independents Record exchange sets and folders in Open Recent. Viewer README and docs update. —

Visual validation for slices 4–6 uses the MCP-driven viewer checks against the local corpora (IC-ENC, UKHO and USACE under the standard dataset root) and a NOAA "Alaska" collection. Alaska exercises the antimeridian and a large item count.


9. Later (explicitly deferred)

  • Update detection: compare a Local item's recorded edition and update (DSID or .source.json) with the feed. Show Outdated. Provide "Update" and "Update all". Consider NOAA's OneWeek_ENCs.zip style change bundles.
  • More feeds:
    • NOAA S-102/S-104/S-111 on AWS. These are remote S-100 CATALOG.XMLs: gzip, relative fileNames, and per-model-run overwrites for S-111.
    • USACE IENC catalogues (bbox only). Done (#670 slice 1).
    • A curated directory of known catalogues. Done (#670 slice 2), as our own known-sources.json, not OpenCPN's GPL chart_sources.xml.
    • Community chartcatalogs lists. Done (#670 slices 3–4, §7.4).
    • User-added catalogue URLs. Done (#670 slice 5):
      • CatalogueFormatDetector reads only the head of the document (gzip-aware) and recognises the format by its root element.
      • S100_ExchangeCatalogue is recognised but reported as not yet supported.
      • The viewer keeps the user's catalogues in catalogues.json, a known-sources document written by KnownCatalogueSources.Write, and lists them under "Custom" in the directory.
  • S-128 as the interchange format: export a collection as an S-128 catalogue, and import one as a collection.
  • SECOM source kind: the read-only first slice is #804 (§7.5). Certificates, signing and mutual TLS stay deferred.
  • Copy on import as an option.
  • File watching and automatic re-indexing for local sources.
  • M_COVR deep index for S-57 polygons.
  • CLI and MCP: tracked by #792, which shares the viewer's Library MCP tools (list_library_sources, query_library_items, add_library_source, library_action, …) with the s100 mcp serve host rather than adding new tool names. As built (#792 chunk 1): the library runtime moved from the viewer into EncDotNet.S100.Collections.Library: CollectionLibrary (was the viewer's LibraryService), the item states (LibraryItemState, LibraryAvailability), forecast-run facts and the item query (LibraryQuery, CoverageHitTest). It stays in this project, not a new assembly: the runtime needs only the model, indexers and persistence already here. Opening items into a dataset catalog, which needs Datasets.Pipelines, comes later behind a host interface. As built (#792 chunk 2): load and download are host-neutral too.
    • LibraryDownloads (was the viewer's LibraryDownloadService) reports progress through plain records. LibraryDownloads.ManagedFolders holds the download folder layout.
    • LibraryLoader groups items by exchange set and opens each group through an ILibraryDatasetOpener host seam:
      • The viewer's opener wraps IExchangeSetService.OpenSubsetAsync.
      • CatalogLibraryOpener (in Mcp.Tools) loads each item into any IMutableDatasetCatalog, such as HeadlessMutableCatalog.
    • LibraryOperations composes these for a headless host and tracks each operation in LibraryActivityTracker until its datasets are open. That covers download, re-index, then open (#790), and AwaitIdleAsync waits on it.
    • The viewer keeps thin adapters that add its notifications.
    • Later: s100 collections list|index|find --at lat,lon.

10. Open questions

  1. Index cache format. Gzipped JSON is simple and probably enough (7k NOAA polygons, a few MB). Measure in slice 2. If load time matters, switch to a compact binary like DiskS57CatalogCache.
  2. Overlap between collections. If a local folder and the NOAA download folder both contain US5AK1AM, the Library shows two items that resolve to different paths, and both could load. Accept this for now and revisit with update detection, which needs cross-source matching by cell name anyway.
  3. Band gating for S-100 items with no display scale. Use approximateGridResolution, or always show them? Initial proposal: always show outlines, and gate loading by viewport only.
  4. Where the detection helpers live. Move ExchangeSetDetection into EncDotNet.S100.Collections, or into EncDotNet.S100.ExchangeSets so the facade can use it too? Leaning towards ExchangeSets.
  5. Library tab placement. Should the Library become the default first activity tab, ahead of Datasets, since it is now the primary way in?