@remnic/core 9.26.0 → 9.28.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access-admin-ops-surface.d.ts +13 -10
- package/dist/access-admin-ops-surface.js +43 -38
- package/dist/access-audit.js +2 -2
- package/dist/access-authorization-probe.d.ts +13 -10
- package/dist/access-authorization-probe.js +44 -39
- package/dist/access-boundary.d.ts +13 -10
- package/dist/access-boundary.js +43 -38
- package/dist/access-cli.js +100 -94
- package/dist/access-cli.js.map +1 -1
- package/dist/access-health-types.d.ts +10 -1
- package/dist/access-http-lcm-compaction.d.ts +13 -10
- package/dist/access-http.d.ts +13 -10
- package/dist/access-http.js +53 -48
- package/dist/access-identity-continuity-surface.d.ts +9 -8
- package/dist/access-identity-continuity-surface.js +43 -38
- package/dist/access-lcm-surface.d.ts +13 -10
- package/dist/access-lcm-surface.js +43 -38
- package/dist/access-mcp.d.ts +13 -10
- package/dist/access-mcp.js +48 -43
- package/dist/access-memory-search-fanout.d.ts +2 -2
- package/dist/{access-namespace-preflight-wRSZEO5V.d.ts → access-namespace-preflight-BiQ_-iUJ.d.ts} +1 -1
- package/dist/access-namespace-preflight.d.ts +3 -3
- package/dist/access-namespace-preflight.js +43 -38
- package/dist/access-observe-write-surface.d.ts +13 -10
- package/dist/access-observe-write-surface.js +43 -38
- package/dist/access-operations-batch.js +45 -40
- package/dist/access-operations.d.ts +16 -13
- package/dist/access-operations.js +47 -42
- package/dist/access-recall-concurrency.d.ts +13 -10
- package/dist/access-recall-concurrency.js +43 -38
- package/dist/access-recall-response.d.ts +13 -10
- package/dist/access-recall-response.js +43 -38
- package/dist/access-recall-surface.d.ts +13 -10
- package/dist/access-recall-surface.js +43 -38
- package/dist/access-schema.d.ts +80 -80
- package/dist/access-schema.js +4 -4
- package/dist/{access-service-DL8FJnTj.d.ts → access-service-CXkIgcGh.d.ts} +12 -8
- package/dist/access-service.d.ts +13 -10
- package/dist/access-service.js +43 -38
- package/dist/access-surface-catalog.d.ts +13 -10
- package/dist/access-wearables-meetings-surface.d.ts +4 -3
- package/dist/action-confidence.d.ts +1 -1
- package/dist/active-memory-bridge.d.ts +1 -1
- package/dist/active-recall.d.ts +1 -1
- package/dist/active-recall.js +4 -2
- package/dist/active-recall.js.map +1 -1
- package/dist/adapters/index.js +7 -7
- package/dist/adapters/registry.js +3 -3
- package/dist/behavior-learner.d.ts +1 -1
- package/dist/behavior-signals.d.ts +1 -1
- package/dist/bootstrap.d.ts +9 -8
- package/dist/briefing.d.ts +3 -2
- package/dist/briefing.js +12 -10
- package/dist/buffer-surprise-report.d.ts +1 -1
- package/dist/buffer.d.ts +3 -2
- package/dist/bulk-import/index.d.ts +3 -3
- package/dist/calibration.d.ts +1 -1
- package/dist/capabilities.d.ts +1 -1
- package/dist/{capsule-crypto-YO5QJ6L3.js → capsule-crypto-7FJQINUR.js} +2 -2
- package/dist/{catalog-BLLxZ5L5.d.ts → catalog-Bouj8Gfg.d.ts} +1 -1
- package/dist/causal-behavior.d.ts +1 -1
- package/dist/causal-consolidation.d.ts +1 -1
- package/dist/causal-consolidation.js +13 -11
- package/dist/causal-consolidation.js.map +1 -1
- package/dist/causal-trajectory-graph.d.ts +1 -1
- package/dist/{chunk-7RZJWZ46.js → chunk-227UFSEQ.js} +5 -5
- package/dist/{chunk-VGEH3STZ.js → chunk-2BEJ4XCF.js} +2 -2
- package/dist/{chunk-LIIQPDFN.js → chunk-2FYNLDKJ.js} +24 -3
- package/dist/chunk-2FYNLDKJ.js.map +1 -0
- package/dist/{chunk-BXLOS5AJ.js → chunk-2NLLXCJG.js} +2 -2
- package/dist/{chunk-6NNNURLK.js → chunk-2PHX3I5C.js} +8 -8
- package/dist/{chunk-YN4AUGNF.js → chunk-2TQ2UFKH.js} +65 -60
- package/dist/chunk-2TQ2UFKH.js.map +1 -0
- package/dist/{chunk-XAAIUXJO.js → chunk-3BSJBIZ3.js} +2 -2
- package/dist/{chunk-JCVQV6QX.js → chunk-3UOSVTFR.js} +11 -11
- package/dist/{chunk-AJ7QCSIS.js → chunk-7CPOZH6Y.js} +2 -2
- package/dist/{chunk-EXM2Q555.js → chunk-AHFZXUZM.js} +7 -5
- package/dist/chunk-AHFZXUZM.js.map +1 -0
- package/dist/{chunk-M2KGXXTL.js → chunk-ALTP3MFS.js} +2 -2
- package/dist/{chunk-77CNV7DY.js → chunk-BLAKS3CP.js} +4 -4
- package/dist/{chunk-FE5FKTCI.js → chunk-BR4UFMLO.js} +2 -2
- package/dist/chunk-CVILPI77.js +432 -0
- package/dist/chunk-CVILPI77.js.map +1 -0
- package/dist/{chunk-H2PBM562.js → chunk-DKQM5Q6Q.js} +2 -2
- package/dist/{chunk-CSXHRAJJ.js → chunk-EG64LXKL.js} +3 -3
- package/dist/{chunk-PTN6UAUA.js → chunk-ES36HTSC.js} +4 -4
- package/dist/{chunk-WMDRLTNA.js → chunk-EZWWCN7I.js} +2 -2
- package/dist/{chunk-XKGPIWGI.js → chunk-FBZTWUK5.js} +5 -5
- package/dist/{chunk-GJJBPI6I.js → chunk-FOCEI3TN.js} +6 -3
- package/dist/chunk-FOCEI3TN.js.map +1 -0
- package/dist/{chunk-Y6E37WRG.js → chunk-GJPCP5CA.js} +2 -2
- package/dist/{chunk-IHMXPG4P.js → chunk-GQSMYXKL.js} +6 -4
- package/dist/{chunk-IHMXPG4P.js.map → chunk-GQSMYXKL.js.map} +1 -1
- package/dist/{chunk-TGQ2NTWH.js → chunk-IJ6WHMFB.js} +16 -2
- package/dist/{chunk-TGQ2NTWH.js.map → chunk-IJ6WHMFB.js.map} +1 -1
- package/dist/{chunk-VDX2J7OX.js → chunk-IPLYGWQF.js} +6 -6
- package/dist/{chunk-XNUBF6RF.js → chunk-IYT632D5.js} +3 -3
- package/dist/{chunk-2QSZNTDO.js → chunk-JBPKEARU.js} +7 -7
- package/dist/{chunk-67DSDRUB.js → chunk-JIF3VRKQ.js} +8 -8
- package/dist/{chunk-V25OWXAC.js → chunk-JQ7T5VDC.js} +1 -1
- package/dist/chunk-JQ7T5VDC.js.map +1 -0
- package/dist/{chunk-MIFF37NN.js → chunk-KKBTBUVT.js} +2 -2
- package/dist/{chunk-ZHP4SUPB.js → chunk-LIHC3T43.js} +2 -2
- package/dist/{chunk-PX7VDW7R.js → chunk-MJPV4LBH.js} +79 -79
- package/dist/{chunk-PX7VDW7R.js.map → chunk-MJPV4LBH.js.map} +1 -1
- package/dist/{chunk-UGH6VPPO.js → chunk-MWTMTW2T.js} +2 -2
- package/dist/{chunk-F6LSNPPB.js → chunk-MXJ3VP6S.js} +6 -6
- package/dist/{chunk-KPWLZBXZ.js → chunk-NBNDVNJ2.js} +186 -25
- package/dist/chunk-NBNDVNJ2.js.map +1 -0
- package/dist/{chunk-M5EPMNS4.js → chunk-P2CLYN6H.js} +2 -2
- package/dist/{chunk-7XXCIO6X.js → chunk-PXQM2HOG.js} +13 -6
- package/dist/{chunk-7XXCIO6X.js.map → chunk-PXQM2HOG.js.map} +1 -1
- package/dist/{chunk-UZK7VVPD.js → chunk-Q5VJUNXA.js} +1858 -272
- package/dist/chunk-Q5VJUNXA.js.map +1 -0
- package/dist/{chunk-KGTD2KZJ.js → chunk-Q6YJW6KE.js} +2 -2
- package/dist/{chunk-X3SQYAPP.js → chunk-R372P2EH.js} +2 -2
- package/dist/{chunk-HBVOJNV4.js → chunk-RKXZ7P4V.js} +3 -3
- package/dist/chunk-RMMVQQA6.js +70 -0
- package/dist/chunk-RMMVQQA6.js.map +1 -0
- package/dist/{chunk-E6EAB4J6.js → chunk-S24E4MBQ.js} +14 -14
- package/dist/chunk-S24E4MBQ.js.map +1 -0
- package/dist/{chunk-RYHWZ52A.js → chunk-S2BBISHU.js} +4 -4
- package/dist/{chunk-LLISELSY.js → chunk-TBMRQZDY.js} +2 -2
- package/dist/{chunk-FMLKOL2Z.js → chunk-TC3VNKGX.js} +4 -4
- package/dist/{chunk-7K5Q6COX.js → chunk-TVVEYCNW.js} +4 -4
- package/dist/{chunk-2XP3PWL3.js → chunk-UR5SIN7H.js} +53 -35
- package/dist/chunk-UR5SIN7H.js.map +1 -0
- package/dist/{chunk-NKDVXEFU.js → chunk-VFNI3RPT.js} +2 -2
- package/dist/chunk-VNA7W7QF.js +134 -0
- package/dist/chunk-VNA7W7QF.js.map +1 -0
- package/dist/{chunk-PQPSZICJ.js → chunk-VYYNBJXH.js} +5 -5
- package/dist/chunk-WNIUPHWV.js +152 -0
- package/dist/chunk-WNIUPHWV.js.map +1 -0
- package/dist/chunk-WRJVB3C2.js +118 -0
- package/dist/chunk-WRJVB3C2.js.map +1 -0
- package/dist/{chunk-SN3ZESWQ.js → chunk-WVVK2RFM.js} +6 -6
- package/dist/{chunk-DQEMWVMT.js → chunk-X7Y7WX73.js} +1 -1
- package/dist/{chunk-4KKV7W62.js → chunk-YOBNCD3Z.js} +6 -6
- package/dist/chunk-YXZG5TVN.js +40 -0
- package/dist/chunk-YXZG5TVN.js.map +1 -0
- package/dist/{chunk-4DJQYKMN.js → chunk-ZIKENA2C.js} +2 -2
- package/dist/chunk-ZIKENA2C.js.map +1 -0
- package/dist/{chunk-SK3EYPKC.js → chunk-ZN4UZWMT.js} +2 -2
- package/dist/{cli-BoouX6LX.d.ts → cli-DJbkaZBU.d.ts} +5 -5
- package/dist/cli.d.ts +14 -12
- package/dist/cli.js +79 -74
- package/dist/compounding/engine.d.ts +3 -2
- package/dist/compounding/engine.js +12 -10
- package/dist/compounding/preference-consolidator.d.ts +1 -1
- package/dist/compression-optimizer.d.ts +1 -1
- package/dist/config.d.ts +1 -1
- package/dist/config.js +4 -2
- package/dist/connectors/codex-materialize-runner.d.ts +1 -1
- package/dist/connectors/codex-materialize-runner.js +12 -10
- package/dist/connectors/codex-materialize.d.ts +1 -1
- package/dist/connectors/index.d.ts +1 -1
- package/dist/connectors/index.js +12 -10
- package/dist/consolidation-provenance-check.d.ts +3 -2
- package/dist/consolidation-undo.d.ts +3 -2
- package/dist/contradiction/index.d.ts +3 -2
- package/dist/conversation-index/backend.d.ts +1 -1
- package/dist/conversation-index/backend.js +2 -2
- package/dist/conversation-index/chunker.d.ts +1 -1
- package/dist/conversation-index/faiss-adapter.d.ts +1 -1
- package/dist/conversation-index/indexer.d.ts +1 -1
- package/dist/conversation-index/search.d.ts +1 -1
- package/dist/corpus-watermark.d.ts +15 -2
- package/dist/corpus-watermark.js +25 -21
- package/dist/day-summary.d.ts +1 -1
- package/dist/delinearize.d.ts +1 -1
- package/dist/direct-answer-wiring.d.ts +1 -1
- package/dist/direct-answer.d.ts +1 -1
- package/dist/embedding-fallback.d.ts +1 -1
- package/dist/enrichment/index.d.ts +1 -1
- package/dist/entity-id-normalization.d.ts +4 -0
- package/dist/entity-id-normalization.js +10 -0
- package/dist/entity-id-normalization.js.map +1 -0
- package/dist/entity-retrieval-boundaries.d.ts +3 -0
- package/dist/entity-retrieval-boundaries.js +8 -0
- package/dist/entity-retrieval-boundaries.js.map +1 -0
- package/dist/entity-retrieval.d.ts +3 -2
- package/dist/entity-retrieval.js +13 -10
- package/dist/entity-schema.d.ts +1 -1
- package/dist/entity-schema.js +1 -1
- package/dist/explicit-capture.d.ts +9 -8
- package/dist/extraction-error-classification.d.ts +1 -1
- package/dist/extraction-faithfulness.d.ts +1 -1
- package/dist/extraction-judge-telemetry.d.ts +1 -1
- package/dist/extraction-judge-training.d.ts +1 -1
- package/dist/extraction-judge.d.ts +1 -1
- package/dist/extraction-liveness.d.ts +1 -1
- package/dist/extraction.d.ts +1 -1
- package/dist/fallback-llm.d.ts +1 -1
- package/dist/{first-start-migration-LEXL3XYL.js → first-start-migration-BKNGOFED.js} +4 -4
- package/dist/graph-dashboard-diff.d.ts +1 -1
- package/dist/graph-dashboard-key.d.ts +1 -1
- package/dist/graph-dashboard-parser.d.ts +1 -1
- package/dist/graph-edge-reinforcement.d.ts +1 -1
- package/dist/graph-snapshot.d.ts +1 -1
- package/dist/graph.d.ts +1 -1
- package/dist/importance.d.ts +1 -1
- package/dist/importers/index.d.ts +1 -1
- package/dist/in-flight-reads.d.ts +1 -1
- package/dist/index.d.ts +409 -407
- package/dist/index.js +147 -141
- package/dist/intent.d.ts +1 -1
- package/dist/lcm/engine.d.ts +1 -1
- package/dist/lcm/engine.js +3 -3
- package/dist/lcm/index.d.ts +1 -1
- package/dist/lcm/index.js +9 -9
- package/dist/lcm/tools.d.ts +1 -1
- package/dist/lifecycle.d.ts +1 -1
- package/dist/live-connectors-runner.d.ts +1 -1
- package/dist/local-llm.d.ts +1 -1
- package/dist/local-model-endpoint.d.ts +1 -1
- package/dist/maintenance/memory-governance.d.ts +1 -1
- package/dist/maintenance/memory-governance.js +12 -10
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.d.ts +3 -2
- package/dist/maintenance/rebuild-memory-lifecycle-ledger.js +12 -10
- package/dist/maintenance/rebuild-memory-projection.d.ts +3 -2
- package/dist/maintenance/rebuild-memory-projection.js +13 -11
- package/dist/{maintenance-DD22oIx9.d.ts → maintenance-DveW6azo.d.ts} +3 -3
- package/dist/mcp-memory-inspector-app.d.ts +13 -10
- package/dist/memory-action-policy.d.ts +1 -1
- package/dist/memory-cache.d.ts +1 -1
- package/dist/memory-lifecycle-ledger-utils.d.ts +1 -1
- package/dist/memory-projection-mutations.d.ts +4 -0
- package/dist/memory-projection-mutations.js +15 -0
- package/dist/memory-projection-mutations.js.map +1 -0
- package/dist/memory-projection-store.d.ts +1 -1
- package/dist/memory-provenance.d.ts +1 -1
- package/dist/memory-worth-outcomes.d.ts +3 -2
- package/dist/models-json.d.ts +1 -1
- package/dist/namespaces/migrate.d.ts +4 -3
- package/dist/namespaces/migrate.js +22 -20
- package/dist/namespaces/principal.d.ts +1 -1
- package/dist/namespaces/search.d.ts +1 -1
- package/dist/namespaces/search.js +9 -9
- package/dist/namespaces/storage.d.ts +4 -3
- package/dist/namespaces/storage.js +12 -10
- package/dist/native-knowledge.d.ts +1 -1
- package/dist/offline-sync-impression-drain.d.ts +1 -1
- package/dist/offline-sync-impression-drain.js +3 -3
- package/dist/operator-doctor-corpus.d.ts +22 -3
- package/dist/operator-doctor-corpus.js +32 -22
- package/dist/operator-doctor-replica.d.ts +52 -0
- package/dist/operator-doctor-replica.js +15 -0
- package/dist/operator-doctor-replica.js.map +1 -0
- package/dist/operator-toolkit.d.ts +7 -3
- package/dist/operator-toolkit.js +33 -27
- package/dist/orchestration/compression-guideline-coordinator.d.ts +3 -2
- package/dist/orchestration/maintenance.d.ts +5 -4
- package/dist/orchestration/maintenance.js +17 -15
- package/dist/{orchestrator-CFJ_TG6D.d.ts → orchestrator-Cvlgpe6I.d.ts} +7 -7
- package/dist/orchestrator.d.ts +9 -8
- package/dist/orchestrator.js +99 -93
- package/dist/patterns-cli.d.ts +1 -1
- package/dist/{pipeline-Bq5zU6Bp.d.ts → pipeline-BveYVaz7.d.ts} +1 -1
- package/dist/policy-runtime.d.ts +1 -1
- package/dist/proactive-contention.d.ts +1 -1
- package/dist/provenance.d.ts +1 -1
- package/dist/{qmd-3ILJkC70.d.ts → qmd-CLWDsbYR.d.ts} +1 -1
- package/dist/qmd-preflight.d.ts +2 -2
- package/dist/qmd-recall-cache.d.ts +1 -1
- package/dist/qmd.d.ts +2 -2
- package/dist/recall-concurrency-config.d.ts +1 -1
- package/dist/recall-disclosure-escalation.d.ts +1 -1
- package/dist/recall-explain-renderer.d.ts +1 -1
- package/dist/recall-explain-renderer.js +3 -3
- package/dist/recall-memory-map.d.ts +1 -1
- package/dist/recall-planner-llm.d.ts +1 -1
- package/dist/recall-state.d.ts +1 -1
- package/dist/recall-state.js +2 -2
- package/dist/recall-tag-filter.d.ts +1 -1
- package/dist/recall-timings.d.ts +1 -1
- package/dist/recall-xray-cli.d.ts +1 -1
- package/dist/recall-xray-cli.js +4 -4
- package/dist/recall-xray-renderer.d.ts +1 -1
- package/dist/recall-xray-renderer.js +3 -3
- package/dist/recall-xray.d.ts +1 -1
- package/dist/recall-xray.js +2 -2
- package/dist/replay/normalizers/chatgpt.d.ts +1 -1
- package/dist/replay/normalizers/claude.d.ts +1 -1
- package/dist/replay/normalizers/openclaw.d.ts +1 -1
- package/dist/replay/normalizers/shared.d.ts +1 -1
- package/dist/replay/runner.d.ts +1 -1
- package/dist/replay/types.d.ts +1 -1
- package/dist/replica-divergence.d.ts +279 -0
- package/dist/replica-divergence.js +28 -0
- package/dist/replica-divergence.js.map +1 -0
- package/dist/replica-peers-config.d.ts +5 -0
- package/dist/replica-peers-config.js +13 -0
- package/dist/replica-peers-config.js.map +1 -0
- package/dist/resolve-auth-token.d.ts +21 -2
- package/dist/resolve-auth-token.js +5 -1
- package/dist/resume-bundles.js +5 -3
- package/dist/retrieval-agents.d.ts +2 -2
- package/dist/retrieval-tiers.d.ts +1 -1
- package/dist/routing/engine.d.ts +1 -1
- package/dist/routing/store.d.ts +1 -1
- package/dist/salvage-envelope.d.ts +1 -1
- package/dist/schemas.d.ts +76 -76
- package/dist/{scope-profiles-D0-ff4xT.d.ts → scope-profiles-BVf3DZ7z.d.ts} +1 -1
- package/dist/search/embed-helper.d.ts +1 -1
- package/dist/search/factory.d.ts +1 -1
- package/dist/search/factory.js +8 -8
- package/dist/search/index.d.ts +1 -1
- package/dist/search/index.js +13 -13
- package/dist/search/lancedb-backend.d.ts +1 -1
- package/dist/search/lancedb-backend.js +2 -2
- package/dist/search/meilisearch-backend.d.ts +1 -1
- package/dist/search/meilisearch-backend.js +2 -2
- package/dist/search/noop-backend.d.ts +1 -1
- package/dist/search/orama-backend.d.ts +1 -1
- package/dist/search/orama-backend.js +2 -2
- package/dist/search/port.d.ts +1 -1
- package/dist/search/remote-backend.d.ts +1 -1
- package/dist/{semantic-consolidation-4Tfh7c1g.d.ts → semantic-consolidation-DXLV47y2.d.ts} +1 -1
- package/dist/semantic-consolidation.d.ts +2 -2
- package/dist/semantic-consolidation.js +13 -11
- package/dist/semantic-rule-promotion.js +12 -10
- package/dist/semantic-rule-verifier.d.ts +1 -1
- package/dist/semantic-rule-verifier.js +12 -10
- package/dist/{service-Z8uzoAST.d.ts → service-DbgPTeMJ.d.ts} +2 -2
- package/dist/session-observer-bands.d.ts +1 -1
- package/dist/session-observer-state.d.ts +1 -1
- package/dist/shared-context/manager.d.ts +9 -9
- package/dist/signal.d.ts +1 -1
- package/dist/{storage-BZVMn8yJ.d.ts → storage-DOzTm8wG.d.ts} +13 -22
- package/dist/storage.d.ts +3 -2
- package/dist/storage.js +13 -10
- package/dist/summarizer.d.ts +1 -1
- package/dist/summary-snapshot.d.ts +1 -1
- package/dist/temporal-supersession.d.ts +3 -2
- package/dist/temporal-timeline-recall.d.ts +1 -1
- package/dist/temporal-validity.d.ts +1 -1
- package/dist/threading.d.ts +1 -1
- package/dist/tier-migration.d.ts +3 -2
- package/dist/tier-routing.d.ts +1 -1
- package/dist/topics.d.ts +1 -1
- package/dist/transcript.d.ts +1 -1
- package/dist/transfer/autodetect.js +1 -1
- package/dist/transfer/backup.js +2 -2
- package/dist/transfer/capsule-export.js +3 -3
- package/dist/transfer/capsule-import.js +2 -2
- package/dist/transfer/types.d.ts +66 -66
- package/dist/trust-score-stage.d.ts +1 -1
- package/dist/trust-score.d.ts +1 -1
- package/dist/{types-DHBxIF0s.d.ts → types-BA7W-_xy.d.ts} +53 -4
- package/dist/types.d.ts +1 -1
- package/dist/types.js +1 -1
- package/dist/utility-runtime.d.ts +1 -1
- package/dist/verified-recall.js +12 -10
- package/dist/write-envelope.d.ts +1 -1
- package/package.json +2 -2
- package/src/access-health-types.ts +8 -0
- package/src/access-http.test.ts +4 -0
- package/src/access-service-health.test.ts +23 -0
- package/src/access-service.ts +27 -4
- package/src/cli.ts +9 -7
- package/src/config.ts +2 -1
- package/src/corpus-watermark.test.ts +21 -0
- package/src/corpus-watermark.ts +25 -3
- package/src/entity-id-normalization.ts +55 -0
- package/src/entity-retrieval-boundaries.ts +134 -0
- package/src/entity-retrieval.ts +253 -27
- package/src/entity-schema.ts +2 -1
- package/src/memory-projection-mutations.ts +132 -0
- package/src/namespaces/storage.ts +5 -3
- package/src/operator-doctor-corpus.ts +41 -0
- package/src/operator-doctor-replica.test.ts +261 -0
- package/src/operator-doctor-replica.ts +129 -0
- package/src/operator-toolkit.ts +5 -4
- package/src/orchestration/orchestrator-init.ts +2 -2
- package/src/replica-divergence.test.ts +1475 -0
- package/src/replica-divergence.ts +910 -0
- package/src/replica-peers-config.ts +250 -0
- package/src/resolve-auth-token.ts +38 -0
- package/src/storage/entity-canonical-id-migration-adapter.ts +72 -0
- package/src/storage/entity-canonical-id-migration-runner.ts +68 -0
- package/src/storage/entity-canonical-id-migration.ts +904 -0
- package/src/storage/entity-store.ts +44 -0
- package/src/storage/memory-frontmatter-metadata.ts +17 -0
- package/src/storage/memory-migration-serialization.ts +37 -0
- package/src/storage/memory-read-store.ts +3 -2
- package/src/storage/profile-header.ts +898 -0
- package/src/storage-profile-timestamp.test.ts +3358 -0
- package/src/storage.ts +73 -77
- package/src/testing/subjects/profile-header-write.test.ts +71 -0
- package/src/types.ts +3 -3
- package/dist/chunk-2XP3PWL3.js.map +0 -1
- package/dist/chunk-4DJQYKMN.js.map +0 -1
- package/dist/chunk-E6EAB4J6.js.map +0 -1
- package/dist/chunk-EXM2Q555.js.map +0 -1
- package/dist/chunk-GJJBPI6I.js.map +0 -1
- package/dist/chunk-KPWLZBXZ.js.map +0 -1
- package/dist/chunk-LIIQPDFN.js.map +0 -1
- package/dist/chunk-UZK7VVPD.js.map +0 -1
- package/dist/chunk-V25OWXAC.js.map +0 -1
- package/dist/chunk-YN4AUGNF.js.map +0 -1
- /package/dist/{capsule-crypto-YO5QJ6L3.js.map → capsule-crypto-7FJQINUR.js.map} +0 -0
- /package/dist/{chunk-7RZJWZ46.js.map → chunk-227UFSEQ.js.map} +0 -0
- /package/dist/{chunk-VGEH3STZ.js.map → chunk-2BEJ4XCF.js.map} +0 -0
- /package/dist/{chunk-BXLOS5AJ.js.map → chunk-2NLLXCJG.js.map} +0 -0
- /package/dist/{chunk-6NNNURLK.js.map → chunk-2PHX3I5C.js.map} +0 -0
- /package/dist/{chunk-XAAIUXJO.js.map → chunk-3BSJBIZ3.js.map} +0 -0
- /package/dist/{chunk-JCVQV6QX.js.map → chunk-3UOSVTFR.js.map} +0 -0
- /package/dist/{chunk-AJ7QCSIS.js.map → chunk-7CPOZH6Y.js.map} +0 -0
- /package/dist/{chunk-M2KGXXTL.js.map → chunk-ALTP3MFS.js.map} +0 -0
- /package/dist/{chunk-77CNV7DY.js.map → chunk-BLAKS3CP.js.map} +0 -0
- /package/dist/{chunk-FE5FKTCI.js.map → chunk-BR4UFMLO.js.map} +0 -0
- /package/dist/{chunk-H2PBM562.js.map → chunk-DKQM5Q6Q.js.map} +0 -0
- /package/dist/{chunk-CSXHRAJJ.js.map → chunk-EG64LXKL.js.map} +0 -0
- /package/dist/{chunk-PTN6UAUA.js.map → chunk-ES36HTSC.js.map} +0 -0
- /package/dist/{chunk-WMDRLTNA.js.map → chunk-EZWWCN7I.js.map} +0 -0
- /package/dist/{chunk-XKGPIWGI.js.map → chunk-FBZTWUK5.js.map} +0 -0
- /package/dist/{chunk-Y6E37WRG.js.map → chunk-GJPCP5CA.js.map} +0 -0
- /package/dist/{chunk-VDX2J7OX.js.map → chunk-IPLYGWQF.js.map} +0 -0
- /package/dist/{chunk-XNUBF6RF.js.map → chunk-IYT632D5.js.map} +0 -0
- /package/dist/{chunk-2QSZNTDO.js.map → chunk-JBPKEARU.js.map} +0 -0
- /package/dist/{chunk-67DSDRUB.js.map → chunk-JIF3VRKQ.js.map} +0 -0
- /package/dist/{chunk-MIFF37NN.js.map → chunk-KKBTBUVT.js.map} +0 -0
- /package/dist/{chunk-ZHP4SUPB.js.map → chunk-LIHC3T43.js.map} +0 -0
- /package/dist/{chunk-UGH6VPPO.js.map → chunk-MWTMTW2T.js.map} +0 -0
- /package/dist/{chunk-F6LSNPPB.js.map → chunk-MXJ3VP6S.js.map} +0 -0
- /package/dist/{chunk-M5EPMNS4.js.map → chunk-P2CLYN6H.js.map} +0 -0
- /package/dist/{chunk-KGTD2KZJ.js.map → chunk-Q6YJW6KE.js.map} +0 -0
- /package/dist/{chunk-X3SQYAPP.js.map → chunk-R372P2EH.js.map} +0 -0
- /package/dist/{chunk-HBVOJNV4.js.map → chunk-RKXZ7P4V.js.map} +0 -0
- /package/dist/{chunk-RYHWZ52A.js.map → chunk-S2BBISHU.js.map} +0 -0
- /package/dist/{chunk-LLISELSY.js.map → chunk-TBMRQZDY.js.map} +0 -0
- /package/dist/{chunk-FMLKOL2Z.js.map → chunk-TC3VNKGX.js.map} +0 -0
- /package/dist/{chunk-7K5Q6COX.js.map → chunk-TVVEYCNW.js.map} +0 -0
- /package/dist/{chunk-NKDVXEFU.js.map → chunk-VFNI3RPT.js.map} +0 -0
- /package/dist/{chunk-PQPSZICJ.js.map → chunk-VYYNBJXH.js.map} +0 -0
- /package/dist/{chunk-SN3ZESWQ.js.map → chunk-WVVK2RFM.js.map} +0 -0
- /package/dist/{chunk-DQEMWVMT.js.map → chunk-X7Y7WX73.js.map} +0 -0
- /package/dist/{chunk-4KKV7W62.js.map → chunk-YOBNCD3Z.js.map} +0 -0
- /package/dist/{chunk-SK3EYPKC.js.map → chunk-ZN4UZWMT.js.map} +0 -0
- /package/dist/{first-start-migration-LEXL3XYL.js.map → first-start-migration-BKNGOFED.js.map} +0 -0
|
@@ -0,0 +1,910 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replica divergence detection (issue #2149).
|
|
3
|
+
*
|
|
4
|
+
* The corpus-watermark primitive (corpus-watermark.ts, PR #2156) gives each
|
|
5
|
+
* daemon a cheap, comparable fingerprint of its own memory corpus. This module
|
|
6
|
+
* is the OTHER half the issue asks for: a daemon configured with peer URLs polls
|
|
7
|
+
* each peer's authenticated `/health`, compares the peer's watermark set against
|
|
8
|
+
* the local one PER NAMESPACE, and reports drift (file-count delta, watermark
|
|
9
|
+
* age delta, digest mismatch) so a months-long silent split-brain becomes a
|
|
10
|
+
* same-day alert. Detection ONLY — reconciliation is issue #2150.
|
|
11
|
+
*
|
|
12
|
+
* Design invariants (from AGENTS.md review-prevention patterns):
|
|
13
|
+
* - §22 error-result conflation: a peer that times out / refuses / returns
|
|
14
|
+
* non-2xx / omits `corpus` is a DISTINCT `unreachable`/`unknown` state, never
|
|
15
|
+
* folded into `converged`. A monitor must tell "peer agrees" from "we could
|
|
16
|
+
* not ask". The outcome is a discriminated union, not a boolean + empty array.
|
|
17
|
+
* - §5 state scoping: poll state lives on a per-instance {@link
|
|
18
|
+
* ReplicaDivergenceMonitor}, never a bare module global.
|
|
19
|
+
* - §1/§17/§24/§39 input validation: {@link parseReplicaPeersConfig} rejects
|
|
20
|
+
* invalid input (bad url, non-array peers, fractional/non-positive intervals)
|
|
21
|
+
* rather than silently defaulting, and coerces string booleans/numbers.
|
|
22
|
+
* - Peer tokens are secrets: resolved through the SAME `resolveAgentAccessAuthToken`
|
|
23
|
+
* indirection as `agentAccessHttp.authToken`, NEVER logged, NEVER echoed into a
|
|
24
|
+
* report/health/doctor payload. Peer identity is redacted to `host:port`
|
|
25
|
+
* (userinfo/path/query stripped) so a credential embedded in a URL cannot leak.
|
|
26
|
+
* - Polling never runs inline on the health request path: it reuses the corpus
|
|
27
|
+
* stale-while-revalidate / single-flight idiom, serving the last result with
|
|
28
|
+
* its timestamp and refreshing in the background.
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
import {
|
|
32
|
+
capabilityAllowsNamespace,
|
|
33
|
+
isCapabilityRestricted,
|
|
34
|
+
isValidNamespaceValue,
|
|
35
|
+
type TokenCapabilities,
|
|
36
|
+
} from "./access-token-capabilities.js";
|
|
37
|
+
import type { CorpusWatermark } from "./corpus-watermark.js";
|
|
38
|
+
import { resolveReplicaPeersConfig } from "./replica-peers-config.js";
|
|
39
|
+
import type { ReplicaPeerConfig, ReplicaPeersConfig } from "./replica-peers-config.js";
|
|
40
|
+
import { resolveAgentAccessAuthToken, type ResolveSecretRefFn } from "./resolve-auth-token.js";
|
|
41
|
+
|
|
42
|
+
/** Cap concurrent peer fetches so a large fleet cannot fan out unbounded network work at once. */
|
|
43
|
+
const DEFAULT_MAX_CONCURRENT_PEER_FETCHES = 4;
|
|
44
|
+
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
// Report shape
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
|
|
49
|
+
/** Per-peer verdict. `unreachable`/`unknown` are NEVER conflated with `converged` (§22). */
|
|
50
|
+
export type ReplicaPeerState = "converged" | "diverged" | "unreachable" | "unknown";
|
|
51
|
+
|
|
52
|
+
/** Whether a namespace is present on both sides, or only one (its own divergence outcome). */
|
|
53
|
+
export type ReplicaNamespacePresence = "both" | "local_only" | "peer_only";
|
|
54
|
+
|
|
55
|
+
export interface ReplicaNamespaceDelta {
|
|
56
|
+
namespace: string;
|
|
57
|
+
presence: ReplicaNamespacePresence;
|
|
58
|
+
localFileCount: number | null;
|
|
59
|
+
peerFileCount: number | null;
|
|
60
|
+
/** |local - peer| when present on both sides, else null. */
|
|
61
|
+
fileCountDelta: number | null;
|
|
62
|
+
localNewestWriteAt: string | null;
|
|
63
|
+
peerNewestWriteAt: string | null;
|
|
64
|
+
/** |local.newestWriteAt - peer.newestWriteAt| in ms when both are dated, else null. */
|
|
65
|
+
writeAgeDeltaMs: number | null;
|
|
66
|
+
/** local.digest === peer.digest when present on both sides, else null. */
|
|
67
|
+
digestMatch: boolean | null;
|
|
68
|
+
diverged: boolean;
|
|
69
|
+
/** Concrete, token-free reasons a namespace diverged (numbers included for the operator). */
|
|
70
|
+
reasons: string[];
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface ReplicaPeerReport {
|
|
74
|
+
/** Redacted `host:port` — never the token, never userinfo/path/query. */
|
|
75
|
+
peer: string;
|
|
76
|
+
state: ReplicaPeerState;
|
|
77
|
+
polledAt: string;
|
|
78
|
+
/**
|
|
79
|
+
* Per-namespace deltas. Empty only for a fetch-level `unreachable`/`unknown`
|
|
80
|
+
* (no comparison ran); a comparison that resolves to `unknown` (an ambiguous
|
|
81
|
+
* local-only namespace) still carries its deltas.
|
|
82
|
+
*/
|
|
83
|
+
namespaces: ReplicaNamespaceDelta[];
|
|
84
|
+
divergedNamespaceCount: number;
|
|
85
|
+
/** Stable reason code for a non-comparison state (e.g. "timeout", "http_500", "missing_corpus"). */
|
|
86
|
+
reason?: string;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Local watermark set plus whether every configured namespace was scanned. */
|
|
90
|
+
export interface LocalCensus {
|
|
91
|
+
watermarks: CorpusWatermark[];
|
|
92
|
+
complete: boolean;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface ReplicaDivergenceStatus {
|
|
96
|
+
enabled: boolean;
|
|
97
|
+
/**
|
|
98
|
+
* False when the local corpus census dropped namespaces (a per-namespace scan
|
|
99
|
+
* failed, or enumeration was still warming). A peer can only be certified
|
|
100
|
+
* `converged` against a COMPLETE local set — otherwise an unscanned tenant
|
|
101
|
+
* never enters the comparison and its divergence is invisible. Mirrors the
|
|
102
|
+
* doctor check's `localCensusComplete` gate (round 4, cursor).
|
|
103
|
+
*/
|
|
104
|
+
censusComplete?: boolean;
|
|
105
|
+
/**
|
|
106
|
+
* True when the feature is enabled with peers configured but no poll has
|
|
107
|
+
* completed yet (warming, or a persistently failing local-watermark scan).
|
|
108
|
+
* Distinguishes that in-progress/failed state from the "enabled but no peers
|
|
109
|
+
* configured" case, which reports `pending: false` with an empty `peers` list
|
|
110
|
+
* (review round 1).
|
|
111
|
+
*/
|
|
112
|
+
pending: boolean;
|
|
113
|
+
/** ISO timestamp of the last completed poll cycle, or null when never polled / disabled. */
|
|
114
|
+
polledAt: string | null;
|
|
115
|
+
peers: ReplicaPeerReport[];
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Discriminated fetch outcome — the heart of the §22 empty-vs-failed distinction. */
|
|
119
|
+
export type PeerFetchOutcome =
|
|
120
|
+
| { readonly kind: "ok"; readonly corpus: CorpusWatermark[] }
|
|
121
|
+
| { readonly kind: "unreachable"; readonly reason: string }
|
|
122
|
+
| { readonly kind: "unknown"; readonly reason: string };
|
|
123
|
+
|
|
124
|
+
// ---------------------------------------------------------------------------
|
|
125
|
+
// Comparison (pure)
|
|
126
|
+
// ---------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
function writeAgeDeltaMs(local: string | null, peer: string | null): number | null {
|
|
129
|
+
if (local === null || peer === null) return null;
|
|
130
|
+
const a = Date.parse(local);
|
|
131
|
+
const b = Date.parse(peer);
|
|
132
|
+
if (!Number.isFinite(a) || !Number.isFinite(b)) return null;
|
|
133
|
+
return Math.abs(a - b);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* THE replica certification ladder (issue #2149, review round 6). `converged` is
|
|
138
|
+
* an affirmative "these replicas agree" claim, so it is returned ONLY from
|
|
139
|
+
* evidence that can support it (AGENTS.md §22); every other outcome defaults to
|
|
140
|
+
* the safe non-converged side. The monitor, the doctor, and the capability
|
|
141
|
+
* filter all decide state HERE so they cannot drift. Precedence:
|
|
142
|
+
* 1. any diverged namespace -> `diverged` (a positive finding wins);
|
|
143
|
+
* 2. any local-only namespace -> `unknown`/`namespace_scope_unverifiable`
|
|
144
|
+
* (a scoped peer token hides it, and a genuine peer loss omits it the same
|
|
145
|
+
* way — unprovable in either direction);
|
|
146
|
+
* 3. NO namespace present on both -> `unknown`/`no_shared_namespaces` (an
|
|
147
|
+
* empty local corpus, a peer token that scoped out every namespace, or a
|
|
148
|
+
* capability filter that removed every visible delta all leave zero shared
|
|
149
|
+
* evidence, which cannot certify agreement);
|
|
150
|
+
* 4. otherwise -> `converged`.
|
|
151
|
+
* A genuinely empty single-namespace deployment still shares that namespace on
|
|
152
|
+
* both sides (0 files == 0 files) and converges; only a comparison with NO
|
|
153
|
+
* overlapping namespace at all is `no_shared_namespaces`.
|
|
154
|
+
*/
|
|
155
|
+
function verdictFromDeltas(namespaces: readonly ReplicaNamespaceDelta[]): {
|
|
156
|
+
state: "converged" | "diverged" | "unknown";
|
|
157
|
+
reason?: string;
|
|
158
|
+
} {
|
|
159
|
+
if (namespaces.some((delta) => delta.diverged)) return { state: "diverged" };
|
|
160
|
+
if (namespaces.some((delta) => delta.presence === "local_only")) {
|
|
161
|
+
return { state: "unknown", reason: "namespace_scope_unverifiable" };
|
|
162
|
+
}
|
|
163
|
+
if (!namespaces.some((delta) => delta.presence === "both")) {
|
|
164
|
+
return { state: "unknown", reason: "no_shared_namespaces" };
|
|
165
|
+
}
|
|
166
|
+
return { state: "converged" };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Compare a local watermark set against a peer's, per namespace. A namespace on
|
|
171
|
+
* only one side is its own outcome (never silently skipped):
|
|
172
|
+
* - `peer_only` is divergence — the peer holds data we lack.
|
|
173
|
+
* - `local_only` is AMBIGUOUS — a namespace-restricted peer token hides
|
|
174
|
+
* namespaces it cannot see (not divergence), but an unrestricted peer that
|
|
175
|
+
* genuinely lost the namespace omits it the SAME way, so it cannot be
|
|
176
|
+
* certified converged either. It resolves the peer to `unknown` (codex P1).
|
|
177
|
+
* Digest mismatch flags divergence ONLY at EQUAL total file counts — the same
|
|
178
|
+
* number of files distributed differently across `<tier>:<category>/<day>`
|
|
179
|
+
* buckets (a distribution split-brain). The digest hashes per-bucket COUNTS, so
|
|
180
|
+
* it does NOT catch two replicas whose buckets hold equal counts but different
|
|
181
|
+
* file contents. Any nonzero count delta already perturbs the digest, so
|
|
182
|
+
* flagging digest there too would make `maxFileCountDelta` unreachable (codex
|
|
183
|
+
* P2); within tolerance the count delta is the sole signal.
|
|
184
|
+
*/
|
|
185
|
+
export function compareReplicaWatermarks(
|
|
186
|
+
local: readonly CorpusWatermark[],
|
|
187
|
+
peer: readonly CorpusWatermark[],
|
|
188
|
+
thresholds: Pick<ReplicaPeersConfig, "maxFileCountDelta" | "maxWatermarkAgeDeltaMs">,
|
|
189
|
+
): { state: "converged" | "diverged" | "unknown"; reason?: string; namespaces: ReplicaNamespaceDelta[]; divergedNamespaceCount: number } {
|
|
190
|
+
const byLocal = new Map(local.map((watermark) => [watermark.namespace, watermark]));
|
|
191
|
+
const byPeer = new Map(peer.map((watermark) => [watermark.namespace, watermark]));
|
|
192
|
+
const allNamespaces = [...new Set([...byLocal.keys(), ...byPeer.keys()])].sort((a, b) =>
|
|
193
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
const namespaces = allNamespaces.map((namespace): ReplicaNamespaceDelta => {
|
|
197
|
+
const localWatermark = byLocal.get(namespace);
|
|
198
|
+
const peerWatermark = byPeer.get(namespace);
|
|
199
|
+
|
|
200
|
+
// A namespace present locally but absent from the peer's RESPONSE is
|
|
201
|
+
// advisory, not divergence: a namespace-restricted peer token intentionally
|
|
202
|
+
// hides namespaces it cannot see, so treating local_only as diverged would
|
|
203
|
+
// report permanent false divergence against a scoped token (review round 1).
|
|
204
|
+
// It is still reported so an operator with an unrestricted peer token can
|
|
205
|
+
// act on it; peer_only below stays divergence (the peer holds data we lack).
|
|
206
|
+
if (localWatermark && !peerWatermark) {
|
|
207
|
+
return {
|
|
208
|
+
namespace,
|
|
209
|
+
presence: "local_only",
|
|
210
|
+
localFileCount: localWatermark.memoryFileCount,
|
|
211
|
+
peerFileCount: null,
|
|
212
|
+
fileCountDelta: null,
|
|
213
|
+
localNewestWriteAt: localWatermark.newestWriteAt,
|
|
214
|
+
peerNewestWriteAt: null,
|
|
215
|
+
writeAgeDeltaMs: null,
|
|
216
|
+
digestMatch: null,
|
|
217
|
+
diverged: false,
|
|
218
|
+
reasons: ["namespace_absent_from_peer_response"],
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
if (!localWatermark && peerWatermark) {
|
|
222
|
+
return {
|
|
223
|
+
namespace,
|
|
224
|
+
presence: "peer_only",
|
|
225
|
+
localFileCount: null,
|
|
226
|
+
peerFileCount: peerWatermark.memoryFileCount,
|
|
227
|
+
fileCountDelta: null,
|
|
228
|
+
localNewestWriteAt: null,
|
|
229
|
+
peerNewestWriteAt: peerWatermark.newestWriteAt,
|
|
230
|
+
writeAgeDeltaMs: null,
|
|
231
|
+
digestMatch: null,
|
|
232
|
+
diverged: true,
|
|
233
|
+
reasons: ["namespace_absent_locally"],
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Present on both sides.
|
|
238
|
+
const l = localWatermark as CorpusWatermark;
|
|
239
|
+
const p = peerWatermark as CorpusWatermark;
|
|
240
|
+
const fileCountDelta = Math.abs(l.memoryFileCount - p.memoryFileCount);
|
|
241
|
+
const digestMatch = l.digest === p.digest;
|
|
242
|
+
const ageDelta = writeAgeDeltaMs(l.newestWriteAt, p.newestWriteAt);
|
|
243
|
+
const reasons: string[] = [];
|
|
244
|
+
if (fileCountDelta > thresholds.maxFileCountDelta) {
|
|
245
|
+
reasons.push(`file_count_delta=${fileCountDelta}`);
|
|
246
|
+
} else if (fileCountDelta === 0 && !digestMatch) {
|
|
247
|
+
// Equal counts, different digest = equal size / different content: the
|
|
248
|
+
// split-brain signal. A nonzero delta within tolerance is NOT flagged on
|
|
249
|
+
// digest, since it necessarily perturbs the digest anyway (codex P2).
|
|
250
|
+
reasons.push("digest_mismatch");
|
|
251
|
+
}
|
|
252
|
+
if (ageDelta !== null && ageDelta > thresholds.maxWatermarkAgeDeltaMs) {
|
|
253
|
+
reasons.push(`write_age_delta_ms=${ageDelta}`);
|
|
254
|
+
}
|
|
255
|
+
// One side has a dated newest write while the other reports null: with matching
|
|
256
|
+
// counts+digests that is inconsistent (a shared bucket census implies the same
|
|
257
|
+
// hot day-partitions), so it is asymmetric/corrupt telemetry, not agreement — a
|
|
258
|
+
// missing measurement cannot prove convergence (round 6, codex).
|
|
259
|
+
if ((l.newestWriteAt === null) !== (p.newestWriteAt === null)) {
|
|
260
|
+
reasons.push("newest_write_presence_mismatch");
|
|
261
|
+
}
|
|
262
|
+
return {
|
|
263
|
+
namespace,
|
|
264
|
+
presence: "both",
|
|
265
|
+
localFileCount: l.memoryFileCount,
|
|
266
|
+
peerFileCount: p.memoryFileCount,
|
|
267
|
+
fileCountDelta,
|
|
268
|
+
localNewestWriteAt: l.newestWriteAt,
|
|
269
|
+
peerNewestWriteAt: p.newestWriteAt,
|
|
270
|
+
writeAgeDeltaMs: ageDelta,
|
|
271
|
+
digestMatch,
|
|
272
|
+
diverged: reasons.length > 0,
|
|
273
|
+
reasons,
|
|
274
|
+
};
|
|
275
|
+
});
|
|
276
|
+
|
|
277
|
+
const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
|
|
278
|
+
const verdict = verdictFromDeltas(namespaces);
|
|
279
|
+
return { state: verdict.state, reason: verdict.reason, namespaces, divergedNamespaceCount };
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Peer fetch
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
/**
|
|
287
|
+
* Dual-prefix health probe order: try the path THIS server actually registers
|
|
288
|
+
* first (access-http.ts serves `/engram/v1/health` only), then the `/remnic/v1`
|
|
289
|
+
* prefix as forward-compat fallback. Probing an unregistered path first would
|
|
290
|
+
* make every same-version peer pay a 404 (round 6, codex P2).
|
|
291
|
+
*/
|
|
292
|
+
const HEALTH_PATHS = ["/engram/v1/health", "/remnic/v1/health"] as const;
|
|
293
|
+
|
|
294
|
+
export type FetchLike = (input: string, init?: { headers?: Record<string, string>; signal?: AbortSignal }) => Promise<{
|
|
295
|
+
ok: boolean;
|
|
296
|
+
status: number;
|
|
297
|
+
json(): Promise<unknown>;
|
|
298
|
+
/**
|
|
299
|
+
* Raw body stream when the transport exposes one (the global `fetch`
|
|
300
|
+
* Response does). Present so a peer payload can be size-bounded while it is
|
|
301
|
+
* read; a test double that omits it falls back to `json()`.
|
|
302
|
+
*/
|
|
303
|
+
body?: unknown;
|
|
304
|
+
}>;
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Cap on a peer's `/health` payload. A configured peer is a trust boundary: a
|
|
308
|
+
* compromised or malfunctioning one could otherwise stream an unbounded corpus
|
|
309
|
+
* that `json()` buffers whole, and up to `maxConcurrent` peers are polled at
|
|
310
|
+
* once, so the exposure multiplies (round 7, codex P2). Generous enough for a
|
|
311
|
+
* fleet-sized namespace census, small enough that four of them cannot exhaust
|
|
312
|
+
* the daemon.
|
|
313
|
+
*/
|
|
314
|
+
export const MAX_PEER_RESPONSE_BYTES = 8 * 1024 * 1024;
|
|
315
|
+
|
|
316
|
+
class PeerResponseTooLarge extends Error {}
|
|
317
|
+
|
|
318
|
+
/** Read + parse a peer body, refusing to buffer more than the cap. */
|
|
319
|
+
async function readBoundedJson(response: { json(): Promise<unknown>; body?: unknown }): Promise<unknown> {
|
|
320
|
+
const stream = response.body as { getReader?: () => ReadableStreamDefaultReader<Uint8Array> } | null | undefined;
|
|
321
|
+
if (!stream || typeof stream.getReader !== "function") {
|
|
322
|
+
// No stream to meter (test double, or a transport without one).
|
|
323
|
+
return response.json();
|
|
324
|
+
}
|
|
325
|
+
const reader = stream.getReader();
|
|
326
|
+
const chunks: Uint8Array[] = [];
|
|
327
|
+
let total = 0;
|
|
328
|
+
try {
|
|
329
|
+
for (;;) {
|
|
330
|
+
const { done, value } = await reader.read();
|
|
331
|
+
if (done) break;
|
|
332
|
+
if (!value) continue;
|
|
333
|
+
total += value.byteLength;
|
|
334
|
+
if (total > MAX_PEER_RESPONSE_BYTES) throw new PeerResponseTooLarge();
|
|
335
|
+
chunks.push(value);
|
|
336
|
+
}
|
|
337
|
+
} finally {
|
|
338
|
+
await reader.cancel().catch(() => undefined);
|
|
339
|
+
}
|
|
340
|
+
const merged = new Uint8Array(total);
|
|
341
|
+
let offset = 0;
|
|
342
|
+
for (const chunk of chunks) {
|
|
343
|
+
merged.set(chunk, offset);
|
|
344
|
+
offset += chunk.byteLength;
|
|
345
|
+
}
|
|
346
|
+
return JSON.parse(new TextDecoder().decode(merged));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export interface FetchPeerOptions {
|
|
350
|
+
timeoutMs: number;
|
|
351
|
+
resolveSecretRef?: ResolveSecretRefFn | null;
|
|
352
|
+
/** Injectable for tests; defaults to global fetch. */
|
|
353
|
+
fetchImpl?: FetchLike;
|
|
354
|
+
/**
|
|
355
|
+
* Max age (ms) a peer census `computedAt` may reach before the peer is treated
|
|
356
|
+
* as `unknown`/`peer_census_stale` — a snapshot older than this predates
|
|
357
|
+
* changes it may not reflect, so it cannot certify convergence (round 6, codex
|
|
358
|
+
* P1). Reuses the caller's `maxWatermarkAgeDeltaMs`; when unset OR non-positive,
|
|
359
|
+
* no staleness gate is applied (a 0 bound is the strictest DIVERGENCE mode, not
|
|
360
|
+
* a 0ms freshness gate — round 6, cursor).
|
|
361
|
+
*/
|
|
362
|
+
maxCensusAgeMs?: number;
|
|
363
|
+
/** Wall clock (ms) the staleness gate measures `computedAt` against; defaults to now. */
|
|
364
|
+
nowMs?: number;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** Redact a peer URL to `host:port` — strips userinfo/path/query so a credential in a URL cannot leak. */
|
|
368
|
+
export function redactPeerUrl(url: string): string {
|
|
369
|
+
try {
|
|
370
|
+
return new URL(url).host;
|
|
371
|
+
} catch {
|
|
372
|
+
return "peer";
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/** Reduce a network error to a stable, host-free reason code (never leaks a URL or token). */
|
|
377
|
+
function networkReason(error: unknown): string {
|
|
378
|
+
if (error && typeof error === "object") {
|
|
379
|
+
if ("name" in error && error.name === "TimeoutError") return "timeout";
|
|
380
|
+
if ("name" in error && error.name === "AbortError") return "aborted";
|
|
381
|
+
if (
|
|
382
|
+
"cause" in error &&
|
|
383
|
+
error.cause &&
|
|
384
|
+
typeof error.cause === "object" &&
|
|
385
|
+
"code" in error.cause &&
|
|
386
|
+
typeof error.cause.code === "string"
|
|
387
|
+
) {
|
|
388
|
+
return `network_${error.cause.code}`;
|
|
389
|
+
}
|
|
390
|
+
if ("code" in error && typeof error.code === "string") return `network_${error.code}`;
|
|
391
|
+
}
|
|
392
|
+
return "unreachable";
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function coerceCorpusWatermark(raw: unknown): CorpusWatermark | null {
|
|
396
|
+
if (!raw || typeof raw !== "object") return null;
|
|
397
|
+
const record = raw as Record<string, unknown>;
|
|
398
|
+
// A peer's namespace key crosses a trust boundary: a noncanonical value
|
|
399
|
+
// ("default ", a path separator, a control character) would compare as a
|
|
400
|
+
// DISTINCT namespace and produce phantom local_only/peer_only deltas for the
|
|
401
|
+
// same logical tenant, and doctor interpolates it into terminal output. Use
|
|
402
|
+
// the same validator the rest of the namespace boundary uses (round 7).
|
|
403
|
+
if (!isValidNamespaceValue(record.namespace)) return null;
|
|
404
|
+
// A file count must be a nonnegative integer: a negative/fractional count is
|
|
405
|
+
// corrupted telemetry, not a valid corpus (codex P2). Rejecting it here routes
|
|
406
|
+
// the whole peer response to `unknown` via the malformed_corpus guard.
|
|
407
|
+
if (
|
|
408
|
+
typeof record.memoryFileCount !== "number" ||
|
|
409
|
+
!Number.isInteger(record.memoryFileCount) ||
|
|
410
|
+
record.memoryFileCount < 0
|
|
411
|
+
) {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
if (typeof record.digest !== "string" || record.digest.length === 0) return null;
|
|
415
|
+
// `newestWriteAt` is either absent/null or a parseable timestamp; an
|
|
416
|
+
// unparseable string is corrupted telemetry, not "undated" (codex P2).
|
|
417
|
+
let newestWriteAt: string | null = null;
|
|
418
|
+
if (record.newestWriteAt !== undefined && record.newestWriteAt !== null) {
|
|
419
|
+
if (typeof record.newestWriteAt !== "string" || !Number.isFinite(Date.parse(record.newestWriteAt))) return null;
|
|
420
|
+
newestWriteAt = record.newestWriteAt;
|
|
421
|
+
}
|
|
422
|
+
// `computedAt` must be a present, parseable timestamp: an empty/unparseable
|
|
423
|
+
// value is corrupted telemetry, and the staleness gate (fetchPeerWatermarks)
|
|
424
|
+
// needs a real instant. A missing/bad computedAt routes the whole peer to
|
|
425
|
+
// `malformed_corpus`, never a certified convergence (round 6, codex P1).
|
|
426
|
+
if (typeof record.computedAt !== "string" || !Number.isFinite(Date.parse(record.computedAt))) {
|
|
427
|
+
return null;
|
|
428
|
+
}
|
|
429
|
+
return {
|
|
430
|
+
namespace: record.namespace,
|
|
431
|
+
memoryFileCount: record.memoryFileCount,
|
|
432
|
+
newestPartition: typeof record.newestPartition === "string" ? record.newestPartition : null,
|
|
433
|
+
newestWriteAt,
|
|
434
|
+
digest: record.digest,
|
|
435
|
+
computedAt: record.computedAt,
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Sentinel rejection for {@link withDeadline} timeouts (distinct from a resolver error). */
|
|
440
|
+
const DEADLINE_TIMEOUT = Symbol("deadline_timeout");
|
|
441
|
+
/**
|
|
442
|
+
* Bound a promise by an absolute deadline: reject with {@link DEADLINE_TIMEOUT} if
|
|
443
|
+
* it has not settled by `deadlineMs`. Keeps a stalling peer-token resolver from
|
|
444
|
+
* hanging the whole per-peer fetch (round 6, codex). The wrapped promise is
|
|
445
|
+
* abandoned on timeout; the race keeps its later settlement handled, so it never
|
|
446
|
+
* surfaces as an unhandled rejection.
|
|
447
|
+
*/
|
|
448
|
+
async function withDeadline<T>(promise: Promise<T>, deadlineMs: number): Promise<T> {
|
|
449
|
+
const { promise: expiry, reject } = Promise.withResolvers<never>();
|
|
450
|
+
// The deadline timer is NOT unref'd: it is the mechanism enforcing the bound,
|
|
451
|
+
// so it must fire even when the wrapped promise (e.g. a stalled token resolver)
|
|
452
|
+
// holds nothing else on the event loop. It is always cleared below on settle.
|
|
453
|
+
const timer = setTimeout(() => reject(DEADLINE_TIMEOUT), Math.max(1, deadlineMs - Date.now()));
|
|
454
|
+
try {
|
|
455
|
+
return await Promise.race([promise, expiry]);
|
|
456
|
+
} finally {
|
|
457
|
+
clearTimeout(timer);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/**
|
|
462
|
+
* Fetch a peer's authenticated `/health` corpus with a bounded timeout, trying
|
|
463
|
+
* the remnic prefix then the legacy engram prefix. Never throws — every failure
|
|
464
|
+
* mode maps to a discriminated `unreachable`/`unknown` outcome (§22).
|
|
465
|
+
*/
|
|
466
|
+
export async function fetchPeerWatermarks(
|
|
467
|
+
peer: ReplicaPeerConfig,
|
|
468
|
+
options: FetchPeerOptions,
|
|
469
|
+
): Promise<PeerFetchOutcome> {
|
|
470
|
+
const doFetch = options.fetchImpl ?? (globalThis.fetch as unknown as FetchLike);
|
|
471
|
+
// ONE deadline for the whole peer, not one per prefix: a preferred path that
|
|
472
|
+
// 404s just under the limit would otherwise hand the legacy fallback a fresh
|
|
473
|
+
// full budget and double the documented per-peer bound (round 5, codex P2).
|
|
474
|
+
// Token resolution runs UNDER this deadline too: a SecretRef whose host
|
|
475
|
+
// resolver stalls would otherwise hang this call forever, wedging the monitor's
|
|
476
|
+
// single-flight refresh and blocking doctor's whole peer batch (round 6, codex).
|
|
477
|
+
const deadline = Date.now() + options.timeoutMs;
|
|
478
|
+
let token: string | undefined;
|
|
479
|
+
try {
|
|
480
|
+
token = await withDeadline(
|
|
481
|
+
resolveAgentAccessAuthToken(peer.token, { resolveSecretRef: options.resolveSecretRef }),
|
|
482
|
+
deadline,
|
|
483
|
+
);
|
|
484
|
+
} catch (error) {
|
|
485
|
+
// No resolver, a resolution failure, or a resolver that stalls past the
|
|
486
|
+
// deadline all degrade to a per-peer failure — never a throw or a hang
|
|
487
|
+
// (review round 1; round 6 adds the stall/timeout case).
|
|
488
|
+
return { kind: "unreachable", reason: error === DEADLINE_TIMEOUT ? "timeout" : "token_error" };
|
|
489
|
+
}
|
|
490
|
+
const base = peer.url.replace(/\/+$/, "");
|
|
491
|
+
|
|
492
|
+
for (let i = 0; i < HEALTH_PATHS.length; i += 1) {
|
|
493
|
+
const path = HEALTH_PATHS[i];
|
|
494
|
+
const isLastPath = i === HEALTH_PATHS.length - 1;
|
|
495
|
+
let response: { ok: boolean; status: number; json(): Promise<unknown> };
|
|
496
|
+
try {
|
|
497
|
+
response = await doFetch(`${base}${path}`, {
|
|
498
|
+
headers: token ? { authorization: `Bearer ${token}` } : undefined,
|
|
499
|
+
signal: AbortSignal.timeout(Math.max(1, deadline - Date.now())),
|
|
500
|
+
});
|
|
501
|
+
} catch (error) {
|
|
502
|
+
// A network error means the host is unreachable regardless of path — no
|
|
503
|
+
// point trying the other prefix against the same host.
|
|
504
|
+
return { kind: "unreachable", reason: networkReason(error) };
|
|
505
|
+
}
|
|
506
|
+
// Only a 404 warrants trying the legacy prefix; any other non-2xx is a real failure.
|
|
507
|
+
if (response.status === 404 && !isLastPath) continue;
|
|
508
|
+
if (!response.ok) return { kind: "unreachable", reason: `http_${response.status}` };
|
|
509
|
+
let body: unknown;
|
|
510
|
+
try {
|
|
511
|
+
body = await readBoundedJson(response);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
if (error instanceof PeerResponseTooLarge) {
|
|
514
|
+
return { kind: "unknown", reason: "response_too_large" };
|
|
515
|
+
}
|
|
516
|
+
// A peer that sent headers then stalled mid-body aborts the reader. That
|
|
517
|
+
// is a timeout, not malformed JSON — monitoring must be able to tell a
|
|
518
|
+
// body-phase stall from corrupt telemetry (round 9, codex P2).
|
|
519
|
+
const name = (error as { name?: unknown } | null)?.name;
|
|
520
|
+
if (name === "TimeoutError" || name === "AbortError") {
|
|
521
|
+
return { kind: "unreachable", reason: networkReason(error) };
|
|
522
|
+
}
|
|
523
|
+
return { kind: "unknown", reason: "invalid_json" };
|
|
524
|
+
}
|
|
525
|
+
if (!body || typeof body !== "object" || !("corpus" in body) || !Array.isArray(body.corpus)) {
|
|
526
|
+
return { kind: "unknown", reason: "missing_corpus" };
|
|
527
|
+
}
|
|
528
|
+
// If any entry is malformed the peer's telemetry is unusable: report
|
|
529
|
+
// `unknown` rather than silently shrinking to a smaller (or empty) corpus
|
|
530
|
+
// that could read as converged or false divergence (review round 1). An
|
|
531
|
+
// empty array is a valid empty corpus and stays `ok`.
|
|
532
|
+
const corpus = body.corpus
|
|
533
|
+
.map(coerceCorpusWatermark)
|
|
534
|
+
.filter((watermark): watermark is CorpusWatermark => watermark !== null);
|
|
535
|
+
if (corpus.length !== body.corpus.length) {
|
|
536
|
+
return { kind: "unknown", reason: "malformed_corpus" };
|
|
537
|
+
}
|
|
538
|
+
// Duplicate namespace keys are malformed too: the comparison builds a Map,
|
|
539
|
+
// so a later entry silently wins and a mismatching watermark followed by a
|
|
540
|
+
// matching one would certify `converged` (round 3, codex P2).
|
|
541
|
+
if (new Set(corpus.map((watermark) => watermark.namespace)).size !== corpus.length) {
|
|
542
|
+
return { kind: "unknown", reason: "malformed_corpus" };
|
|
543
|
+
}
|
|
544
|
+
// A peer that ADVERTISES an incomplete census omitted corpus entries whose
|
|
545
|
+
// scan failed or whose cache is warming. Its partial array must not be read
|
|
546
|
+
// as a complete one: a namespace that exists only on the peer but was
|
|
547
|
+
// omitted would leave the comparison seeing agreement (round 7, codex P1).
|
|
548
|
+
// A peer that does not advertise the field at all predates it — documented
|
|
549
|
+
// as a mixed-version limitation rather than pinning every older peer to
|
|
550
|
+
// `unknown` forever.
|
|
551
|
+
// Prefer `corpusComplete`, which describes THIS response's corpus array;
|
|
552
|
+
// `replica.censusComplete` came from the peer's independently-cached
|
|
553
|
+
// monitor scan and could disagree with the array it shipped (round 8).
|
|
554
|
+
const peerBody = body as { corpusComplete?: unknown; replica?: { censusComplete?: unknown } };
|
|
555
|
+
// Absent is compatibility (an older peer); PRESENT but non-boolean is
|
|
556
|
+
// corrupt telemetry and must not fall through to a weaker signal (round 9).
|
|
557
|
+
if (peerBody.corpusComplete !== undefined && typeof peerBody.corpusComplete !== "boolean") {
|
|
558
|
+
return { kind: "unknown", reason: "malformed_corpus" };
|
|
559
|
+
}
|
|
560
|
+
const legacyComplete = peerBody.replica?.censusComplete;
|
|
561
|
+
// The legacy flag needs the same boolean gate as its replacement: a string
|
|
562
|
+
// "false" bypasses the `=== false` branch below and certifies a partial
|
|
563
|
+
// census (round 10). Absent stays compatibility; present-but-wrong is corrupt.
|
|
564
|
+
if (peerBody.corpusComplete === undefined && legacyComplete !== undefined && typeof legacyComplete !== "boolean") {
|
|
565
|
+
return { kind: "unknown", reason: "malformed_corpus" };
|
|
566
|
+
}
|
|
567
|
+
const peerComplete = typeof peerBody.corpusComplete === "boolean" ? peerBody.corpusComplete : legacyComplete;
|
|
568
|
+
if (peerComplete === false) {
|
|
569
|
+
return { kind: "unknown", reason: "peer_census_incomplete" };
|
|
570
|
+
}
|
|
571
|
+
// A peer census whose `computedAt` is too far from the poll time in EITHER
|
|
572
|
+
// direction cannot certify convergence — treat it as `unknown`, not health
|
|
573
|
+
// (round 6, codex). Too OLD predates changes it may not reflect; too far in
|
|
574
|
+
// the FUTURE is corrupt/clock-skewed telemetry (e.g. a 9999 timestamp) that
|
|
575
|
+
// would otherwise read as indefinitely fresh. `computedAt` is validated
|
|
576
|
+
// parseable above. A non-positive bound disables the gate: maxWatermarkAgeDeltaMs=0
|
|
577
|
+
// is the strictest DIVERGENCE mode ("flag any write-age gap"), NOT a 0ms
|
|
578
|
+
// census-freshness bound that would mark every peer stale (round 6, cursor).
|
|
579
|
+
const maxCensusAgeMs = options.maxCensusAgeMs;
|
|
580
|
+
if (maxCensusAgeMs !== undefined && maxCensusAgeMs > 0) {
|
|
581
|
+
const now = options.nowMs ?? Date.now();
|
|
582
|
+
if (corpus.some((watermark) => Math.abs(now - Date.parse(watermark.computedAt)) > maxCensusAgeMs)) {
|
|
583
|
+
return { kind: "unknown", reason: "peer_census_stale" };
|
|
584
|
+
}
|
|
585
|
+
}
|
|
586
|
+
return { kind: "ok", corpus };
|
|
587
|
+
}
|
|
588
|
+
return { kind: "unreachable", reason: "http_404" };
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// ---------------------------------------------------------------------------
|
|
592
|
+
// Poll all peers
|
|
593
|
+
// ---------------------------------------------------------------------------
|
|
594
|
+
|
|
595
|
+
async function mapWithConcurrency<T, R>(
|
|
596
|
+
items: readonly T[],
|
|
597
|
+
limit: number,
|
|
598
|
+
worker: (item: T, index: number) => Promise<R>,
|
|
599
|
+
): Promise<R[]> {
|
|
600
|
+
const results = new Array<R>(items.length);
|
|
601
|
+
let cursor = 0;
|
|
602
|
+
const runners = new Array(Math.min(Math.max(1, limit), items.length || 1)).fill(null).map(async () => {
|
|
603
|
+
while (cursor < items.length) {
|
|
604
|
+
const index = cursor;
|
|
605
|
+
cursor += 1;
|
|
606
|
+
results[index] = await worker(items[index], index);
|
|
607
|
+
}
|
|
608
|
+
});
|
|
609
|
+
await Promise.all(runners);
|
|
610
|
+
return results;
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
export interface PollReplicaPeersOptions {
|
|
614
|
+
config: ReplicaPeersConfig;
|
|
615
|
+
localWatermarks: readonly CorpusWatermark[];
|
|
616
|
+
now?: Date;
|
|
617
|
+
resolveSecretRef?: ResolveSecretRefFn | null;
|
|
618
|
+
fetchImpl?: FetchLike;
|
|
619
|
+
maxConcurrent?: number;
|
|
620
|
+
/** Optional sink for a single deduped warn line per non-converged poll cycle (never contains tokens). */
|
|
621
|
+
log?: (line: string) => void;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
/**
|
|
625
|
+
* Poll every configured peer once, compare each against the local watermark set,
|
|
626
|
+
* and assemble a report. Disabled or peerless → no network at all (returns an
|
|
627
|
+
* empty report). Never throws: a single peer failure degrades only that peer.
|
|
628
|
+
*/
|
|
629
|
+
export async function pollReplicaPeers(options: PollReplicaPeersOptions): Promise<ReplicaDivergenceStatus> {
|
|
630
|
+
const { config, localWatermarks } = options;
|
|
631
|
+
if (!config.enabled || config.peers.length === 0) {
|
|
632
|
+
return { enabled: config.enabled, pending: false, polledAt: null, peers: [] };
|
|
633
|
+
}
|
|
634
|
+
const polledAt = (options.now ?? new Date()).toISOString();
|
|
635
|
+
const thresholds = {
|
|
636
|
+
maxFileCountDelta: config.maxFileCountDelta,
|
|
637
|
+
maxWatermarkAgeDeltaMs: config.maxWatermarkAgeDeltaMs,
|
|
638
|
+
};
|
|
639
|
+
|
|
640
|
+
const peers = await mapWithConcurrency(
|
|
641
|
+
config.peers,
|
|
642
|
+
options.maxConcurrent ?? DEFAULT_MAX_CONCURRENT_PEER_FETCHES,
|
|
643
|
+
async (peer): Promise<ReplicaPeerReport> => {
|
|
644
|
+
const label = redactPeerUrl(peer.url);
|
|
645
|
+
try {
|
|
646
|
+
const outcome = await fetchPeerWatermarks(peer, {
|
|
647
|
+
timeoutMs: config.requestTimeoutMs,
|
|
648
|
+
resolveSecretRef: options.resolveSecretRef,
|
|
649
|
+
fetchImpl: options.fetchImpl,
|
|
650
|
+
maxCensusAgeMs: config.maxWatermarkAgeDeltaMs,
|
|
651
|
+
nowMs: options.now?.getTime(),
|
|
652
|
+
});
|
|
653
|
+
if (outcome.kind !== "ok") {
|
|
654
|
+
return { peer: label, state: outcome.kind, polledAt, namespaces: [], divergedNamespaceCount: 0, reason: outcome.reason };
|
|
655
|
+
}
|
|
656
|
+
const comparison = compareReplicaWatermarks(localWatermarks, outcome.corpus, thresholds);
|
|
657
|
+
const peerReport: ReplicaPeerReport = {
|
|
658
|
+
peer: label,
|
|
659
|
+
state: comparison.state,
|
|
660
|
+
polledAt,
|
|
661
|
+
namespaces: comparison.namespaces,
|
|
662
|
+
divergedNamespaceCount: comparison.divergedNamespaceCount,
|
|
663
|
+
};
|
|
664
|
+
// A comparison that resolves to `unknown` (ambiguous local-only, or no
|
|
665
|
+
// shared namespace) carries a token-free reason so /health and doctor
|
|
666
|
+
// show WHY it is not certified converged (round 6).
|
|
667
|
+
if (comparison.reason) peerReport.reason = comparison.reason;
|
|
668
|
+
return peerReport;
|
|
669
|
+
} catch {
|
|
670
|
+
// Defense in depth: no per-peer error may reject the whole poll (§22).
|
|
671
|
+
return { peer: label, state: "unreachable", polledAt, namespaces: [], divergedNamespaceCount: 0, reason: "error" };
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
);
|
|
675
|
+
|
|
676
|
+
const report: ReplicaDivergenceStatus = { enabled: true, pending: false, polledAt, peers };
|
|
677
|
+
logDivergence(options.log, report);
|
|
678
|
+
return report;
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function logDivergence(log: PollReplicaPeersOptions["log"], report: ReplicaDivergenceStatus): void {
|
|
682
|
+
if (!log) return;
|
|
683
|
+
const flagged = report.peers.filter((peer) => peer.state !== "converged");
|
|
684
|
+
if (flagged.length === 0) return;
|
|
685
|
+
const detail = flagged
|
|
686
|
+
.map((peer) => (peer.state === "diverged" ? `${peer.peer}=diverged(${peer.divergedNamespaceCount}ns)` : `${peer.peer}=${peer.state}`))
|
|
687
|
+
.join(", ");
|
|
688
|
+
log(`replica divergence: ${flagged.length} of ${report.peers.length} peer(s) flagged: ${detail}`);
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// ---------------------------------------------------------------------------
|
|
692
|
+
// Capability filtering (read-time)
|
|
693
|
+
// ---------------------------------------------------------------------------
|
|
694
|
+
|
|
695
|
+
/**
|
|
696
|
+
* Filter a full report to a presenting token's namespace capabilities, exactly
|
|
697
|
+
* as the corpus `/health` field is filtered (issue #2156 finding B): a
|
|
698
|
+
* namespace-restricted token must not learn about namespaces it cannot see. A
|
|
699
|
+
* peer's reachability state carries no namespace data and is preserved; a
|
|
700
|
+
* comparison peer's visible state is recomputed from its visible deltas so
|
|
701
|
+
* divergence in a hidden namespace never leaks as a "diverged" verdict. The
|
|
702
|
+
* census gate is then re-applied to the FILTERED view: filtering can hide a real
|
|
703
|
+
* shared divergence and leave a peer_only delta that must not read as a false
|
|
704
|
+
* split-brain to a restricted caller under an incomplete census (round 6, codex).
|
|
705
|
+
*/
|
|
706
|
+
export function filterReplicaReportByCaps(
|
|
707
|
+
report: ReplicaDivergenceStatus,
|
|
708
|
+
caps: TokenCapabilities | null | undefined,
|
|
709
|
+
): ReplicaDivergenceStatus {
|
|
710
|
+
if (!isCapabilityRestricted(caps ?? undefined)) return report;
|
|
711
|
+
const peers = report.peers.map((peer): ReplicaPeerReport => {
|
|
712
|
+
// Fetch-level states (unreachable, or unknown with no comparison) carry no
|
|
713
|
+
// namespace data — preserve verbatim. A comparison peer (converged/diverged,
|
|
714
|
+
// or comparison-`unknown` from an ambiguous local-only namespace) has deltas
|
|
715
|
+
// that MUST be filtered so a restricted token never learns a hidden namespace.
|
|
716
|
+
if (peer.namespaces.length === 0) return peer;
|
|
717
|
+
const namespaces = peer.namespaces.filter((delta) => capabilityAllowsNamespace(caps ?? undefined, delta.namespace));
|
|
718
|
+
const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
|
|
719
|
+
// A census-level `unknown` is NOT namespace-scoped: it says the local set was
|
|
720
|
+
// partial, which no amount of capability filtering can make safe. It survives
|
|
721
|
+
// the recompute verbatim (round 5, cursor).
|
|
722
|
+
if (peer.state === "unknown" && peer.reason === "local_census_incomplete") {
|
|
723
|
+
return { ...peer, namespaces, divergedNamespaceCount };
|
|
724
|
+
}
|
|
725
|
+
// Otherwise re-certify from the VISIBLE deltas through the one shared ladder.
|
|
726
|
+
// A token that hid EVERY namespace of this peer now sees zero shared
|
|
727
|
+
// evidence, which `verdictFromDeltas` resolves to `unknown` — never a
|
|
728
|
+
// convergence claim derived from nothing (round 6, coderabbit).
|
|
729
|
+
const verdict = verdictFromDeltas(namespaces);
|
|
730
|
+
const filtered: ReplicaPeerReport = { ...peer, namespaces, divergedNamespaceCount, state: verdict.state };
|
|
731
|
+
if (verdict.reason) filtered.reason = verdict.reason;
|
|
732
|
+
else delete filtered.reason;
|
|
733
|
+
return filtered;
|
|
734
|
+
});
|
|
735
|
+
// Re-apply the census gate to the FILTERED view: hiding a real shared-namespace
|
|
736
|
+
// divergence can leave a visible peer_only delta that would falsely read as
|
|
737
|
+
// diverged to a restricted caller when the local census was incomplete. The
|
|
738
|
+
// gate is a no-op for a complete census (round 6, codex).
|
|
739
|
+
return gateReportByCensus({ ...report, peers }, report.censusComplete !== false);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
// Local-census completeness gate (shared by monitor + doctor)
|
|
744
|
+
// ---------------------------------------------------------------------------
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* Overlay the LOCAL-census half of the certification rule onto a whole report:
|
|
748
|
+
* a peer is `converged` only when the local census scanned EVERY configured
|
|
749
|
+
* namespace — a dropped local tenant never enters the comparison, so its
|
|
750
|
+
* divergence would be invisible (round 4). Applied identically by the
|
|
751
|
+
* background monitor (/health) and `summarizeReplicaDivergence` (doctor) so the
|
|
752
|
+
* two surfaces cannot disagree about an incomplete census (round 6). Under an
|
|
753
|
+
* incomplete census every `peer_only` delta is NEUTRALIZED (an incomplete local
|
|
754
|
+
* scan cannot tell a namespace we genuinely lack from one we merely failed to
|
|
755
|
+
* read — a false split-brain), so it stops counting as divergence. A peer then
|
|
756
|
+
* stays `diverged` ONLY if a REAL shared-namespace divergence remains; otherwise
|
|
757
|
+
* (converged, or peer_only-only) it is `unknown`/`local_census_incomplete`.
|
|
758
|
+
* unreachable/unknown already carry a truthful state and stand.
|
|
759
|
+
*/
|
|
760
|
+
export function gateReportByCensus(
|
|
761
|
+
report: ReplicaDivergenceStatus,
|
|
762
|
+
censusComplete: boolean,
|
|
763
|
+
): ReplicaDivergenceStatus {
|
|
764
|
+
if (censusComplete) return { ...report, censusComplete: true };
|
|
765
|
+
return {
|
|
766
|
+
...report,
|
|
767
|
+
censusComplete: false,
|
|
768
|
+
peers: report.peers.map((peer) => {
|
|
769
|
+
if (peer.state === "unreachable" || peer.state === "unknown") return peer;
|
|
770
|
+
// Neutralize every `peer_only` delta: against a partial local set a namespace
|
|
771
|
+
// we failed to scan is indistinguishable from one we genuinely lack, so it
|
|
772
|
+
// must not count as divergence or claim "absent locally" (round 6, codex).
|
|
773
|
+
// A REAL shared-namespace divergence still stands.
|
|
774
|
+
const namespaces = peer.namespaces.map((delta) =>
|
|
775
|
+
delta.presence === "peer_only" && delta.diverged
|
|
776
|
+
? { ...delta, diverged: false, reasons: ["namespace_absent_locally_unverified"] }
|
|
777
|
+
: delta,
|
|
778
|
+
);
|
|
779
|
+
const divergedNamespaceCount = namespaces.filter((delta) => delta.diverged).length;
|
|
780
|
+
const hasSharedDivergence = namespaces.some((delta) => delta.presence === "both" && delta.diverged);
|
|
781
|
+
if (hasSharedDivergence) return { ...peer, namespaces, divergedNamespaceCount };
|
|
782
|
+
return { ...peer, namespaces, divergedNamespaceCount, state: "unknown" as const, reason: "local_census_incomplete" };
|
|
783
|
+
}),
|
|
784
|
+
};
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---------------------------------------------------------------------------
|
|
788
|
+
// Background monitor (SWR / single-flight — corpus idiom, per-instance)
|
|
789
|
+
// ---------------------------------------------------------------------------
|
|
790
|
+
|
|
791
|
+
export interface ReplicaDivergenceMonitorOptions {
|
|
792
|
+
clock?: () => number;
|
|
793
|
+
resolveSecretRef?: ResolveSecretRefFn | null;
|
|
794
|
+
fetchImpl?: FetchLike;
|
|
795
|
+
maxConcurrent?: number;
|
|
796
|
+
log?: (line: string) => void;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Instance-scoped (§5) stale-while-revalidate monitor of peer divergence. {@link
|
|
801
|
+
* getReport} NEVER awaits the poll: it returns the last completed report (stale
|
|
802
|
+
* allowed, or a never-polled placeholder) and single-flights a background poll
|
|
803
|
+
* when the entry is missing or older than `pollIntervalMs`. So a `/health` probe
|
|
804
|
+
* is always O(1) and polling never runs inline on the request path. Disabled or
|
|
805
|
+
* peerless config short-circuits with zero network work.
|
|
806
|
+
*/
|
|
807
|
+
export class ReplicaDivergenceMonitor {
|
|
808
|
+
private cached: { report: ReplicaDivergenceStatus; expiresAt: number } | undefined;
|
|
809
|
+
/** Earliest clock() at which a failed poll may retry — enforces backoff (round 6). */
|
|
810
|
+
private nextAttemptAt = 0;
|
|
811
|
+
private inFlight: Promise<void> | undefined;
|
|
812
|
+
private readonly clock: () => number;
|
|
813
|
+
private readonly resolveSecretRef: ResolveSecretRefFn | null | undefined;
|
|
814
|
+
private readonly fetchImpl: FetchLike | undefined;
|
|
815
|
+
private readonly maxConcurrent: number | undefined;
|
|
816
|
+
private readonly log: ((line: string) => void) | undefined;
|
|
817
|
+
|
|
818
|
+
constructor(options: ReplicaDivergenceMonitorOptions = {}) {
|
|
819
|
+
this.clock = options.clock ?? Date.now;
|
|
820
|
+
this.resolveSecretRef = options.resolveSecretRef;
|
|
821
|
+
this.fetchImpl = options.fetchImpl;
|
|
822
|
+
this.maxConcurrent = options.maxConcurrent;
|
|
823
|
+
this.log = options.log;
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
getReport(input: {
|
|
827
|
+
/**
|
|
828
|
+
* Raw `replicaPeers` block. An absent/partial/loosely-typed block (a host
|
|
829
|
+
* adapter or an older persisted config that bypassed `parseConfig`) resolves
|
|
830
|
+
* to the documented default — disabled, no peers, no polling — so `/health`
|
|
831
|
+
* stays answerable instead of throwing (issue #2155 read-boundary pattern).
|
|
832
|
+
*/
|
|
833
|
+
config: ReplicaPeersConfig | undefined;
|
|
834
|
+
/** Fresh local watermark set for comparison; invoked only during a background refresh. */
|
|
835
|
+
computeLocalWatermarks: () => Promise<LocalCensus>;
|
|
836
|
+
caps?: TokenCapabilities | null;
|
|
837
|
+
/**
|
|
838
|
+
* Completeness of the census THIS request is presenting alongside the
|
|
839
|
+
* report. A cached poll was gated by the census that existed when it ran,
|
|
840
|
+
* so a since-degraded scan could ship `converged` peers next to an
|
|
841
|
+
* incomplete corpus (round 9, cursor). Gating on the way out keeps the one
|
|
842
|
+
* response self-consistent without poisoning the shared cache.
|
|
843
|
+
*/
|
|
844
|
+
localCensusComplete?: boolean;
|
|
845
|
+
}): ReplicaDivergenceStatus {
|
|
846
|
+
const config = resolveReplicaPeersConfig(input.config);
|
|
847
|
+
if (!config.enabled) return { enabled: false, pending: false, polledAt: null, peers: [] };
|
|
848
|
+
if (config.peers.length === 0) return { enabled: true, pending: false, polledAt: null, peers: [] };
|
|
849
|
+
const fresh = this.cached !== undefined && this.clock() < this.cached.expiresAt;
|
|
850
|
+
// A failed poll backs off for one interval: refreshing again on the very
|
|
851
|
+
// next probe would re-run a full local corpus scan + peer fan-out per
|
|
852
|
+
// request (round 6, coderabbit). Serve the last good report if any, else the
|
|
853
|
+
// pending placeholder — both truthful, neither `converged`.
|
|
854
|
+
if (!fresh && this.clock() >= this.nextAttemptAt) this.refresh(config, input.computeLocalWatermarks);
|
|
855
|
+
if (this.cached) {
|
|
856
|
+
const gated =
|
|
857
|
+
input.localCensusComplete === false ? gateReportByCensus(this.cached.report, false) : this.cached.report;
|
|
858
|
+
return filterReplicaReportByCaps(gated, input.caps ?? null);
|
|
859
|
+
}
|
|
860
|
+
// Enabled with peers but no completed poll yet — distinct from "no peers".
|
|
861
|
+
return { enabled: true, pending: true, polledAt: null, peers: [] };
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
private refresh(config: ReplicaPeersConfig, computeLocalWatermarks: () => Promise<LocalCensus>): void {
|
|
865
|
+
if (this.inFlight) return;
|
|
866
|
+
this.inFlight = (async () => {
|
|
867
|
+
const census = await computeLocalWatermarks();
|
|
868
|
+
const localWatermarks = census.watermarks;
|
|
869
|
+
const report = await pollReplicaPeers({
|
|
870
|
+
config,
|
|
871
|
+
localWatermarks,
|
|
872
|
+
resolveSecretRef: this.resolveSecretRef,
|
|
873
|
+
fetchImpl: this.fetchImpl,
|
|
874
|
+
maxConcurrent: this.maxConcurrent,
|
|
875
|
+
log: this.log,
|
|
876
|
+
});
|
|
877
|
+
// Expiry is measured from when the poll FINISHES, not when it starts: a
|
|
878
|
+
// poll slower than pollIntervalMs would otherwise store an already-expired
|
|
879
|
+
// entry and re-poll on every probe (round 2, cursor). The census overlay
|
|
880
|
+
// (an incomplete local set cannot certify convergence) is the SAME shared
|
|
881
|
+
// gate the doctor applies, so /health and doctor cannot disagree (round 6).
|
|
882
|
+
const gated = gateReportByCensus(report, census.complete);
|
|
883
|
+
// A poll interval longer than the freshness bound would keep certifying
|
|
884
|
+
// telemetry the freshness check would now reject (round 10). Cache for the
|
|
885
|
+
// shorter of the two so a cached `converged` can never outlive it. Zero is
|
|
886
|
+
// the documented "compare write ages strictly, no staleness gate" mode, NOT
|
|
887
|
+
// a zero-length cache - that would re-scan the corpus on every probe
|
|
888
|
+
// (round 11, §17 zero footgun).
|
|
889
|
+
const freshnessCap = config.maxWatermarkAgeDeltaMs > 0 ? config.maxWatermarkAgeDeltaMs : Number.POSITIVE_INFINITY;
|
|
890
|
+
const ttl = Math.min(config.pollIntervalMs, freshnessCap);
|
|
891
|
+
this.cached = { report: gated, expiresAt: this.clock() + ttl };
|
|
892
|
+
this.nextAttemptAt = 0; // a successful poll clears any failure backoff
|
|
893
|
+
})()
|
|
894
|
+
.catch(() => {
|
|
895
|
+
// A failed poll caches no REPORT, but MUST consume the interval: else
|
|
896
|
+
// every probe reschedules a full local corpus scan + peer fan-out on a
|
|
897
|
+
// corpus this feature exists to protect (round 6, coderabbit). Until
|
|
898
|
+
// nextAttemptAt, getReport serves the last good report or `pending`.
|
|
899
|
+
this.nextAttemptAt = this.clock() + config.pollIntervalMs;
|
|
900
|
+
})
|
|
901
|
+
.finally(() => {
|
|
902
|
+
this.inFlight = undefined;
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/** Await any in-flight background poll (deterministic tests / shutdown). */
|
|
907
|
+
async whenIdle(): Promise<void> {
|
|
908
|
+
while (this.inFlight) await this.inFlight;
|
|
909
|
+
}
|
|
910
|
+
}
|