Skip to content

Phase-1 decentralized catalog crawler (onix plugin + module) - #894

Draft
ameersohel45 wants to merge 33 commits into
catalog-crawlerfrom
feat/catalog-crawler-phase1
Draft

Phase-1 decentralized catalog crawler (onix plugin + module)#894
ameersohel45 wants to merge 33 commits into
catalog-crawlerfrom
feat/catalog-crawler-phase1

Conversation

@ameersohel45

@ameersohel45 ameersohel45 commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Summary

Phase-1 decentralized catalog crawler — a framework-agnostic module (pkg/crawler) plus a thin onix plugin and a DS-internal /crawl handler — built on top of the initial catalog-crawler scaffold.

This branch builds on catalog-crawler's last 4 commits and reworks/extends them into the full Phase-1 implementation:

  • 167eac3 refactor(catalogcrawler): rewrite to match the file spec
  • 81e8981 fix(dedi): real detached-JWS sign/verify per §7
  • c38080a fix(catalogcrawler): match manifest catalog-index entry on the registry
  • 36f8f2b catalog-crawler: add crawler plugin and DS-facing catalog/pull handler

Behaviour change vs catalog-crawler (the base)

The base branch implemented a synchronous, pull-based crawler:

  • Crawler.CrawlSubscriber(CrawlRequest) → CrawlResult — one call fetched + verified a subscriber's manifest/index and composed the full catalog (CatalogResult.Catalog = the baseline with every change file applied).
  • A DS-internal catalog/pull handler invoked it synchronously and returned the catalogs inline in the HTTP response — a CatalogPullCallbackAction-shaped body {status: COMPLETED|FAILED, catalogs: […], error} ("no ACK/callback split; a single synchronous response suffices").
  • Change detection was digest-based (CrawlModeIncremental skipped a part whose digest was unchanged); scope was one subscriber per request.

This branch reworks that into a background, push-based crawler:

  • The crawler is a daemon: Start / Stop + an on-demand CrawlNow(indexURL) → runId. Scheduled index/catalog jobs run continuously from plugin init.
  • The endpoint is now an async trigger: POST /crawl {indexUrl} returns 202 ACCEPTED + a runId — it does not return the catalog inline. The crawler pushes each resolved catalog to Discovery's /catalog/push (→ Kafka → publish job → Elasticsearch); outcomes are observed via logs/metrics and the crawler_* tables, not the trigger's response.
  • Change detection is version-driven (a persisted per-catalog cursor); the digest is kept purely as a fetch-time integrity check.
  • State is durable: a Postgres work queue (claim/lease + retry/park), MERGE-only delta pushes, and per-index scheduling.

(The /crawl handler file is named catalogPullHandler.go after a file-only rename; its struct and route are still crawl.)

What's in it

Crawl model

  • Postgres work queue (crawler_index / crawler_queue / crawler_catalog) with FOR UPDATE SKIP LOCKED, a claim token + lease; the cursor advances only on a successful push.
  • Version-driven change detection; the per-file digest is verified as an integrity gate before decode.
  • MERGE-only delta push (CRAWLER_MERGE_ONLY): the delta is built straight from the change file's catalog metadata envelope — no baseline fetch. First sync / re-baseline still fetches the baseline. A change file that omits the envelope is a permanent content fault (no silent baseline fallback).
  • Pushes to Discovery /catalog/push with publishDirectives (catalogType, updateMode, visibleTo).

Fetch / decode

  • SSRF-guarded fetch with dial-time IP validation (DNS-rebind safe), artifact size caps, and a decompression-bomb guard.
  • Conditional-GET (ETag / If-Modified-Since → 304 skip).
  • json + json.gzip via an extensible codec registry.

Reliability / lifecycle

  • Typed fault taxonomy driving park-vs-retry: digest mismatch / SSRF / oversize / content-invalid park (ERROR, operator signal); 5xx and 408/425/429 retry with capped backoff.
  • §6b lifecycle vocabulary (SyncOutcome / SyncPhase / IndexOutcome / DaemonState).

Observability

  • Component/stage/message structured logging with run_id / pass_id; CRAWLER_LOG_LEVEL.
  • Ops metrics: crawler_sync_outcome_total{outcome,fault}, a seconds_since_last_success liveness gauge, catalogs_parked / catalogs_tracked, sync_lag, index_poll, queue_depth, and push/index latency histograms.

Scheduler

  • An on-demand /crawl'd index joins the recurring schedule: the scheduled pass re-polls every persisted index (deduped, each gated by its own next_crawl_at), so a later change file is picked up with no second /crawl.

Structure

  • Domain packages: pkg/crawler/{catalog,fetch,decode,publish,source,store,runner,config} + a composition root; full catalogcrawlercrawler rename across module / cmd / plugin. /crawl handler file renamed crawlHandler.gocatalogPullHandler.go (file-only; symbols/route unchanged).

Testing

  • TDD throughout. go test ./pkg/crawler/... green (incl. DB-gated store/engine tests via CRAWLER_TEST_DB_DSN), plus gofmt / go build / go vet.

Deferred to Phase 2

  • Signature-tuple verification, per-catalog scope enforcement, removals / FULL mode, and a concrete registry source client.

ameersohel45 and others added 30 commits July 28, 2026 11:43
… onix plugin)

Add the Phase-1 catalog crawler as a framework-agnostic engine
(pkg/catalogcrawler) plus a thin onix plugin that adapts it, per the
locked design/plan.

Engine (pkg/catalogcrawler, no core/module|pkg/plugin imports):
- change detection by version cursor (sync / skip-unchanged / rollback / retire)
- selection: public + configured networkIds -> publishDirectives.visibleTo
- resolve full catalog from baseline + change files (reuses pkg/catalogfile)
- push builder: publishDirectives updateMode=FULL + visibleTo
- retry backoff + sync_status rollup (ok/partial/failed)
- Postgres state: crawler_index / crawler_queue / crawler_catalog +
  FOR UPDATE SKIP LOCKED claim + coalescing enqueue + transactional complete
- source resolution: config / registry / on-demand
- two scheduled jobs (index + catalog) linked by the queue
- HTTP client: digest verify, SSRF guard, size cap
- config from env; standalone driver cmd/catalog-crawler

onix integration (thin plugin over the engine):
- Crawler plugin interface -> Start/Stop/CrawlNow
- plugin adapter builds+starts the engine, injects schemav2validator
- /crawl handler (202 on-demand trigger), replacing the inline catalog/pull
- crawl module wiring: handler type, manager, config, module map, yaml block

Phase-1 decisions reflected in code: signature verification kept but flag-off;
resolve-full -> FULL replace (removals handled by replace); crawler-side schema
validation of the /push body against the catalog/publish action; queue keyed by
catalog_id (file urls read from the index); all config-driven, secrets via env.

Grounded against the File Specifications example (golden model_test); example
identifiers kept neutral.

Tests: TDD throughout. DB-backed tests skip without CRAWLER_TEST_DB_DSN and use
a per-package Postgres schema so parallel `go test ./...` stays isolated.
Close the design gaps found in review (all three were already in the
design/plan — this is code catching up):

- Metrics: inject a Metrics sink (NopMetrics default; OTel impl) — pushed and
  failed{reason} counters, queue_depth gauge, push + index histograms.
- Batch push: split a resolved catalog by resource count; first batch FULL,
  the rest MERGE, posted sequentially, stopping on the first failed batch
  (single call below the threshold). sync_status rolled up over batch outcomes.
- next_crawl_at: the index job skips indexes not yet due (per-index cadence);
  /crawl forces an immediate pass. GetIndex now reads next_crawl_at.

Plan (self-review) updated: these three marked done; the Registry HTTP client
and the scope filter (schemaTypes subset of approvedScopes) marked explicitly
deferred.

TDD: BatchCatalog unit test + engine metric assertion. Whole repo green; .so builds.
…LL removals)

Discovery's /push MERGE upserts by id and never deletes (only FULL does
deleteByCatalogId, confirmed in catalog-publish-job/PersistenceStep) — so the
previous always-FULL push was wrong: it delete+reinserted the whole catalog on
every change. The catalog job now picks the mode from what actually changed:

- ResolveWithChangeset folds the complete catalog AND records the changeset
  since the cursor: upserted ids, whether any removal happened, and a
  baseline-fallback flag (new / cursor behind the current baseline).
- only upserts        -> MERGE, pushing catalog metadata + just the changed
                         resources/offers (filterCatalog).
- any removal, or new / re-baselined -> FULL (the complete catalog; replace).
- BatchCatalog now takes a base mode and keeps catalog metadata on every batch
  (fixes a latent bug where MERGE batches dropped descriptor/provider/etc.).

Docs: design + plan updated to the mode-by-changeset push model.

TDD: ResolveWithChangeset, filterCatalog, and BatchCatalog(base-mode) tests.
Whole repo green; .so builds.
…safety, SSRF)

External review found real concurrency/data-loss issues. Fixes:

- #1 queue race: add a claim_id token; ClaimNext stamps it, Complete/
  FailQueueItem settle only the holder's claim, and a coalescing Enqueue no
  longer resets an in-progress row (only bumps to_version). Complete releases
  (not deletes) a superseded row -> no lost update / double-push.
- #2 data loss: the version cursor now advances ONLY on a successful push.
  failItem never advances it; persistent failures retry at capped backoff and
  are recorded via RecordFailure (no version write) -> an outage longer than
  MaxAttempts can't silently drop catalogs.
- #3 orphaned claim: ClaimNext reclaims claims older than a lease (15m).
- #4 CrawlNow use-after-close: runs on the engine ctx, tracked in wg, drained
  by Stop; the /crawl handler no longer detaches on context.Background().
- #5 interval panic: clamp IndexInterval/CatalogInterval in New (no NewTicker(0)).
- #7 digest fail-open: FetchFile rejects an empty digest (integrity mandatory).
- #8 change continuity: ResolveWithChangeset requires fromVersion == running;
  a gap fails the resolve instead of mis-composing.
- #6 SSRF: CheckRedirect re-runs the host guard on every hop; deny CGNAT.
- #10 telemetry: PluginEntries() reports the crawler slot.

Also: BatchCatalog keeps catalog metadata on every batch (MERGE batches no
longer drop descriptor/provider).

Deferred (documented): consolidate onto pkg/security/artifactfetcher (#9),
dial-time IP pinning for DNS-rebind, enforce validUntil (P2).

Docs (design + plan) updated. TDD: claim-token/lease/supersede + continuity +
digest-fail-closed tests. Whole repo green; .so builds.
The review-fix commit accidentally reformatted stdHandler.go, healthcheck*.go,
http_metric.go and config_test.go via 'gofmt -w core/module/handler/'. Those are
whitespace-only and unrelated to the crawler — reverted to keep the PR scoped to
crawlHandler.go + config.go.
Add .json.gzip as a second catalog/index format alongside .json, built
as an extensible codec registry so zstd/brotli are one entry + one
decoder later (fetch/verify/schema/push untouched).

- codec registry: compiled-in encoding->decoder map (not config-
  selectable — a decoder inflates untrusted bytes, so it is trust
  surface). Single decode() choke point applies a separate decompressed
  cap (decompression-bomb guard: reject, don't truncate).
- format selection: explicit FileEntry.Encoding wins, else inferred
  from the .json.gzip / .json.gz URL suffix.
- digest-before-inflate: verify the sha-256 over the compressed bytes
  at rest before spending CPU decoding; transport compression disabled
  so hashed bytes == artifact bytes (no digest-over-decompressed
  ambiguity). Only authenticated bytes are ever decoded.
- failure classification: unsupported encoding, decompression bomb,
  corrupt/oversize/continuity-gap, schema-invalid, and 4xx rejections
  are now PERMANENT — parked (status='failed', next_attempt_at=
  infinity), cursor not advanced, logged at error level. A version bump
  re-activates the parked row, so a re-published catalog recovers
  automatically. Transient errors (network/5xx/timeout) still retry at
  capped backoff.

Config: CRAWLER_MAX_DECOMPRESSED_BYTES (default 100 MiB).

Tests: codec unit tests (passthrough, gzip round-trip, bomb-at-cap,
corrupt, unknown-encoding, additive-codec, encodingFor); an end-to-end
gzip fetch test (suffix + explicit encoding, digest over compressed
bytes); and state TestParkAndRecover (park SQL + version-bump
recovery). Full module go test ./... green; plugin .so builds.
Avoid re-downloading an unchanged index. The index job now sends the
stored ETag / Last-Modified as If-None-Match / If-Modified-Since; a host
that honours them answers 304 Not Modified and we skip the download
entirely, advancing only the crawl cadence.

Opportunistic with graceful fallback — a host that sends no validators
simply returns 200 and we fall back to the existing version-based change
gate, so correctness never depends on the headers.

- migration 0003: crawler_index gains nullable etag / last_modified.
- state: IndexState carries the validators; GetIndex/UpsertIndex read
  and store them; new TouchIndex bumps last_crawled_at + next_crawl_at
  on a 304 without touching version or validators.
- http: FetchIndex takes an IndexCond and returns an IndexResult
  (NotModified + captured validators); getCond sends the conditional
  headers and detects 304. The catalog job fetches unconditionally
  (it always needs the body to resolve the entry).
- engine: crawlIndex sends the stored validators, short-circuits on 304
  (log + TouchIndex + return), and stores fresh validators on a 200.

Plugin + driver unchanged — they pass httpc.FetchIndex as a method
value, which follows the new signature.

Tests: TouchIndex; the full 200->capture->304 HTTP round-trip; the
no-validator 200 fallback; and an engine 304 pass (echoes the stored
ETag, enqueues nothing, keeps the version, advances the cadence). Full
crawler + state suites green against Postgres; module-wide go test green.
Two related crawler improvements (kept in one commit — both touch
engine.go/state.go and don't split into per-file commits that each
compile).

1) Detailed per-pass report. crawler_catalog.push_status stops being a
   single value and becomes a jsonb ARRAY of pass records, appended on
   every settle and capped to the most recent 20. Each element:
   {ts, from, to, mode, resources, offers, removals, batchesAcked,
    batchesTotal, outcome, httpStatus, reason}. outcome is one of
   pushed | partial | failed | rejected | skipped. reason/http_status
   still mirror the latest pass for cheap top-level queries; status
   (active/retired) is unchanged. A partial push (batchesAcked <
   batchesTotal) is now visible. Migration 0004 converts push_status
   text -> jsonb, guarded so Migrate can replay it idempotently.
   GetCatalogReports reads the array. Run totals ("how many catalogs /
   resources") are an aggregate over the latest element.

   - resolve.go: Changeset now counts RemovedResources/RemovedOffers.
   - engine.go: builds a PassReport on success / retire / push-fail;
     failReport() builds the minimal report for pre-push failures;
     fail/failItem/failPermanent carry the report.

2) Size-based push batching. BatchCatalog packs resources by SERIALIZED
   byte size instead of a fixed resource count, so a batch of large
   resources can't exceed Discovery's payload cap and get 400'd. Config
   CRAWLER_PUSH_BATCH_SIZE (count) -> CRAWLER_MAX_PUSH_BYTES (bytes,
   default 10 MiB). The engine passes a doc budget = cap - envelope
   headroom (4 KiB) - len(visibleTo). FULL lead (keeps offers) then
   MERGE rest is preserved; a single resource over the budget still
   forms its own batch (can't split) and its reject surfaces in the
   pass report.

Tests: state append/cap/partial history against Postgres; engine pass
recorded with counts/mode; BatchCatalog byte-budget splits + oversize
single resource. gofmt/vet/build clean; full crawler + state suites and
module-wide go test green; plugin .so builds.
… entry

The Discovery /push CatalogPublishAction schema REQUIRES catalogType on
every publishDirective (beckn.yaml: publishDirectives.items.required =
[catalogId, catalogType]; catalogType enum = MASTER | REGULAR).
BuildPushBody omitted it, so every push was rejected with 400
SCH_SCHEMA_VALIDATION_FAILED. Caught by a local end-to-end run against
the discover docker stack.

Thread it through: PushMeta gains CatalogType, the engine sets it from
the index CatalogEntry.CatalogType, and BuildPushBody emits it in the
directive. Forwarded verbatim (no fabricated default) — a publisher that
declares no/invalid catalogType is surfaced as a reject in the pass
report, not silently defaulted.

Verified: local crawl now pushes 200 for both a plain-JSON catalog and a
json.gzip catalog; resources land in Elasticsearch. TestBuildPushBody
asserts the directive carries catalogType. gofmt/build/vet clean; full
crawler + state suites green.
This version pushes updates as MERGE only and builds the delta straight
from the change files — FULL and removals are deferred to a later
version (in both the crawler and the publish job).

- CRAWLER_MERGE_ONLY (default true): always updateMode=MERGE.
- ResolveDelta builds the payload from the change files' `catalog`
  metadata envelope (id/descriptor/provider — schema-required) + the
  union of resource/offer upserts (latest per id), with NO baseline
  fetch, for an incremental update. A first sync (cursor behind the
  baseline) or a change set without the metadata envelope falls back to
  a full resolve, still pushed as MERGE.
- Removals are recorded in the changeset and logged
  (crawler.catalog.removals_skipped) but NOT applied; a change with
  nothing to upsert (e.g. removal-only) is skipped and the cursor still
  advances (completeSkipped, outcome=skipped).
- The mode-by-changeset FULL path (resolvePush with MergeOnly=false) is
  kept dormant behind the flag for the next version.

Grounded: publish job has no delete-by-id (MERGE upserts only, FULL
wipes by catalog_id) and beckn.yaml Catalog requires
id+descriptor+provider — so removals need FULL, and a MERGE delta must
carry the metadata envelope.

Tests: TestResolveDelta (delta built from change files, baseline not
fetched, removals recorded-not-applied, ok=false without envelope);
TestEngine_MergeOnly_DeltaPush (incremental → MERGE, baseline NOT
fetched, delta-only resources, cursor advances). gofmt/build/vet clean;
full crawler + state suites and module-wide go test green.
Domain-oriented (~6-7 pkg) layout with intent-revealing names, a two-PR
plan (structural PR-A / observability PR-B), a function/type/package
naming review (fix names that lie), file-cohesion contract, content
schema-validation seam, and test-suite organization. Synthesis of a
three-lens architecture review; packages decode/ and telemetry/ adopted.
…eady log spec

Add §6b "Define the lifecycle explicitly": the four scattered status/outcome
vocabularies (retry.go, model.go, engine.go, state/) consolidated into typed
enums placed by layer — catalog/lifecycle.go (Outcome, CatalogStatus, Decision,
DropReason) at the shared bottom, runner/lifecycle.go (DaemonState, PassState,
PassKind, CatalogPhase) as orchestration-only — with classifyOutcome/Permanent
collapsing the 3x-duplicated "4xx => rejected" rule and the ACTIVE/active casing
bug. Rejects phase-named packages (cohesion over temporal grouping).

Add §9b "Log specification (implementation-ready)": the exact log contract
(~15 INFO/WARN/ERROR events, down from ~25) with keys, levels, required fields,
and correlation (run_id/pass_id) — success is a trace, health is a metric, only
exceptions and lifecycle boundaries are logs; ~10 *_failed DB logs collapse to
crawler.store.unhealthy. Wire acceptance criteria for both.
… add phase-1 + gzip review docs

- README: update to the current index/catalog scheduled-job wiring, config
  keys, on-demand /crawl, source resolution, and phase-1 verification scope.
- add catalog-crawler-phase1-review.md (correctness findings).
- add catalog-crawler-gzip-support-review.md (json.gzip decode design).
…subject

Fix the subject-less "crawler started/running/completed" framing: a lifecycle
belongs to a unit of work, not to the long-running process. Rework §6b and §9b
around a Catalog Sync (catalog_id + fromVersion->toVersion) as THE lifecycle —
started -> running(resolving/verifying/validating/scoping/publishing) -> done AS
a named terminal outcome (pushed|partial|skipped|dropped|retired|faulted). The
crawler process is a supervisor (ready->stopping->stopped, no "completed"); the
index crawl is the feeder.

- §6b: drop PassState{started,running,completed,failed}; enums regrouped by whose
  lifecycle they describe (SyncOutcome/FaultClass/DropReason/CatalogStatus in
  catalog/, SyncPhase/IndexOutcome/DaemonState in runner/); transitions redrawn.
- §9b: every log line now carries explicit lifecycle + state fields so the
  lifecycle is legible on the line; catalog regrouped (sync exceptions, sync
  batch, index crawl, daemon, store); ~13 events; rendered INFO vs DEBUG streams.
- §9: span tree + metrics realigned (crawler.sync/.index_crawl, crawler.sync.*).
…ecycle vocabulary

Behavior-preserving restructure of the flat pkg/catalogcrawler into the
domain-oriented layout from docs/catalog-crawler-restructure-cr.md (PR-A);
telemetry (§9/§9b — instruments, log catalog, run_id/pass_id) is deferred
to PR-B and untouched.

Structure (flat -> 8 packages + composition root):
- catalog/  pure domain (imports only stdlib + catalogfile): index, signature,
  visibility, decide, eligibility, compose (incl. ResolveDelta), fault, lifecycle
- fetch/    client, guard, integrity, conditional
- decode/   registry, gzip
- publish/  request, batch, client (Push + BatchOutcome moved here from http.go;
  docCounts/ackedCount here)
- source/   static, registry
- store/    open, queue, cursor, indexstate, scan (one package, shared tx)
- runner/   runner, indexpass, syncpass, backoff, ports, lifecycle
  (engine.go split by job)
- config/   settings
- catalogcrawler.go composition root (New(Settings, Options)); telemetry.go plumbing

Renames (§5a, compiler-checked): failItem->scheduleRetry, failPermanent->
parkPermanently, fail->routeFailure, failReport->newFailureReport,
FailQueueItem->RescheduleQueueItem; Source.IndexURLs->IndexRefs;
PartOutcome->BatchOutcome; engine Config->EngineConfig; IndexCond->
IndexConditions; getCond->getConditional; TouchIndex->AdvanceIndexCadence;
processItem split into handleQueueItem + syncCatalog.

Lifecycle (§6b, recentered on the Catalog Sync as the subject):
- catalog/lifecycle.go: SyncOutcome (pushed/partial/skipped/dropped/retired/
  faulted — failed/rejected folded into faulted+FaultClass; dropped reserved for
  scope, a P2 path), CatalogStatus, Decision, DropReason; each String() is the
  stable wire value.
- catalog/fault.go: FaultClass + PermanentError/IsPermanent + Permanent()
  (drives park-vs-retry).
- runner/lifecycle.go: SyncPhase (resolving/verifying/validating/scoping/
  publishing), IndexOutcome (unchanged/enqueued/failed), DaemonState (ready/
  stopping/stopped/start_failed) + declared legal transitions; classifyOutcome
  (single home for the 4xx=>rejected/faulted rule). PassState/PassKind/
  CatalogPhase removed (the subject-less framing).
- Service is pre-production, so the persisted pass-report outcome adopts the new
  vocabulary outright; external behavior (what's pushed, retry-vs-park, cursor
  advancement) and the emitted metrics/logs are unchanged.

Tests moved with their code (catalog/ white-box; adapters black-box +
export_test where needed; store/ Postgres-gated per-package schema; runner/
in-memory port fakes). Plugin + cmd rewired to the composition root.

Verified static (gofmt/build/vet clean; all sub-packages + module-wide go test
green; plugin .so links) and live end-to-end against the real FreshMart catalog
(MERGE push, 4 resources + 4 offers, data in Elasticsearch, pass report reads
the new SyncOutcome).
…, audit hardening, file docs

Multi-agent audit + fix pass over the restructured crawler.

Observability / lifecycle (§6b/§9b), recentred on the Catalog Sync as the subject:
- Every log line now carries lifecycle + state (event key = crawler.<lifecycle>.<state>)
  plus run_id (per tick) and pass_id (per catalog); ~26 ad-hoc events -> ~13 typed
  INFO/WARN/ERROR events; success terminals + running sub-states are DEBUG/trace.
- Collapsed ~13 store *_failed logs into one crawler.store.unhealthy{operation}.
- Daemon lifecycle centralised in the composition root: crawler.daemon.ready{effective
  config} / start_failed{stage} / stopping / stopped — identical contract for both drivers.
- SyncPhase + IndexOutcome now driven (were dead types); Debug added to the Logger port.
- Metric label for failures is now the typed FaultClass.String() (was reasonCategory).

Protocol fix (Discovery contract): context.action was catalog/publish (a provider->CS
action); the crawler calls Discovery's /catalog/push, so the correct Beckn action is
catalog/push (spec v2.0.0 beckn.yaml const catalog/push). Fixed in publish/request.go +
tests + docs; removed the fabricated CRAWLER_SCHEMA_ACTION README row (no such env exists).

Audit hardening:
- SSRF DNS-rebinding (TOCTOU): fetch now validates the IP at dial time via a custom
  DialContext and dials the validated IP directly, closing the gap where a second,
  independent resolution could reach an internal address. isPublicIP is the one source of truth.
- ClassifyFault: 408/425/429 are retryable, no longer parked as permanent.
- config: clamp FetchTimeout/IndexInterval/CatalogInterval/MaxAttempts <=0 to defaults
  (a 0 fetch timeout on untrusted URLs was a real footgun).

Docs: clear top-of-file purpose header on every crawler source file (newcomer-friendly).

Behavior otherwise unchanged: same DB writes, push calls, retry/park, cursor advancement,
304 handling. Build/vet/gofmt/test green across all catalogcrawler packages + cmd.
Full rename per the CR decision: pkg/catalogcrawler -> pkg/crawler,
cmd/catalog-crawler -> cmd/crawler, pkg/plugin/implementation/catalogcrawler
-> .../crawler, plugin id + built .so catalogcrawler.so -> crawler.so
(install/build-plugins.sh, config yaml), and all import paths, package
clauses, error-message prefixes, and doc code-path references. Subpackages
(catalog, fetch, decode, publish, source, store, runner, config) unchanged
under the new root. Mechanical only — build/vet/gofmt/test green.
Rename the domain vocabulary and the two job files to say what they do:
  - catalog.Decide  -> catalog.DetectChange   (change detection)
  - catalog.Select  -> catalog.ResolveScope   (network-scope resolution)
  - decide.go / eligibility.go / compose.go -> change.go / scope.go / resolve.go
  - runner/indexpass.go -> crawl.go, runner/syncpass.go -> sync.go
    ('pass' read like pass/fail; the files hold the crawl job and the sync job)

Pure renames; behaviour unchanged (build/vet/test green).
syncCatalog becomes an 8-line stage list read top to bottom
(resolveEntry -> fetchContent -> verifyContent -> scope -> batch ->
publish -> complete) driven by runPipeline; a stage is one line to insert.
crawlIndex becomes a linear spine (load -> dueForCrawl -> conditional fetch
-> 304? -> processIndex -> recordIndex) with decideCatalog per catalog.

Behaviour-preserving: same fetch/verify/scope/push, same phase breadcrumbs,
same fault routing, same metrics/logs (every external call preserved 1:1).
Verified: gofmt, build, vet, go test ./pkg/crawler/...
Review of pkg/crawler surfaced no correctness bugs (the crawl/sync refactor
was verified faithful). Applied cleanups:

Comments/naming — stale after the crawl.go/sync.go + DetectChange/ResolveScope rename:
  - runner.go/index.go/lifecycle.go: drop stale indexpass/syncpass + eligibility
    references; correct the DropReason doc
  - sync.go: pipeline stage scope -> resolveVisibility (it resolves visibleTo;
    membership is decided upstream in decideCatalog); helper resolvePush -> buildPushDoc

Observability:
  - catalogPass: count OutcomePartial as 'retried', not 'faulted' (a partial push
    is a retry-in-progress); additive 'retried' field on crawler.sync_pass.completed

De-duplication (drift risk):
  - sync.go: extract settle() shared by complete/completeSkipped/retire
  - publish: Rollup returns acked; delete AckedCount
  - catalog/resolve.go: ResolveDelta reuses accumulateChangeset
  - crawl.go: crawlResult.checked -> fetched (+ log field indexes_fetched)

Deliberately NOT applied (would regress): DetectChange->*int64 (nil-deref risk),
readCapped cross-pkg helper (couples fetch/decode), RecordFailure/upsertCatalog
SQL merge (DB-write regression risk). The dead-code report was a false positive:
every flagged symbol is test-covered / reserved P2 vocabulary.

Verified: gofmt, build, vet, go test ./pkg/crawler/... all green.
One process, two jobs → three log components (daemon/crawl/sync). Every line is
component + stage + natural message + attributes + stats. Documents the stages,
messages, levels, the fault->step mapping, reading/filtering, and the known
Phase-2 gaps (removals-deferred 'skipped', retirement-not-propagated 'retired').
Implements docs/crawler-logs.md. Behaviour-preserving (only log output changes).

- Collapse the 7 lifecycle values to 3 components: daemon / crawl / sync.
- Every line: component + stage + natural message + attributes + stats.
- crawl stages: polled / queued / finished / failed. sync stages: syncing /
  synced / skipped / retired / failed / finished. Failure message says WHERE it
  broke (fault->step) and WHETHER it recovers.
- Collapse the sync phase breadcrumbs into one 'syncing' line; keep the readable
  syncCatalog pipeline. Drop the SyncPhase transition machine.
- catalog/lifecycle.go -> catalog/status.go (persisted enums: store/DB contract).
- Delete runner/lifecycle.go: DaemonState -> runner.go, classifyOutcome ->
  sync.go, IndexOutcome + unused crawlResult.outcome removed.
- storeUnhealthy is component-aware (stage=failed, fault=store).
- Fix: an attempts-exhausted transient fault still retries (item is rescheduled),
  so it logs WARN 'still retrying past the limit', NOT ERROR 'parked'. Only a
  permanent fault parks.

Verified: gofmt, build (incl. cmd + plugin), vet, go test ./pkg/crawler/... green.
Re-review of the component/stage/message refactor (behavior verified faithful;
these are cleanups + doc↔code sync):

- Remove reserved-unused scope-drop vocabulary: DropReason (type/consts/String)
  and OutcomeDropped (+ its dead tally case) — re-add in Phase 2 with the code
  that produces them.
- Delete dead DaemonStartFailed const (unused; value contradicted emitted 'failed').
- stepPhrase: add content_invalid -> 'build the push request', ssrf -> 'download
  the files' (these faults previously mis-reported the step).
- Spell out the tally in crawl.finished / sync.finished messages.
- Drop the sync.started event (asymmetric with crawl; keeps INFO quiet) — a tick
  is summarized by 'finished' only.
- Replace the dead syncStage.what field with a []stageFn pipeline whose labels
  are inline comments (recipe stays readable, no write-only field).
- Fix stale comments (lifecycle.go->status.go, sync_pass/index_pass wording;
  soften the telemetry header re: field order / run_id).
- docs/crawler-logs.md: banner -> implemented; document crawl.failed,
  polled not_modified, daemon at=start; drop sync.started; add ssrf/content_invalid
  to the fault->step table.

Verified: gofmt, build (crawler+cmd), vet, go test ./pkg/crawler/... green.
Rework the runner.Metrics port from the plumbing-only 5 signals to the set ops
and business actually need. New/changed signals:

- crawler_sync_outcome_total{outcome,fault} — one labeled counter (replaces the
  separate CatalogPushed/CatalogFailed): real success rate + the fault label that
  routes an error spike to publisher vs Discovery vs infra.
- crawler_seconds_since_last_success{job} — liveness gauge (OTel observable gauge
  driven by MarkPassSuccess per tick) that disambiguates 'queue empty because
  caught up' from 'queue empty because the crawler is wedged'.
- crawler_catalogs_parked / crawler_catalogs_tracked — standing gauges of stuck
  inventory + coverage (new Store.CountParked/CountTracked).
- crawler_sync_lag_seconds — queue-residence freshness (synced_at - enqueued_at;
  ClaimedItem now carries EnqueuedAt).
- crawler_index_poll_total{result} — per-source reachability.
- kept queue_depth + push/index-latency histograms.

Wiring: RecordSyncOutcome at the five sync terminals (fault only on failures),
MarkPassSuccess once per crawl/sync tick, parked/tracked refreshed each sync tick.
Port stays behind NopMetrics by default (framework-agnostic); the OTel adapter in
crawler/telemetry.go implements it (observable gauge is mutex-guarded, no race).

Verified: gofmt, build (crawler+cmd), vet, go test ./pkg/crawler/... (incl.
DB-backed store/engine) all green.
CrawlNow ran a full crawl but logged nothing on completion (only DEBUG-level
per-index traces, off by default) and never returned a run_id, so a
successful on-demand crawl was silent and uncorrelatable. CrawlNow now mints
its run_id synchronously, returns it to the HTTP caller (surfaced as runId in
the /crawl response), and emits the same INFO "crawl finished" summary a
scheduled tick uses.

Also add a trigger field (scheduled/on_demand) to every crawl-component log
line, since a scheduled tick and an on-demand crawl otherwise look identical
without cross-referencing run_id against the HTTP response that minted it.
…G_LEVEL

Both drivers hardcoded slog.NewJSONHandler(..., nil), which defaults to
INFO — so per-item DEBUG lines (logPolled, logQueued, etc.) were dropped
in every deployment regardless of the onix core logger's own log.level
(a separate sink). CRAWLER_LOG_LEVEL now controls this handler directly.
… work

catalogPass broke out of its claim loop on a Store.ClaimNext error and fell
through to the shared tail, which calls Metrics.MarkPassSuccess("sync")
unconditionally. A sustained Postgres outage therefore reset
crawler_seconds_since_last_success every ~30s (one tick), so the "crawler
wedged" alert — the single reason that gauge exists, per docs/crawler-metrics.md
— could never fire on the condition it was built to catch.

Return instead of break, matching indexPass, which already withholds
MarkPassSuccess("crawl") when Source.IndexRefs fails. The queue-drained break is
untouched: an idle tick is a successful tick, and must keep marking success —
that idle-vs-wedged distinction is the whole point of the gauge.

recMetrics.MarkPassSuccess was a bare no-op that no test asserted on; it now
counts per job. The new test drives a real Postgres store with a closed pool, so
the failure arrives through the actual ClaimNext path rather than a mock.
… cause

Two coupled defects in the fault taxonomy (B8 + B2 of the Phase-1 review).

B8 — the fetch layer raised its integrity and guard failures as plain
fmt.Errorf, so IsPermanent was false and routeFailure sent them to
scheduleRetry, which never parks. A digest mismatch — Phase 1's ONLY integrity
check, and the spec's "treat as tampering and flag it" signal — therefore
re-downloaded the same bad bytes every =<5 min forever, logged WARN and counted
as fault=transient, instead of parking with an ERROR an operator would see.
FaultDigestMismatch/FaultSSRF/FaultOversize were already declared permanent;
nothing produced them. Now raised via PermanentFaultf: digest mismatch, the
download cap, and both SSRF rejections.

The non-200 branch deliberately stays transient — a publisher's 503 must retry,
not park the catalog until they publish a new version. Covered by a test.

B2 — ClassifyFault collapsed every PermanentError to FaultDecode, so a
continuity gap, a tampered digest and an SSRF rejection all surfaced as
fault_class="decode" and pointed operators at the wrong stage. PermanentError
now carries an optional FaultClass that the raiser (which knows) sets, and
ClassifyFault reports it, falling back to FaultDecode when unset. The class
survives fmt.Errorf(%w) and *url.Error wrapping, which is how dial-time SSRF
rejections reach the classifier — both pinned by tests.

Knock-on fix: stepPhrase("oversize") returned "batch the catalog", written for a
batching fault no code path raises (Discovery rejecting a batch is a 413 =>
push_rejected). With the class now produced by the download cap, that phrase
would have printed the wrong stage, so it becomes "download the files". The
decompression-bomb cap stays unclassified (=> decode, "unpack the files"), which
is the truthful stage for it; both park, only the phrase differs.

docs/crawler-logs.md's fault table is corrected to match and now states which
classes park vs retry, and why the two size caps report different stages.
… the schedule

The scheduled index pass enumerated only Source.IndexRefs() (config + registry),
so an index crawled once via the on-demand /crawl endpoint was never revisited —
a later change file needed another manual /crawl. Now indexPass unions those refs
with every index already recorded in crawler_index (new Store.KnownIndexes),
deduped by URL (configured refs win) and each still gated by its own
next_crawl_at, so a once-crawled index keeps being re-polled on the normal
cadence and picks up new change files on its own.

- store.KnownIndexes: SELECT index_url/participant_id/source FROM crawler_index.
- runner.withPersistedIndexes: merge + dedup; a store error is non-fatal (logged
  as store unhealthy, the pass proceeds with the configured refs).
- On-demand still force-runs immediately (bypasses the next_crawl_at gate); it
  just also gets scheduled afterward. Deleting an index's crawler_index row (the
  reference UI's Reset) drops it from the schedule again.

Test (DB-backed): TestIndexPass_ReCrawlsPersistedIndexes — a persisted on-demand
index is re-crawled by the scheduled pass, and a URL that is both configured and
persisted is crawled once (deduped).

Verified: gofmt, build (crawler + cmd), vet, go test ./pkg/crawler/... green.
…es (no baseline fallback)

An incremental MERGE builds the delta straight from the change files' catalog
metadata envelope (id/descriptor/provider). When that envelope was absent,
buildPushDoc fell back to ResolveWithChangeset — re-downloading the baseline and
folding the whole catalog — which silently masked a malformed change file behind
a full re-resolve.

Now a missing envelope on an incremental update (cursor >= baseline) is a
permanent content fault: buildPushDoc returns PermanentFaultf(FaultContentInvalid,
...), so the item parks (ERROR) with the baseline NOT fetched, nothing pushed, and
the cursor unchanged — it re-activates when the publisher republishes a compliant
change file (version bump). Change files must carry the required catalog metadata.

First sync is unchanged: cursor < baseline (a new catalog, or a publisher
re-baseline/compaction) still fetches the baseline via ResolveWithChangeset — that
is the initial resolve, not a fallback.

Test: TestEngine_MergeOnly_NoEnvelope_ParksNoBaselineFallback — an envelope-less
change file parks the catalog with no baseline fetch, no push, cursor held.

Verified: gofmt, build (crawler + cmd), vet, go test ./pkg/crawler/... green.
@ameersohel45
ameersohel45 requested a review from nirmay as a code owner July 29, 2026 13:07
@nirmay

nirmay commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Thanks for the writeup and the work here — the crawl engine's core mechanics (queue claim/lease, SSRF dial-time guard, decompression cap) are solid. I don't think this is mergeable in its current shape, though — there are two equally-weighted sets of blockers: structure/placement, and correctness/security. Both need to be addressed in the same pass, not deferred.

Structure / placement

1. cmd/crawler shouldn't be a new top-level binary.
The repo's only existing top-level binaries are cmd/adapter, cmd/catalogpublisherctl, cmd/test. This PR adds cmd/crawler/main.go as a second, independent daemon entrypoint that duplicates what pkg/plugin/implementation/crawler/crawler.go already does as a plugin. Every other plugin runs only through the plugin/handler path — none has a standalone cmd. If a standalone worker mode is genuinely needed, it should be a mode on the existing adapter, not a second binary with its own lifecycle.

2. Hard-coded Postgres coupling.
pkg/crawler/store/open.go calls sql.Open("pgx", dsn) directly, and the plugin requires CRAWLER_DB_DSN to start at all — there's no storage interface a different backend could satisfy. This makes the plugin unusable for anyone not running Postgres. Please refactor so pkg/crawler depends on a Store interface, with Postgres as one pluggable implementation of it, consistent with how other stateful plugins are structured.

3. docs/ is a brand-new, inconsistent top-level convention.
The base branch has no docs/ directory. This PR introduces one with 5 crawler-only files, several of which read as internal review notes (catalog-crawler-phase1-review.md, catalog-crawler-gzip-support-review.md, catalog-crawler-restructure-cr.md) rather than shipped documentation. Every other plugin documents itself via its own README.md under pkg/plugin/implementation/<name>/. Please fold the real docs into the plugin README and drop the review-note files from the diff entirely.

4. pkg/crawler shouldn't be a new top-level package tree.
Every plugin-specific implementation currently lives entirely under pkg/plugin/implementation/<name>/. This PR instead creates pkg/crawler/{catalog,fetch,decode,publish,source,store,runner,config} as a sibling to genuinely cross-cutting packages like pkg/security, pkg/model, pkg/telemetry. Please fold this back under the plugin's own directory (e.g. pkg/plugin/implementation/catalogcrawler/internal/...). If there's a real "framework-agnostic second driver" need behind this, that's worth an explicit discussion — it doesn't justify a stray top-level package and a stray top-level binary landing together.

Correctness / security

5. SSRF pre-check has no timeout.
checkPublicURL's net.LookupIP call runs before the request is built and has no context bound to it — unlike the dial-time guard, which correctly uses a context-bound resolver. A slow or malicious resolver for an attacker-supplied publisher host can stall this check indefinitely, bypassing FetchTimeout entirely.

6. Retry cap doesn't actually park.
scheduleRetry is documented as "give up after MaxAttempts failed pushes," but the code never parks on exceeding it for transient faults — it logs and keeps retrying forever with capped backoff. A permanently-failing catalog never surfaces as an operator-actionable state.

7. Digest verification fails open.
A FileEntry with a blank/missing digest is treated as verified, so FetchFile returns unverified bytes. Since signature verification is deferred to Phase 2, digest is the only integrity gate in this phase — and it's optional as written. A malicious or MITM'd index that simply omits digest on a change file gets its content pushed to Discovery as authentic.

8. No per-index lock between on-demand and scheduled crawls.
CrawlNow (the /crawl trigger) and the scheduled ticker can run concurrently against the same index with no lock, risking duplicate queue enqueues and lost-update races on the version cursor. Untested.

9. Signature verification silently dropped, not just an unused arg.
The prior catalogcrawler verified per-file Ed25519 signature tuples and the manifest's detached-JWS proof before trusting fetched content. This engine only checks a SHA-256 digest and defers signing to "Phase 2" — that's a real security capability regression, not a constructor-arg cleanup, and should be called out plainly rather than folded into a scoping note. the branch that was created earlier was already using the signer plugin and include verification artifacts that should have been used instead.

10. /crawl has no source allowlist.
Combined with #5, an unauthenticated internal endpoint that accepts any indexUrl and triggers a server-side fetch is close to an unauthenticated SSRF primitive. There's no check that indexUrl is one of the configured CRAWLER_INDEX_URLS.

Please restructure per 1–4 and fix 5–10 in the same pass — happy to re-review once it's reshaped.

@ameersohel45
ameersohel45 marked this pull request as draft July 29, 2026 17:17
…d in review

Closes all ten findings from the PR 894 review, and five further defects found
during adversarial review that were not on that list.

Structure and placement
- Delete cmd/crawler. It was never wired into build-plugins.sh, any Dockerfile,
  any compose file or CI; the plugin path was always the only wired path.
- Move pkg/crawler under pkg/plugin/implementation/crawler/internal/crawler, so
  nothing outside the plugin can import it.
- Remove docs/. Three of its five files were internal working notes, including a
  564 line change request marked "Status: Proposed". The logging and metrics
  reference content is now part of the plugin README.
- Move the six persisted-state types (PassReport, CatalogState, IndexState,
  KnownIndex, QueueItem, ClaimedItem) into package catalog, then declare
  runner.Store over them. All 16 signatures name only catalog types and the
  standard library, so a backend can satisfy the port without importing
  Postgres. Backend selection goes through a registry and factory with pgx
  confined to one file, and CRAWLER_ENABLED (default false) means an operator
  not running the crawler needs no DSN and opens no database.

Signature verification
- Restore per-file signed tuple verification, which the branch had dropped to
  digest only. Mandatory, fail closed, no off switch. Uses the existing
  artifactverifier.VerifyFileTuple rather than new cryptography.
- Keys come from the network registry by {subscriberId, keyId}, the same path
  signvalidator uses for transport signatures. There is no key configuration.
  The crawl handler now requires a registry plugin and refuses to construct
  without one, because a crawler that cannot verify parks everything silently.
- Registry errors are transient and retry; a definitive unknown key is permanent
  and parks. Lookups cache on (subscriberId, keyId), never keyId alone. Add a
  dedicated signature fault class so an operator key problem no longer reports
  as digest_mismatch and blames the publisher.

Data integrity, found in review
- A partial push no longer parks. Batches are sent in order and stop at the
  first un-acked batch, so a later 4xx after an earlier ack used to park the
  item without advancing the cursor, leaving Discovery permanently half applied.
  Two further park sites inside the batch loop bypassed the first fix and
  reported BatchesAcked as zero, hiding it.
- The index version cursor is withheld when a catalog fails to enqueue. It used
  to advance regardless, so a store blip mid pass dropped an update until the
  publisher bumped the index, made worse by conditional GET answering 304.
- A corrupt but correctly signed baseline now parks instead of settling as a
  clean skip and advancing the cursor. A genuinely empty catalog still skips.
- ResolveDelta enforces change-file continuity, so a missing intermediate
  version parks rather than silently composing a divergent catalog.
- A crawl pass that could not do its work no longer marks itself successful, so
  a wedged crawler stops reporting itself healthy.

Other
- Bound the SSRF DNS pre-check with the configured fetch timeout; the resolver
  was context aware but no production caller supplied a deadline.
- Refuse 0.0.0.0/8, 240.0.0.0/4 and global multicast in the public-IP check.
- Park on exceeding MaxAttempts instead of retrying forever.
- Serialise crawlIndex per index URL so an on-demand and a scheduled crawl
  cannot race the version cursor.
- QueueDepth counts only pending work, so one parked catalog no longer
  permanently false-fires the backlog alert.
- CRAWLER_MERGE_ONLY parses with strconv.ParseBool; "0" and "no" used to mean
  true. Reject CRAWLER_REGISTRY_URL, which was accepted while being ignored.
- Cap the /crawl request body. The endpoint still accepts any index URL, which
  is the recorded scope for this release; trust rides on the signature check.

Verification
- go build and go vet clean, 61 test packages pass, no new gofmt findings.
- The Postgres-gated store and engine tests were run against a real database
  rather than skipped.
- A new end-to-end test runs the whole chain against real Postgres with a
  publisher origin, a registry and Discovery. The control case, a catalog signed
  by a key the registry does not vouch for, parks with a signature fault and
  nothing reaches Discovery.

Note: digest verification was reported as failing open. It already failed
closed, and integrity.go is unchanged by this work.
@nirmay

nirmay commented Jul 30, 2026

Copy link
Copy Markdown
Collaborator

Thanks @ameersohel45
This PR overall looks good, we have some more changes in the spec to be implemented. I will come back on the plan for the same.

Wire a registry-backed index-discovery source and unify the on-demand
/crawl trigger onto it, so the scheduled pass and /crawl take one
registry-based input model instead of two.

Source: DediQueryClient calls GET {base}/query/{networkId} and keeps the
live provider records that publish a catalog_index_url; registrySource
dedups by index URL across networks. Config now accepts
CRAWLER_REGISTRY_URL, and selectSource picks the registry source only
when a network is also configured (matching LoadSettings' own
source-required check) - otherwise it falls back to the static
CRAWLER_INDEX_URLS list, closing a gap where a bare registry URL would
have started a crawler that discovered nothing.

/crawl: the body is now {registryUrl, networkId|networkIds}; the handler
runs /query discovery and crawls each discovered index on demand.
CrawlNow(indexURL) becomes CrawlRegistry(registryURL, networkIDs) across
the definition interface, the plugin adapter, the engine, and the
disabled stub, built through an injected registry-source factory so the
engine stays agnostic of how a query client is constructed.

Tests: TDD red->green for the /query source, the config/selection
predicate, and CrawlRegistry; the e2e now proves /query -> discover ->
verify -> push end-to-end on loopback (plus the wrong-key control still
parks). The signature gate is unchanged and still enforced.
@ameersohel45

Copy link
Copy Markdown
Contributor Author

/crawl payload change — now registry-based (commit f9a7037)

The on-demand POST /crawl trigger no longer takes a raw index URL. It now takes a registry URL + network(s) and runs the same DeDi /query discovery the scheduled pass uses, so on-demand and scheduled crawls share one input model.

Request

Before

{ "indexUrl": "https://publisher.example/catalog-index.json" }

After

{
  "registryUrl": "https://fabric.nfh.global/registry/dedi",
  "networkId": "beckn.one/testnet"
}
Field Required Notes
registryUrl Absolute http/https. DeDi registry base; the crawler calls GET {registryUrl}/query/{networkId} to discover the provider catalog indexes, then fetches + verifies + pushes each.
networkId ✅* Single network id, e.g. beckn.one/testnet.
networkIds List form, e.g. ["beckn.one/testnet","beckn.one/mainnet"]. Merged with networkId, de-duplicated, order preserved.

* at least one of networkId / networkIds must be present.

Response — 202 Accepted

{
  "status": "ACCEPTED",
  "registryUrl": "https://fabric.nfh.global/registry/dedi",
  "networkIds": ["beckn.one/testnet"],
  "runId": ""
}

Validation: a missing/blank registryUrl, a non-http(s) scheme, or no network → 400 Bad Request. The crawl runs asynchronously; progress surfaces in the crawler's telemetry, correlated by runId.

Scheduled pass (unchanged input model)

The background pass reads the same source from config — CRAWLER_REGISTRY_URL + CRAWLER_NETWORK_IDS — and discovers via /query every CRAWLER_INDEX_INTERVAL. Setting a registry base with no networks falls back to the static CRAWLER_INDEX_URLS list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants