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:
- Local sources — folders of exchange sets (S-100
CATALOG.XMLand S-57CATALOG.031) and loose datasets, referenced in place. - 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.BoundingPolygonis kept as an unparsed GML string (ExchangeCatalogueReader.cs:300). - S-57
CATALOG.031CATD records have no edition, update or issue date. Those live in each cell's DSID record. TheEncDotNet.S57package exposes them asEditionNumber,UpdateNumber,IssueDateandUpdateApplicationDate. - The extent overlay can only draw rectangles, and catalogue coverage is never drawn.
ExchangeSetServiceopens 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
- a provider abstraction (
- 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
- 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
- Importing produces a persisted index. It survives restarts and can be refreshed on demand. The datasets themselves are not loaded.
- The Library panel lists collections and their items with metadata, and supports filtering by product, usage band, availability and text.
- Coverage outlines on the map, drawn from indexed polygons, with styling by availability and gating by band and scale.
- Load a chosen item or subset now, or register it for load as you pan. S-100 as well as S-57.
- Download NOAA cells (one, a selection, or those in view) into a viewer-managed folder. Afterwards they behave as local items.
- 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:
Boundsis present for every catalogued item, so hit-testing, culling and lazy-load gating never depend on polygons. It isnullonly 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.)Coverageis 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. GeoCoveragereplaces the viewer's single-ringDatasetCatalogCoverage.- Antimeridian-crossing coverage matters for Alaska and the Aleutians.
Rings are stored as published. The overlay normalizes them for
display (§6.2), and
Boundsrecords a crossing asWest > East, the same convention asBoundingBox.
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)
- Walk the tree once. A directory that contains
CATALOG.XML(or the alternate spellings inExchangeSetDetection) orCATALOG.031is an exchange-set root. It is indexed by §5.2 and the walk does not descend into it to look for loose files. - A
.zipthat contains a catalogue is indexed as an exchange set withRootIsZip. - Any other candidate file becomes a loose dataset, indexed by §5.3.
Candidates are
.000,.h5and.gml..xmlcounts only when it is not a catalogue or support file. - S-57 update files (
.001and up) outside an exchange set are attached to their base cell asUpdateRelativePaths. 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:ExchangeCatalogueReaderwithExchangeCatalogueReadOptions.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 perDatasetDiscoveryMetadata. Edition and update come from the catalogue.- New: parse
DataCoverage.BoundingPolygonGML (gml:Polygon/gml:exterior/gml:posList, EPSG:4326 lat-lon axis order) intoGeoCoverage. Fall back toBoundingBox. - Display scales come from the existing
ResolveMinimum/MaximumDisplayScale(). - Updates are grouped per base using the same rules as
S101ExchangeSetUpdatePlan.
- New: parse
- S-57
CATALOG.031:S57ExchangeSetCatalog.ReadBaseCells. It gives the cell name, relative path, updates and the CATD bounding box.- New:
S57DatasetHeader(inDatasets.S57) readsEDTN,UPDN,ISDT,UADTand 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 upstreamEncDotNet.S57change 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.
- New:
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 noXmlSerializertree. Map each<cell>to an item:name→ Name/Keylname→ Titlecscale→ CompilationScaleedtn/updn/isdt/uadtstatus→ Status- usage band from the name
states/coast_guard_districts/regions/zipfile_size→Properties<cov><panel>rings →GeoCoverage. TypeEis an exterior ring andIis a hole.zipfile_location/zipfile_size/zipfile_datetime_iso8601→RemoteItemLocation
- Filter by
NoaaEncFilterat 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.FromPositionsnormalises 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
IncludeCancelledis 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_districtsandregionsvalues (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.Statusis mapped fromS128ProductStatus.- Location is
NoItemLocationunless the entry carries an online resource, in which case it isRemoteItemLocation. - The details that
S128DatasetCatalogSourcemaps 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.
LocalManifestIndexerre-reads the manifest on every refresh, so the source is a live reference. It then indexes each path of each selected group throughLocalSourceIndexer.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
groupandgroupNameproperties. - Groups on the index.
SourceIndex.Groupsrecords 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.jsonis authoritative, small, and edited by the user. It holds the collections and their sources. It is modelled onRouteStore: versioned document, debounced atomic save, and migration hooks.<data>/cache/collections/<sourceId>.index.json.gzholds the derived cache, one per source. It is safe to delete, and re-indexing rebuilds it. It is registered inViewerDataPaths' 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 coarseChangedevent, the same pattern asIDatasetCatalogSource. - Computes availability by joining items with the loaded-dataset
registry (
DatasetsViewModelentries keyed by resolved path), the lazy-load coordinator, the download folder, andFile.Exists.
CollectionLoadServiceturns items into loads (§7).CollectionCoverageOverlayreplaces the need to extendDatasetExtentIndicatorController:- It is a Mapsui
MemoryLayerof polygons, added throughIMapLayerCollection.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
Boundsfirst, 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
DatasetExtentIndicatorControllerdoes for boxes.
- It is a Mapsui
- 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:
LibraryCoverageOverlayControlleroutlines 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
IsBandEligiblerule (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 (
machinein the feed JSON), with the token masked.- Empty state: three large targets.
Slice 3 as built:
- The service is
LibraryService, notCollectionService. 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
ListBoxwith text and "show cancelled" filters, and availability is resolved lazily per row.- The Catalog panel's view model, view and
Viewer/Catalogstypes are deleted.MainViewModelno longer takes a catalogue panel.- The splitter keeps the persisted
CatalogInnerSplitsetting 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.ExchangeSetLayoutwith 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,DatasetCatalogAggregatorandS128DatasetCatalogSourceare 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:
- Fetch or refresh the feed.
- Pick states, districts or regions, with counts and total download size. Counts are de-duplicated across the selection.
- Enter a name.
- 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(IAssetSourceover the root, relative path, updates, bounds) →DatasetsViewModel.AddRangeFromExchangeSet, then load. - S-100 exchange-set members → the existing
AddFromExchangeSetpath, fed the chosenDatasetDiscoveryMetadataonly. - Loose →
DatasetsViewModel.LoadFromPathAsync.
Slice 5 as built:
- Subset open: instead of splitting
OpenAsync, a separateIExchangeSetService.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.IsScaleEligiblegates items without a band by their coarsest display scale.DatasetEntry.UsageBandis 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.IsRegisteringguard and covered by a regression test.- Entries loaded now: entries loaded immediately are taken out of the lazy loader, with
IsDeferredcleared.
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
UsageBandwhen it is present (S-57). Otherwise it uses the item'sMinimum/MaximumDisplayScale(S-100), soLazyCellGate.ShouldBeLoadedis 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):
PortrayalCacheWarmerrenders 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
CreateExchangeSetCellProcessorand 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.NoaaEncCellDownloaderfetches the cell zip to a.partialfile, extracts it into a staging folder, and swaps that in for any previous copy only once it is complete.- Layout discovery: it finds
CATALOG.031and<CELL>.000plus its updates rather than assuming the layout.- Record: it writes
.source.jsonwith the edition, update and layout relative to the cell folder.- In the viewer:
LibraryDownloadServicedownloads 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_locationto<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,updnandzipfile_datetimein<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>.000plus updates. TheLocalItemLocationroot is<CELL>/ENC_ROOTand the base is discovered the way loose-cell folders are.
7.4 Community lists and packages (#670)
As built:
- Reader and source:
ChartCatalogsProductCatalogReaderreadsRncProductCatalogChartCatalogslists. AChartCatalogsFeedSource(JSON kindchartCatalogsFeed) is scoped by aChartCatalogsFilterover entry numbers. Repeated entries are listed once.- Packages: an entry is a download that may hold many cells, so its
RemoteItemLocationcarries aDownloadFolder(community/<list stem>) and aPackage(the entry number, made file-system safe).- Downloading a package:
EncCellDownloadersaves the whole package under<folder>/<package>/and records every.000it 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,
ChartCatalogsFeedIndexerindexes 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 withpackageTitle.- 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_datetimeis 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.
SecomClientinEncDotNet.S100.Collections.SecomcallsCapability,GetSummary(paged; capped, withSecomSummaryList.Truncatedset only when the cap is hit) andGet. It falls back from/v2to/v1and pins whichever answered. The parser (SecomJson) is tolerant of the drift listed under Gotchas below.- Source.
SecomSource(JSON kindsecom) with aSecomFilter(products matched on this side, plus an optional WKT area sent asgeometry).SecomSourceIndexer:
- lists each object as an online item whose
RemoteItemLocationpoints atGetwithEnvelope = Secomand a single-fileLayout(<identifier>.gml,.000or.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;
DescribeAsynccounts objects per product for the Add to Library dialog.- Download.
EncCellDownloaderunwrapsGetresponses. It refuses encrypted objects and signatures that do not match. It decompresses single-file ZIP payloads, and records theSecomSignatureCheckin.source.json.- Signatures.
SecomSignatureVerifierchecks the hex signature, DER or raw r‖s, over the decoded data with the firstpublicCertificate(base64 DER or minified PEM). Signer trust is judged againstSecomTrustAnchors(§7.8). It is kept in Collections rather than shared withExchangeSetVerifier: 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 fromSha3, a FIPS 202 Keccak written from the standard and tested against hashlib vectors and, where available, the platform.Unsupportednow means an unknowndigitalSignatureReference. Baleen and ELMAN sign with SHA3-384; CCG and AIVN with SHA2-384.- Discovery. A URL that answers
Capabilityis recognised asKnownCatalogueFormat.Secom, both in the directory's "Add a catalogue by URL" and in MCPadd_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.Synckeeps a local copy of every listed object.LibrarySync(EncDotNet.S100.Collections.Library) listens toCollectionLibrary.SourceIndexed. Per-kind rules are anILibrarySyncPolicy: whether a source is synced, which items it wants locally, its managed folder, and when its listing is complete enough to prune.SecomSyncPolicyis the first; other online kinds will opt in. The sync works as follows:
- Downloads new and changed objects through
LibraryDownloadswithout a notification. It stops withNeededByteswhen that would exceedLibrarySyncOptions.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:
SecomClientdetects 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_sourcetakessyncandinMapView, andlist_library_sourcesreportssync.- 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.digitalSignatureValueis computed over the decodeddatabytes 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.
datais base64 inside JSON. A download is a JSON call, not a file URL, soEncCellDownloadercannot be reused as-is.- Servers drift between v1 and v2.
- Product names:
S124vsS-124. - The summary list:
informationSummaryObjectvssummaryObject. paginationmay be absent.- Ping fields differ, and timestamps come compact or ISO.
- The parser must be tolerant.
- Product names:
- 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.
FeedCachedoes not fit. It is GET and URI-keyed only.
Plan.
- #804: a read-only source.
- A tolerant v2/v1 client with an injectable handler.
- A
SecomSourceand 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_sourcesupport.
- 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.DatasetsViewModelgives every exchange set of one source the sameExchangeSetHeader, 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.ShowOnMapis a persisted flag on every kind, omitted from JSON when off. The viewer'sLibraryShowOnMapServicehandles 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_sourcetakesshowOnMap, andlist_library_sourcesreports 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.
Syncmoved fromSecomSourcetoCollectionSource, next toShowOnMap; #808's"sync": truestill reads.- Policies.
ILibrarySyncPolicy.Supportssays which kinds a policy handles, andIsSyncedmeans it supports the kind andSyncis on.OnlineSyncPolicycovers 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_sourcesyncworks for every online kind, andset_library_source_optionssetssync/showOnMapon 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.
ProbeAsynccallsCapability, then oneGetSummarypage:
- 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 withadd_library_source url=<endpoint>.
7.8 SECOM signer trust (#823)
- Anchors.
SecomTrustAnchorsholds 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.
BuiltInis 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.
FindAnchorbuilds the chain with custom root trust and ignores time validity. Revocation is checked separately (§7.11). The statedpublicRootCertificateThumbprintmust 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.jsonkeeps theSecomSignatureCheckand 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.TryGetDownloadedjudges the signer again, against its currentTrustAnchorsand 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
signatureproperty 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. ItsCreateHandler()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, throughLibraryDownloads.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
SecomTrustAnchorsroot, 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,WrongHostor (#833)Revoked.Probes and the viewer.
SecomRegistry(given the validator) attaches the decision toSecomProbeResult.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_servicesreportsserverCertificateandserverCertificateAnchor.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
Capabilityanonymously but share data only with an MCP client certificate (mutual TLS).
SecomClientIdentityis 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 subjectUID, as MCP identity registries issue it),Anchor(when it chains to aSecomTrustAnchorsroot) and its validity period.- It is held in memory only.
Presented by
SecomServerTrust's handlers.
SetIdentitysets, replaces or clears it at runtime.- Each handler builds an inner
HttpClientHandlerfor 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.Identitynames the identity presented.Interim supply, until the keys & certificates UX (§7.13 now stores identities):
--secom-identity <path>, with the password fromSOUNDCHARTS_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/summaryand adds enveloped, signed POST forms:POST …/v2/object/search/summary(GetSummary) andPOST …/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/summaryis answered with anything but 404.- CertificateRefused is kept for active refusals (401, 403, or a TLS-level refusal) of the identity.
serviceVersionis 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.orgserver certificate both namehttp://api.maritimeconnectivity.net/x509/api/certificates/crl/urn:mrn:mcp:ca:mcc:mcp-idreg-newand 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:
thisUpdateis now andnextUpdateis 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.
SecomRevocationfetches and checks CRLs itself instead of usingX509RevocationMode.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:
thisUpdatehas passed, allowing 5 minutes of clock skew, andnextUpdatehas 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
SecomTrustAnchorsroot 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>.crlin 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-longnextUpdatewould 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 alwaysRevoked:
- 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.Revocationsays so, the row adds "Whether it has been revoked could not be checked.", andlist_secom_servicesreportsserverCertificateRevocation. 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 thesignatureproperty 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 thesignatureproperty 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.CacheOnlyand never wait on the network, because download status is read from the UI.- Index runs (
SecomSourceIndexer, in the background) and downloads may fetch..source.jsonrecords the result asrevocation. 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
SecomRevocationis shared, with a plainHttpClient, because CRLs are plain HTTP, and the feed cache directory. It goes toSecomServerTrust,SecomSourceIndexerandLibraryDownloads.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
certificateHoldremoval; 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/summaryfor GetSummary;POST …/v2/object/searchfor 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-384envelopeRootCertificateThumbprintof the root (lower-case hex), andenvelopeSignatureTime(yyyy-MM-ddTHH:mm:ssZ).envelopeSignatureis 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.SecomEnvelopeSignerbuilds both bodies. The root comes fromSecomTrustAnchors(MCP Root for an MCC identity); a certificate under no anchor names itself.envelopeSignatureCertificatecarries 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, orset_secom_identity {signatureAlgorithm}, overrides it.Client.
SecomClientswitches 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.EncCellDownloaderthen posts a signed Get, built from the item's GET URI bySecomClient.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;
SecomTrustAnchorsaccepts 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-identityorset_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.SecomIdentitiesholdsSecomIdentityReferenceentries 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;
RevokedAtonce a check finds the identity revoked.
SecomIdentityInUsenames the one in use.N stored, one in use.
SecomIdentityStoreimports, uses, replaces and removes identities. Using one callsSecomServerTrust.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):
- The user's anchors are applied.
- The identity in use is presented: the command-line one if given, otherwise the stored one loaded from the key store.
A
--secom-identityidentity wins for its run. The page shows it as a temporary "From command line" row with no actions.Revocation.
SecomServerTrust.CheckIdentityRevocationchecks 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 andIdentityRevokedis raised. The store recordsRevokedAtand 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.
DisabledBuiltInAuthoritieslists 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 throughEncCellDownloader.TrustAnchorsSource, so the change reaches them too.MCP.
set_secom_identityalso 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 includesreferencefor a stored identity.Reset all settings also deletes stored identities' keys.
7.14 From a certificate problem to its fix (#880)
Coded diagnostics.
IndexDiagnostic.Codesays what kind of problem a diagnostic is, so hosts don't parse messages. A SECOM source that can't be listed carries aSecomDiagnosticCodesvalue:
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 TLSAuthenticationException.The code is on the Error diagnostic, or on the Warning when a cached list is shown instead.
Library status.
LibraryNodeText.Statusgives 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.
SecomTrustRefreshre-reads only the affected SECOM sources:
- on
IdentityChanged, those with an identity code;- on
AnchorsChanged, those withServerUntrusted.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.Identityisdefault,noneor ansc-ident:reference (SecomIdentitySetting), saved incollections.json. It's a reference only: no key, password or thumbprint copy. New sources start ondefault. Sources saved before the setting existed read asdefault, 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'sSecomIdentityInUsebecomes it. The host tells the trust withSecomServerTrust.DefaultIdentityId;s100 mcp serve'sset_secom_identityand--secom-identitymake 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.Identitytags its own).CreateHandler()keeps one innerSocketsHttpHandlerper 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 onRemoteItemLocation.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.
SocketsHttpHandlerretries such a request up to three times on new connections; aConnectCallbackallows 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.
HttpClientHandlercan't tell, so the inner handlers setSslOptions.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 toSslOptions.RemoteCertificateValidationCallback; its sender is theSslStream, whoseTargetHostNamegives the host. Both callbacks merge into oneSecomHandshakeper 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
SecomPresentationonSourceIndex.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.jsonin 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.Classifynow uses what the handshake showed. A failed connection after the server certificate was accepted and a client certificate asked for isCertificateRefused(an identity was presented) orNeedsCertificate. A rejected signed request is the newSignedRequestRefused("Signed request not accepted"); the Library says "Your certificate was refused" for it too.Hints.
SecomIdentityHints.Judgegives the picker's hint, andSecomPresentation.HintForapplies 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
defaultwhile it's the default);- trusted authorities changed: the sources with an untrusted server, as before.
Replace and remove.
SecomIdentityUsagelists 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 tonone; 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_sourcesshows it. The tools are shared withs100 mcp serve(#864). The CLI has no SECOM identity store yet (deferred), so there a reference names no identity and presents none, unlessset_secom_identityloaded that identity for the session.The picker (viewer,
IdentityPickerViewModelandViews/Secom/IdentityPicker) is shared by the add flow and source details. Its closed value is the setting's name and sub-line. Its menu listsDefault · {name},None · anonymousand 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.Waitscopes 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
identityandwaittoo, soadd_library_source(viewer ands100 mcp serve) can add a service before it can be read; withoutwaitit 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,noneor 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 defaultorchosen(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.SetIdentitynow also takes an id,IdentityChangedisIdentitiesChanged,CreateHandler(presentIdentity)isCreateHandler(),CheckIdentityRevocationreturns a result per identity,SecomSourceIndexer.SignerisTrust,EncCellDownloader.SecomSignerisSecomTrust,LibraryDownloads.ManagedFolderstakes the trust,SecomRegistry.ProbeAsyncandSecomSourceIndexer.DescribeAsynctake an identity setting,SecomDiagnosticCodes.Classifytakes the identity and what the handshake showed,ILibraryEditor.SetOptionsAsynctakes aSourceOptionsChange, andLibraryCatalogueReaders.Secomtakes 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 (
containerType1). 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.SIGNandS-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 acancellationrecord, 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.
EncCellDownloaderchecks 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.
dataReferenceis stable, butinfo_identifiercarries 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 itsinfo_sizeisn't a byte count. For an undated object, a hash of its summary (stable identifier, name, description, size, status, product version) isRemoteItemLocation.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_lastModifiedDateis 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'smessage, and a signed request refused this way reads asSignedRequestRefused.- The chain is built with intermediates they configure, so a client sending only its own certificate is accepted too.
info_sizeis 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=0returns onlymaxItemsPerPage, per SECOM §5.5.SecomClientasks 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 perHttpClientand 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'sOneWeek_ENCs.zipstyle change bundles. - More feeds:
- NOAA S-102/S-104/S-111 on AWS. These are remote S-100
CATALOG.XMLs: gzip, relativefileNames, 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 GPLchart_sources.xml. - Community
chartcatalogslists. Done (#670 slices 3–4, §7.4). - User-added catalogue URLs. Done (#670 slice 5):
CatalogueFormatDetectorreads only the head of the document (gzip-aware) and recognises the format by its root element.S100_ExchangeCatalogueis recognised but reported as not yet supported.- The viewer keeps the user's catalogues in
catalogues.json, a known-sources document written byKnownCatalogueSources.Write, and lists them under "Custom" in the directory.
- NOAA S-102/S-104/S-111 on AWS. These are remote S-100
- 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 thes100 mcp servehost rather than adding new tool names. As built (#792 chunk 1): the library runtime moved from the viewer intoEncDotNet.S100.Collections.Library:CollectionLibrary(was the viewer'sLibraryService), 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 needsDatasets.Pipelines, comes later behind a host interface. As built (#792 chunk 2): load and download are host-neutral too.LibraryDownloads(was the viewer'sLibraryDownloadService) reports progress through plain records.LibraryDownloads.ManagedFoldersholds the download folder layout.LibraryLoadergroups items by exchange set and opens each group through anILibraryDatasetOpenerhost seam:- The viewer's opener wraps
IExchangeSetService.OpenSubsetAsync. CatalogLibraryOpener(inMcp.Tools) loads each item into anyIMutableDatasetCatalog, such asHeadlessMutableCatalog.
- The viewer's opener wraps
LibraryOperationscomposes these for a headless host and tracks each operation inLibraryActivityTrackeruntil its datasets are open. That covers download, re-index, then open (#790), andAwaitIdleAsyncwaits on it.- The viewer keeps thin adapters that add its notifications.
- Later:
s100 collections list|index|find --at lat,lon.
10. Open questions
- 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. - 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. - 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. - Where the detection helpers live. Move
ExchangeSetDetectionintoEncDotNet.S100.Collections, or intoEncDotNet.S100.ExchangeSetsso the facade can use it too? Leaning towards ExchangeSets. - Library tab placement. Should the Library become the default first activity tab, ahead of Datasets, since it is now the primary way in?