@7h3/protocol 0.1.2 → 0.5.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/.dockerignore +19 -0
- package/.github/dependabot.yml +32 -0
- package/.github/workflows/ci.yml +31 -0
- package/.github/workflows/publish.yml +59 -0
- package/.github/workflows/scorecard.yml +37 -0
- package/7h3.example.yaml +125 -0
- package/CHANGELOG.md +152 -0
- package/CONTRIBUTING.md +82 -0
- package/Dockerfile +73 -0
- package/GOVERNANCE.md +62 -0
- package/README.md +1317 -6
- package/SECURITY.md +70 -0
- package/bench-results/replay-cache-full-1777891033256.json +10 -0
- package/bench-results/replay-cache-full-1777896317488.json +10 -0
- package/bench-results/replay-cache-full-1777900993184.json +10 -0
- package/bench-results/replay-cache-full-1777901019285.json +10 -0
- package/bench-results/replay-cache-quick-1777870170126.json +10 -0
- package/bench-results/signature-profiles-quick-1775875160079.json +85 -0
- package/bench-results/signature-profiles-quick-1775983539716.json +85 -0
- package/bench-results/signature-profiles-quick-1776237913190.json +85 -0
- package/bench-results/wire-codecs-full-1777891019803.json +93 -0
- package/bench-results/wire-codecs-full-1777896260964.json +93 -0
- package/bench-results/wire-codecs-full-1777901004247.json +93 -0
- package/bench-results/wire-codecs-quick-1775972879056.json +93 -0
- package/bench-results/wire-codecs-quick-1775983541111.json +93 -0
- package/bench-results/wire-codecs-quick-1776237914299.json +93 -0
- package/bench-results/wire-codecs-quick-1777841285236.json +93 -0
- package/bench-results/wire-codecs-quick-1777841321772.json +93 -0
- package/bench-results/wire-codecs-quick-1777841330408.json +93 -0
- package/bench-results/wire-codecs-quick-1777852886082.json +93 -0
- package/bench-results/wire-codecs-quick-1777852988773.json +93 -0
- package/bench-results/wire-codecs-quick-1777870188095.json +93 -0
- package/bench-results/wire-codecs-quick-1777870263918.json +93 -0
- package/bench-results/wire-codecs-quick-1777870455034.json +93 -0
- package/bench-results/wire-codecs-quick-1778816163081.json +93 -0
- package/bench-results/wire-codecs-quick-1778843936130.json +93 -0
- package/bin/7h3.ts +406 -0
- package/conformance/7h3_v0_1.json +77 -0
- package/conformance/7h3_v0_1_binary.json +20 -0
- package/conformance/aip_v0_1_binary.json +20 -0
- package/docker-compose.yaml +77 -0
- package/docs/ADOPTION_PLAN.md +120 -0
- package/docs/AGENTS.md +77 -0
- package/docs/AIP_RFC_v0.1.md +97 -0
- package/docs/AI_DECISION_CARD.md +122 -0
- package/docs/AI_RUNTIME_POLICY.json +126 -0
- package/docs/AI_RUNTIME_POLICY.yaml +110 -0
- package/docs/BACKPRESSURE_TUNING.md +65 -0
- package/docs/BENCHMARK_CLAIM_MATRIX.md +42 -0
- package/docs/BENCHMARK_REPORT_TEMPLATE.md +169 -0
- package/docs/BINARY_CODEC_BENCH.md +23 -0
- package/docs/CLEAN_CLONE_RUNBOOK.md +36 -0
- package/docs/CLOCK_SKEW_POLICY.md +30 -0
- package/docs/DISTRIBUTED_REPLAY.md +142 -0
- package/docs/FUZZ_CAMPAIGN.md +121 -0
- package/docs/GATEWAY.md +195 -0
- package/docs/KEY_MANAGEMENT_POLICY.md +53 -0
- package/docs/KEY_REVOCATION.md +69 -0
- package/docs/MCP_WRAPPER.md +159 -0
- package/docs/MIGRATION_GUIDE.md +40 -0
- package/docs/OPERATORS.md +184 -0
- package/docs/PERF_REGRESSION_POLICY.md +34 -0
- package/docs/PROJECT_EXAMINATION_2026-05-31.md +219 -0
- package/docs/RELEASE_BENCHMARK_REPORT_2026-05-15.md +135 -0
- package/docs/RELEASE_GATE.md +25 -0
- package/docs/RELEASE_NOTES_v0.1.0.md +54 -0
- package/docs/SECURITY_REVIEW_2026-06-05.md +165 -0
- package/docs/TELEMETRY.md +41 -0
- package/docs/THREAT_MODEL.md +89 -0
- package/docs/VERSIONING_POLICY.md +30 -0
- package/docs/assets/banner-github.png +0 -0
- package/docs/assets/banner.png +0 -0
- package/docs/assets/banner.svg +123 -0
- package/eslint.config.js +15 -0
- package/fuzz/ts/harness-decode.ts +136 -0
- package/fuzz/ts/harness-verify.ts +121 -0
- package/fuzz/ts/run.ts +35 -0
- package/mcp-server/README.md +38 -0
- package/mcp-server/package-lock.json +1187 -0
- package/mcp-server/package.json +35 -0
- package/mcp-server/src/index.ts +236 -0
- package/mcp-server/tsconfig.json +14 -0
- package/package.json +121 -13
- package/scripts/aip-framework-quickstart.ts +110 -0
- package/scripts/aip-mcp-gateway.ts +38 -0
- package/scripts/aip-mcp-wrap-demo.ts +72 -0
- package/scripts/aip-quickstart.ts +60 -0
- package/scripts/bench-diff.ts +118 -0
- package/scripts/bench-protocol-e2e.ts +937 -0
- package/scripts/bench-protocol-openloop.ts +1397 -0
- package/scripts/bench-replay-cache.ts +76 -0
- package/scripts/bench-signature-profiles.ts +180 -0
- package/scripts/bench-wire-codecs.ts +161 -0
- package/scripts/build-binary-conformance.ts +36 -0
- package/scripts/build-release-dashboard.ts +175 -0
- package/scripts/canary-rollout.ts +38 -0
- package/scripts/mcpGatewayCli.test.ts +116 -0
- package/scripts/prepare-aip-package.ts +88 -0
- package/scripts/regen-conformance-sigs.ts +18 -0
- package/scripts/release-gate.ts +19 -0
- package/scripts/validate-runtime-policy.ts +18 -0
- package/sdk/browser/index.test.ts +162 -0
- package/sdk/browser/index.ts +257 -0
- package/sdk/browser/package.json +13 -0
- package/sdk/go/cbor.go +551 -0
- package/sdk/go/cbor_test.go +232 -0
- package/sdk/go/encryption.go +280 -0
- package/sdk/go/encryption_test.go +318 -0
- package/sdk/go/go.mod +7 -0
- package/sdk/go/go.sum +4 -0
- package/sdk/go/http.go +135 -0
- package/sdk/go/protocol.go +324 -0
- package/sdk/go/protocol_test.go +334 -0
- package/sdk/go/replay.go +121 -0
- package/sdk/go/replay_test.go +149 -0
- package/sdk/go/webhook.go +136 -0
- package/sdk/pq/package-lock.json +1358 -0
- package/sdk/pq/package.json +42 -0
- package/sdk/pq/src/index.test.ts +143 -0
- package/sdk/pq/src/index.ts +166 -0
- package/sdk/pq/tsconfig.json +14 -0
- package/sdk/pq/vitest.config.ts +7 -0
- package/sdk/python/README.md +18 -0
- package/sdk/python/protocol_7h3/__init__.py +46 -0
- package/sdk/python/protocol_7h3/encryption.py +252 -0
- package/sdk/python/protocol_7h3/http.py +212 -0
- package/sdk/python/protocol_7h3/keys.py +149 -0
- package/sdk/python/protocol_7h3/pq.py +244 -0
- package/sdk/python/protocol_7h3/protocol.py +525 -0
- package/sdk/python/protocol_7h3/queue.py +118 -0
- package/sdk/python/protocol_7h3/replay.py +98 -0
- package/sdk/python/protocol_7h3/webhook.py +116 -0
- package/sdk/python/pyproject.toml +40 -0
- package/sdk/python/tests/test_conformance.py +110 -0
- package/sdk/python/tests/test_encryption.py +206 -0
- package/sdk/python/tests/test_http.py +305 -0
- package/sdk/python/tests/test_keys.py +417 -0
- package/sdk/python/tests/test_queue.py +120 -0
- package/sdk/python/tests/test_webhook.py +345 -0
- package/sdk/rust/Cargo.lock +371 -0
- package/sdk/rust/Cargo.toml +25 -0
- package/sdk/rust/README.md +31 -0
- package/sdk/rust/fuzz/Cargo.toml +29 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_canonicalize.rs +46 -0
- package/sdk/rust/fuzz/fuzz_targets/fuzz_decode.rs +11 -0
- package/sdk/rust/src/bin/aip_mcp_gateway.rs +59 -0
- package/sdk/rust/src/http.rs +145 -0
- package/sdk/rust/src/keys.rs +161 -0
- package/sdk/rust/src/lib.rs +688 -0
- package/sdk/rust/src/queue.rs +79 -0
- package/sdk/rust/src/webhook.rs +86 -0
- package/sdk/rust/tests/conformance.rs +148 -0
- package/sdk/rust/tests/gateway.rs +130 -0
- package/sdk/rust/tests/http_webhook_queue.rs +201 -0
- package/sdk/rust/tests/keys.rs +189 -0
- package/sdk/threshold/index.d.ts +68 -0
- package/sdk/threshold/index.d.ts.map +1 -0
- package/sdk/threshold/index.js +254 -0
- package/sdk/threshold/package-lock.json +1361 -0
- package/sdk/threshold/package.json +39 -0
- package/sdk/threshold/src/index.d.ts +68 -0
- package/sdk/threshold/src/index.d.ts.map +1 -0
- package/sdk/threshold/src/index.js +254 -0
- package/sdk/threshold/src/index.test.ts +238 -0
- package/sdk/threshold/src/index.ts +355 -0
- package/sdk/threshold/tsconfig.json +19 -0
- package/sdk/threshold/vitest.config.ts +12 -0
- package/src/agentAdapter.test.ts +48 -0
- package/src/agentAdapter.ts +56 -0
- package/src/auditLog.test.ts +145 -0
- package/src/auditLog.ts +147 -0
- package/src/capability.test.ts +504 -0
- package/src/capability.ts +380 -0
- package/src/cborCodec.test.ts +263 -0
- package/src/cborCodec.ts +339 -0
- package/src/conformance.test.ts +136 -0
- package/src/conformanceVectors.ts +99 -0
- package/src/encryption.test.ts +206 -0
- package/src/encryption.ts +245 -0
- package/src/envelopeCbor.ts +140 -0
- package/src/frameworkAdapters.test.ts +290 -0
- package/src/frameworkAdapters.ts +261 -0
- package/src/gateway.test.ts +343 -0
- package/src/gateway.ts +246 -0
- package/src/grpcBinding.test.ts +211 -0
- package/src/grpcBinding.ts +103 -0
- package/src/httpBinding.test.ts +376 -0
- package/src/httpBinding.ts +189 -0
- package/src/index.ts +39 -0
- package/src/keyInfra.test.ts +278 -0
- package/src/keyInfra.ts +228 -0
- package/src/keyRegistry.ts +59 -0
- package/src/keyRotation.test.ts +78 -0
- package/src/keyRotation.ts +72 -0
- package/src/mcpGateway.test.ts +129 -0
- package/src/mcpGateway.ts +250 -0
- package/src/mcpTransports.test.ts +92 -0
- package/src/mcpTransports.ts +169 -0
- package/src/mcpWrapper.test.ts +179 -0
- package/src/mcpWrapper.ts +206 -0
- package/src/otel.ts +136 -0
- package/src/policyEnforcer.test.ts +99 -0
- package/src/policyEnforcer.ts +169 -0
- package/src/policyTelemetryFeedback.test.ts +25 -0
- package/src/policyTelemetryFeedback.ts +38 -0
- package/src/protocol.bench.ts +37 -0
- package/{protocol.d.ts → src/protocol.d.ts} +2 -1
- package/src/protocol.d.ts.map +1 -0
- package/src/protocol.js +294 -0
- package/src/protocol.test.ts +155 -0
- package/src/protocol.ts +414 -0
- package/src/protocolAgent.test.ts +105 -0
- package/src/protocolAgent.ts +169 -0
- package/src/protocolBinary.test.ts +165 -0
- package/src/protocolBinary.ts +312 -0
- package/src/protocolCapabilities.ts +70 -0
- package/src/protocolFuzz.advanced.test.ts +235 -0
- package/src/protocolFuzz.test.ts +111 -0
- package/src/protocolNegative.test.ts +97 -0
- package/src/protocolReplay.test.ts +71 -0
- package/src/protocolReplay.ts +194 -0
- package/src/protocolTransport.test.ts +556 -0
- package/src/protocolTransport.ts +483 -0
- package/src/queueBinding.test.ts +130 -0
- package/src/queueBinding.ts +102 -0
- package/src/rateLimiter.test.ts +96 -0
- package/src/rateLimiter.ts +46 -0
- package/src/redisClient.ts +140 -0
- package/src/redisIntegration.test.ts +134 -0
- package/src/replayStores.test.ts +273 -0
- package/src/replayStores.ts +215 -0
- package/src/revocation.test.ts +98 -0
- package/src/revocation.ts +0 -0
- package/src/routePolicy.test.ts +87 -0
- package/src/routePolicy.ts +72 -0
- package/src/runtimePolicy.test.ts +49 -0
- package/src/runtimePolicy.ts +81 -0
- package/src/runtimePolicyManager.test.ts +29 -0
- package/src/runtimePolicyManager.ts +50 -0
- package/src/runtimePolicyPresets.ts +43 -0
- package/src/signedResponse.test.ts +111 -0
- package/src/signedResponse.ts +83 -0
- package/src/stream.test.ts +254 -0
- package/src/stream.ts +417 -0
- package/src/telemetry.test.ts +251 -0
- package/src/telemetry.ts +299 -0
- package/src/webhookBinding.test.ts +144 -0
- package/src/webhookBinding.ts +115 -0
- package/src/wsBinding.test.ts +221 -0
- package/src/wsBinding.ts +200 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +23 -0
- package/vite.lib.config.ts +16 -0
- package/vitest.config.ts +11 -0
- package/agentAdapter.d.ts +0 -26
- package/conformanceVectors.d.ts +0 -20
- package/frameworkAdapters.d.ts +0 -72
- package/index.d.ts +0 -20
- package/index.js +0 -1702
- package/keyRotation.d.ts +0 -20
- package/mcpGateway.d.ts +0 -37
- package/mcpTransports.d.ts +0 -62
- package/mcpWrapper.d.ts +0 -83
- package/policyEnforcer.d.ts +0 -50
- package/policyTelemetryFeedback.d.ts +0 -11
- package/protocolAgent.d.ts +0 -58
- package/protocolBinary.d.ts +0 -8
- package/protocolCapabilities.d.ts +0 -24
- package/protocolReplay.d.ts +0 -35
- package/protocolTransport.d.ts +0 -73
- package/redisClient.d.ts +0 -49
- package/replayStores.d.ts +0 -32
- package/revocation.d.ts +0 -71
- package/runtimePolicy.d.ts +0 -24
- package/runtimePolicyManager.d.ts +0 -15
- package/runtimePolicyPresets.d.ts +0 -11
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createEnvelope, type ProtocolEnvelope } from './protocol'
|
|
2
|
+
import { InMemoryReplayCache } from './protocolReplay'
|
|
3
|
+
import {
|
|
4
|
+
encodeEnvelope,
|
|
5
|
+
receiveEnvelope,
|
|
6
|
+
type ReceiveEnvelopeOptions,
|
|
7
|
+
type WireEnvelope,
|
|
8
|
+
type WireFormat,
|
|
9
|
+
} from './protocolTransport'
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* MCP hardening middleware.
|
|
13
|
+
*
|
|
14
|
+
* MCP messages are plain JSON-RPC 2.0 and, as shipped, carry no signature or
|
|
15
|
+
* replay protection. These wrappers put an AIP envelope around each message on
|
|
16
|
+
* the wire — signing, TTL-bounding, and replay-checking every request and
|
|
17
|
+
* response — while the underlying MCP handler still receives plain JSON-RPC.
|
|
18
|
+
* "Zero app changes": your handler signature does not change.
|
|
19
|
+
*
|
|
20
|
+
* The wrappers enforce four bindings beyond signature verification:
|
|
21
|
+
* - recipient binding: the server only runs the handler when the envelope is
|
|
22
|
+
* addressed to it (`recipient === selfAgentId`) — defeats cross-server relay.
|
|
23
|
+
* - sender binding: the client only accepts responses from its expected peer
|
|
24
|
+
* (`sender === peerAgentId`) — defeats response spoofing.
|
|
25
|
+
* - correlation binding: the client only accepts a response whose
|
|
26
|
+
* `correlationId` matches the request it sent — defeats response substitution.
|
|
27
|
+
* - replay protection on by default (an `InMemoryReplayCache` if none supplied).
|
|
28
|
+
*
|
|
29
|
+
* Verification reuses the transport pipeline (`receiveEnvelope`), so it composes
|
|
30
|
+
* directly with a Redis-backed replay cache (`createRedisReplayStore`) and
|
|
31
|
+
* fleet-wide revocation (`withRevocationCheck`) via the `receive` options.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export type JsonRpcId = string | number | null
|
|
35
|
+
|
|
36
|
+
export interface JsonRpcRequest {
|
|
37
|
+
jsonrpc: '2.0'
|
|
38
|
+
id: JsonRpcId
|
|
39
|
+
method: string
|
|
40
|
+
params?: unknown
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface JsonRpcResponse {
|
|
44
|
+
jsonrpc: '2.0'
|
|
45
|
+
id: JsonRpcId
|
|
46
|
+
result?: unknown
|
|
47
|
+
error?: { code: number; message: string; data?: unknown }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export type McpJsonRpcHandler = (request: JsonRpcRequest) => JsonRpcResponse | Promise<JsonRpcResponse>
|
|
51
|
+
|
|
52
|
+
export interface McpSecurityOptions {
|
|
53
|
+
/** This peer's agent id, used as the envelope `sender` and enforced as the inbound `recipient`. */
|
|
54
|
+
selfAgentId: string
|
|
55
|
+
/** The peer's agent id: set as the envelope `recipient` (client) and enforced as the response `sender`. */
|
|
56
|
+
peerAgentId?: string
|
|
57
|
+
/** Signs an unsigned envelope, e.g. `(e) => signEnvelopeHmac(e, secret, keyId)`. */
|
|
58
|
+
sign: (envelope: Omit<ProtocolEnvelope, 'signature'>) => Promise<ProtocolEnvelope>
|
|
59
|
+
/** Inbound verification options: signature material resolver, replay cache, clock skew, etc. */
|
|
60
|
+
receive?: ReceiveEnvelopeOptions
|
|
61
|
+
/** Wire format for the envelope on the wire. Default `compact`. */
|
|
62
|
+
wireFormat?: WireFormat
|
|
63
|
+
/** Envelope TTL in ms. Default 60_000. */
|
|
64
|
+
ttlMs?: number
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface EncodedRequest {
|
|
68
|
+
raw: WireEnvelope
|
|
69
|
+
/** The envelope `messageId` generated for this request; bind the response's `correlationId` to it. */
|
|
70
|
+
messageId: string
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface DecodeResponseOptions {
|
|
74
|
+
/** When set, require the response `correlationId` to equal this value (the request's messageId). */
|
|
75
|
+
expectCorrelationId?: string
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function jsonRpcError(id: JsonRpcId, code: number, message: string): JsonRpcResponse {
|
|
79
|
+
return { jsonrpc: '2.0', id, error: { code, message } }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Verification options with signature required and a replay cache guaranteed (secure defaults). */
|
|
83
|
+
function hardenedReceive(options: McpSecurityOptions, replayCache: ReceiveEnvelopeOptions['replayCache']): ReceiveEnvelopeOptions {
|
|
84
|
+
return { requireSignature: true, ...options.receive, replayCache }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Wraps an existing MCP JSON-RPC handler so every inbound message is verified
|
|
89
|
+
* (signature + replay + TTL + recipient binding) before the handler runs, and
|
|
90
|
+
* every response is signed. Returns a function over the AIP wire envelope.
|
|
91
|
+
*/
|
|
92
|
+
export function wrapMcpServer(handler: McpJsonRpcHandler, options: McpSecurityOptions): (raw: WireEnvelope) => Promise<WireEnvelope> {
|
|
93
|
+
const wireFormat = options.wireFormat ?? 'compact'
|
|
94
|
+
const ttlMs = options.ttlMs ?? 60_000
|
|
95
|
+
// Replay protection is on by default; supply a Redis-backed cache via options.receive for scale.
|
|
96
|
+
const replayCache = options.receive?.replayCache ?? new InMemoryReplayCache()
|
|
97
|
+
const receiveOptions = hardenedReceive(options, replayCache)
|
|
98
|
+
|
|
99
|
+
async function signAndEncode(envelope: Omit<ProtocolEnvelope, 'signature'>): Promise<WireEnvelope> {
|
|
100
|
+
return encodeEnvelope(await options.sign(envelope), wireFormat)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function reply(recipient: string | undefined, intent: 'RESULT' | 'ERROR', response: JsonRpcResponse, correlationId?: string) {
|
|
104
|
+
return signAndEncode(
|
|
105
|
+
createEnvelope({ sender: options.selfAgentId, recipient, intent, content: JSON.stringify(response), correlationId, ttlMs }),
|
|
106
|
+
)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return async (raw: WireEnvelope): Promise<WireEnvelope> => {
|
|
110
|
+
const received = await receiveEnvelope(raw, receiveOptions)
|
|
111
|
+
const senderHint = received.envelope?.header.sender
|
|
112
|
+
const correlationId = received.envelope?.header.messageId
|
|
113
|
+
|
|
114
|
+
if (!received.ok || !received.envelope) {
|
|
115
|
+
const reason = received.diagnostics.find((d) => d.level === 'error')?.message ?? 'AIP verification failed'
|
|
116
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32600, reason), correlationId)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Recipient binding: refuse to act on an envelope not addressed to this agent.
|
|
120
|
+
if (received.envelope.header.recipient !== options.selfAgentId) {
|
|
121
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32600, 'Recipient mismatch: envelope not addressed to this agent'), correlationId)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
let request: JsonRpcRequest
|
|
125
|
+
try {
|
|
126
|
+
request = JSON.parse(received.envelope.body.content) as JsonRpcRequest
|
|
127
|
+
} catch {
|
|
128
|
+
return reply(senderHint, 'ERROR', jsonRpcError(null, -32700, 'Parse error: envelope body is not JSON-RPC'), correlationId)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
let response: JsonRpcResponse
|
|
132
|
+
try {
|
|
133
|
+
response = await handler(request)
|
|
134
|
+
} catch (error) {
|
|
135
|
+
response = jsonRpcError(request.id ?? null, -32603, error instanceof Error ? error.message : 'Internal error')
|
|
136
|
+
}
|
|
137
|
+
return reply(received.envelope.header.sender, 'RESULT', response, correlationId)
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export interface McpClientCodec {
|
|
142
|
+
encodeRequest(request: JsonRpcRequest): Promise<EncodedRequest>
|
|
143
|
+
decodeResponse(raw: WireEnvelope, options?: DecodeResponseOptions): Promise<JsonRpcResponse>
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Signs outbound requests and verifies inbound responses (with sender + correlation binding). */
|
|
147
|
+
export function createMcpClientCodec(options: McpSecurityOptions): McpClientCodec {
|
|
148
|
+
const wireFormat = options.wireFormat ?? 'compact'
|
|
149
|
+
const ttlMs = options.ttlMs ?? 60_000
|
|
150
|
+
const replayCache = options.receive?.replayCache ?? new InMemoryReplayCache()
|
|
151
|
+
const receiveOptions = hardenedReceive(options, replayCache)
|
|
152
|
+
|
|
153
|
+
return {
|
|
154
|
+
async encodeRequest(request: JsonRpcRequest): Promise<EncodedRequest> {
|
|
155
|
+
const envelope = createEnvelope({
|
|
156
|
+
sender: options.selfAgentId,
|
|
157
|
+
recipient: options.peerAgentId,
|
|
158
|
+
intent: 'TASK',
|
|
159
|
+
content: JSON.stringify(request),
|
|
160
|
+
ttlMs,
|
|
161
|
+
})
|
|
162
|
+
return { raw: encodeEnvelope(await options.sign(envelope), wireFormat), messageId: envelope.header.messageId }
|
|
163
|
+
},
|
|
164
|
+
|
|
165
|
+
async decodeResponse(raw: WireEnvelope, decodeOptions: DecodeResponseOptions = {}): Promise<JsonRpcResponse> {
|
|
166
|
+
const received = await receiveEnvelope(raw, receiveOptions)
|
|
167
|
+
if (!received.ok || !received.envelope) {
|
|
168
|
+
const reason = received.diagnostics.find((d) => d.level === 'error')?.message ?? 'unknown'
|
|
169
|
+
throw new Error(`AIP response verification failed: ${reason}`)
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Sender binding: the response must come from the expected peer.
|
|
173
|
+
if (!options.peerAgentId) {
|
|
174
|
+
throw new Error('createMcpClientCodec requires peerAgentId to bind the response sender')
|
|
175
|
+
}
|
|
176
|
+
if (received.envelope.header.sender !== options.peerAgentId) {
|
|
177
|
+
throw new Error(`AIP response sender '${received.envelope.header.sender}' is not the expected peer '${options.peerAgentId}'`)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// Correlation binding: the response must answer the request we sent.
|
|
181
|
+
if (decodeOptions.expectCorrelationId !== undefined && received.envelope.body.correlationId !== decodeOptions.expectCorrelationId) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
`AIP response correlationId '${received.envelope.body.correlationId ?? ''}' does not match request '${decodeOptions.expectCorrelationId}'`,
|
|
184
|
+
)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return JSON.parse(received.envelope.body.content) as JsonRpcResponse
|
|
188
|
+
},
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Wraps a transport send function (`stdio`, HTTP, ...) so callers issue and
|
|
194
|
+
* receive plain JSON-RPC while AIP signing, verification, and request/response
|
|
195
|
+
* correlation happen on the wire.
|
|
196
|
+
*/
|
|
197
|
+
export function wrapMcpClient(
|
|
198
|
+
send: (raw: WireEnvelope) => Promise<WireEnvelope>,
|
|
199
|
+
options: McpSecurityOptions,
|
|
200
|
+
): (request: JsonRpcRequest) => Promise<JsonRpcResponse> {
|
|
201
|
+
const codec = createMcpClientCodec(options)
|
|
202
|
+
return async (request: JsonRpcRequest): Promise<JsonRpcResponse> => {
|
|
203
|
+
const { raw, messageId } = await codec.encodeRequest(request)
|
|
204
|
+
return codec.decodeResponse(await send(raw), { expectCorrelationId: messageId })
|
|
205
|
+
}
|
|
206
|
+
}
|
package/src/otel.ts
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* otel.ts — Optional OpenTelemetry integration for 7h3 Protocol
|
|
3
|
+
*
|
|
4
|
+
* Pass your @opentelemetry/api Tracer provider via setOtelProvider.
|
|
5
|
+
* No hard dependency — works with any OTel-compatible SDK or without any OTel at all.
|
|
6
|
+
*
|
|
7
|
+
* Duck-typed interfaces: any object matching the shape will work, including
|
|
8
|
+
* the real @opentelemetry/api TracerProvider.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* import { setOtelProvider } from '@7h3/protocol/otel'
|
|
12
|
+
* import { trace } from '@opentelemetry/api'
|
|
13
|
+
*
|
|
14
|
+
* setOtelProvider(trace.getTracerProvider())
|
|
15
|
+
*
|
|
16
|
+
* Without OTel configured, withVerificationSpan and withAuditSpan call fn(null)
|
|
17
|
+
* with zero overhead.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ─── Duck-typed OTel interfaces ───────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A minimal OTel-compatible span interface.
|
|
24
|
+
* Compatible with @opentelemetry/api Span.
|
|
25
|
+
*/
|
|
26
|
+
export interface OtelSpan {
|
|
27
|
+
setAttribute(key: string, value: string | number | boolean): void
|
|
28
|
+
end(): void
|
|
29
|
+
recordException(error: Error): void
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A minimal OTel-compatible tracer interface.
|
|
34
|
+
* Compatible with @opentelemetry/api Tracer.
|
|
35
|
+
*/
|
|
36
|
+
export interface OtelTracer {
|
|
37
|
+
startSpan(name: string, attrs?: Record<string, string | number>): OtelSpan
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A minimal OTel-compatible provider interface.
|
|
42
|
+
* Compatible with @opentelemetry/api TracerProvider.
|
|
43
|
+
*/
|
|
44
|
+
export interface OtelProvider {
|
|
45
|
+
getTracer(name: string): OtelTracer
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ─── State ────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
let _provider: OtelProvider | null = null
|
|
51
|
+
|
|
52
|
+
// ─── Provider management ──────────────────────────────────────────────────────
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Register an OTel provider. Call once at application startup.
|
|
56
|
+
* Subsequent calls replace the previous provider.
|
|
57
|
+
*/
|
|
58
|
+
export function setOtelProvider(provider: OtelProvider): void {
|
|
59
|
+
_provider = provider
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Returns the active OTel tracer for '7h3/protocol', or null if no provider
|
|
64
|
+
* has been registered.
|
|
65
|
+
*/
|
|
66
|
+
export function getOtelTracer(): OtelTracer | null {
|
|
67
|
+
if (_provider === null) return null
|
|
68
|
+
try {
|
|
69
|
+
return _provider.getTracer('7h3/protocol')
|
|
70
|
+
} catch {
|
|
71
|
+
return null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── Span helpers ─────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Creates a span named '7h3.verify' if OTel is configured, calls fn(span),
|
|
79
|
+
* records any exception, and ends the span on completion.
|
|
80
|
+
*
|
|
81
|
+
* If no OTel provider is registered, calls fn(null) directly with zero overhead.
|
|
82
|
+
*/
|
|
83
|
+
export async function withVerificationSpan<T>(
|
|
84
|
+
fn: (span: OtelSpan | null) => Promise<T>,
|
|
85
|
+
attrs?: Record<string, string | number>,
|
|
86
|
+
): Promise<T> {
|
|
87
|
+
const tracer = getOtelTracer()
|
|
88
|
+
if (tracer === null) {
|
|
89
|
+
return fn(null)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const span = tracer.startSpan('7h3.verify', attrs)
|
|
93
|
+
try {
|
|
94
|
+
const result = await fn(span)
|
|
95
|
+
return result
|
|
96
|
+
} catch (err) {
|
|
97
|
+
if (err instanceof Error) {
|
|
98
|
+
span.recordException(err)
|
|
99
|
+
} else {
|
|
100
|
+
span.recordException(new Error(String(err)))
|
|
101
|
+
}
|
|
102
|
+
throw err
|
|
103
|
+
} finally {
|
|
104
|
+
span.end()
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Creates a span named '7h3.audit.write' if OTel is configured, calls fn(span),
|
|
110
|
+
* records any exception, and ends the span on completion.
|
|
111
|
+
*
|
|
112
|
+
* If no OTel provider is registered, calls fn(null) directly with zero overhead.
|
|
113
|
+
*/
|
|
114
|
+
export async function withAuditSpan<T>(
|
|
115
|
+
fn: (span: OtelSpan | null) => Promise<T>,
|
|
116
|
+
): Promise<T> {
|
|
117
|
+
const tracer = getOtelTracer()
|
|
118
|
+
if (tracer === null) {
|
|
119
|
+
return fn(null)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const span = tracer.startSpan('7h3.audit.write')
|
|
123
|
+
try {
|
|
124
|
+
const result = await fn(span)
|
|
125
|
+
return result
|
|
126
|
+
} catch (err) {
|
|
127
|
+
if (err instanceof Error) {
|
|
128
|
+
span.recordException(err)
|
|
129
|
+
} else {
|
|
130
|
+
span.recordException(new Error(String(err)))
|
|
131
|
+
}
|
|
132
|
+
throw err
|
|
133
|
+
} finally {
|
|
134
|
+
span.end()
|
|
135
|
+
}
|
|
136
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { bootstrapRuntimePolicyEnforcer, createPolicyEnforcer, isRuntimeMode } from './policyEnforcer'
|
|
3
|
+
import type { RuntimePolicy } from './runtimePolicy'
|
|
4
|
+
|
|
5
|
+
const policy: RuntimePolicy = {
|
|
6
|
+
version: '1.0',
|
|
7
|
+
name: 'gluv-runtime-policy',
|
|
8
|
+
status: 'active',
|
|
9
|
+
slo_defaults: {
|
|
10
|
+
drop_pct_max: 0.1,
|
|
11
|
+
p99_ms_max: 20,
|
|
12
|
+
},
|
|
13
|
+
tuning: {
|
|
14
|
+
batch_size: {
|
|
15
|
+
low_traffic: [4, 8],
|
|
16
|
+
medium_traffic: [8, 32],
|
|
17
|
+
high_traffic: [32, 64],
|
|
18
|
+
},
|
|
19
|
+
inflight_cap: {
|
|
20
|
+
low_traffic: [16, 64],
|
|
21
|
+
medium_traffic: [64, 256],
|
|
22
|
+
high_traffic: [256, 1024],
|
|
23
|
+
},
|
|
24
|
+
retry_backoff_ms: {
|
|
25
|
+
low_traffic: [1, 2],
|
|
26
|
+
medium_traffic: [2, 8],
|
|
27
|
+
high_traffic: [4, 16],
|
|
28
|
+
},
|
|
29
|
+
max_retry_attempts: 3,
|
|
30
|
+
retriable_status_codes: [503],
|
|
31
|
+
},
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
describe('policy enforcer', () => {
|
|
35
|
+
it('selects binary batch for high concurrency', () => {
|
|
36
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
37
|
+
expect(enforcer.selectMode({ concurrency: 100 })).toBe('http-binary-batch')
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('selects ws-batch for latency-sensitive low concurrency', () => {
|
|
41
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
42
|
+
expect(enforcer.selectMode({ concurrency: 10, latencySensitive: true })).toBe('ws-batch')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('returns tuning ranges and remediation actions', () => {
|
|
46
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
47
|
+
const decision = enforcer.tune({
|
|
48
|
+
trafficClass: 'high',
|
|
49
|
+
dropPct: 1,
|
|
50
|
+
p99Ms: 30,
|
|
51
|
+
})
|
|
52
|
+
expect(decision.batchSizeRange).toEqual([32, 64])
|
|
53
|
+
expect(decision.inflightCapRange).toEqual([256, 1024])
|
|
54
|
+
expect(decision.retryBackoffMsRange).toEqual([4, 16])
|
|
55
|
+
expect(decision.actions.length).toBeGreaterThan(0)
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('retries only retriable status and under attempt cap', () => {
|
|
59
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
60
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 503: overloaded'), attempt: 1 })).toBe(true)
|
|
61
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 400: bad request'), attempt: 1 })).toBe(false)
|
|
62
|
+
expect(enforcer.shouldRetry({ error: new Error('http status 503: overloaded'), attempt: 3 })).toBe(false)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
it('asserts invariants', () => {
|
|
66
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
67
|
+
expect(() =>
|
|
68
|
+
enforcer.assertInvariant({
|
|
69
|
+
signatureVerification: true,
|
|
70
|
+
canonicalization: true,
|
|
71
|
+
replayDefense: true,
|
|
72
|
+
ttlClockSkewEnforcement: true,
|
|
73
|
+
}),
|
|
74
|
+
).not.toThrow()
|
|
75
|
+
expect(() =>
|
|
76
|
+
enforcer.assertInvariant({
|
|
77
|
+
signatureVerification: false,
|
|
78
|
+
canonicalization: true,
|
|
79
|
+
replayDefense: true,
|
|
80
|
+
ttlClockSkewEnforcement: true,
|
|
81
|
+
}),
|
|
82
|
+
).toThrow(/Invariant violation/i)
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('checks runtime mode literals', () => {
|
|
86
|
+
expect(isRuntimeMode('http-binary-batch')).toBe(true)
|
|
87
|
+
expect(isRuntimeMode('invalid-mode')).toBe(false)
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
it('bootstraps policy + enforcer in one call', async () => {
|
|
91
|
+
const bootstrapped = await bootstrapRuntimePolicyEnforcer({
|
|
92
|
+
path: '/virtual/AI_RUNTIME_POLICY.json',
|
|
93
|
+
readTextFile: async () => JSON.stringify(policy),
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
expect(bootstrapped.policy.name).toBe('gluv-runtime-policy')
|
|
97
|
+
expect(bootstrapped.enforcer.selectMode({ concurrency: 100 })).toBe('http-binary-batch')
|
|
98
|
+
})
|
|
99
|
+
})
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import { loadRuntimePolicy, type LoadRuntimePolicyOptions, type RuntimePolicy } from './runtimePolicy'
|
|
2
|
+
|
|
3
|
+
export type RuntimeMode =
|
|
4
|
+
| 'http'
|
|
5
|
+
| 'ws'
|
|
6
|
+
| 'http-binary'
|
|
7
|
+
| 'http-batch'
|
|
8
|
+
| 'http-binary-batch'
|
|
9
|
+
| 'ws-batch'
|
|
10
|
+
| 'ws-binary'
|
|
11
|
+
| 'ws-binary-batch'
|
|
12
|
+
|
|
13
|
+
export interface ModeSelectionInput {
|
|
14
|
+
concurrency: number
|
|
15
|
+
latencySensitive?: boolean
|
|
16
|
+
compatibilityFirst?: boolean
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface TuningInput {
|
|
20
|
+
trafficClass: 'low' | 'medium' | 'high'
|
|
21
|
+
p99Ms?: number
|
|
22
|
+
dropPct?: number
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface TuningDecision {
|
|
26
|
+
batchSizeRange: [number, number]
|
|
27
|
+
inflightCapRange: [number, number]
|
|
28
|
+
retryBackoffMsRange: [number, number]
|
|
29
|
+
actions: string[]
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface RetryInput {
|
|
33
|
+
error: unknown
|
|
34
|
+
attempt: number
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface InvariantConfig {
|
|
38
|
+
signatureVerification: boolean
|
|
39
|
+
canonicalization: boolean
|
|
40
|
+
replayDefense: boolean
|
|
41
|
+
ttlClockSkewEnforcement: boolean
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function asNumberPair(value: unknown, label: string): [number, number] {
|
|
45
|
+
if (!Array.isArray(value) || value.length !== 2) {
|
|
46
|
+
throw new Error(`Invalid policy tuning range '${label}': expected [min,max]`)
|
|
47
|
+
}
|
|
48
|
+
const min = Number(value[0])
|
|
49
|
+
const max = Number(value[1])
|
|
50
|
+
if (!Number.isFinite(min) || !Number.isFinite(max) || min <= 0 || max < min) {
|
|
51
|
+
throw new Error(`Invalid policy tuning range '${label}': expected positive ascending numbers`)
|
|
52
|
+
}
|
|
53
|
+
return [Math.floor(min), Math.floor(max)]
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function includeHttpLiteral(mode: string): mode is RuntimeMode {
|
|
57
|
+
return (
|
|
58
|
+
mode === 'http' ||
|
|
59
|
+
mode === 'ws' ||
|
|
60
|
+
mode === 'http-binary' ||
|
|
61
|
+
mode === 'http-batch' ||
|
|
62
|
+
mode === 'http-binary-batch' ||
|
|
63
|
+
mode === 'ws-batch' ||
|
|
64
|
+
mode === 'ws-binary' ||
|
|
65
|
+
mode === 'ws-binary-batch'
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function createPolicyEnforcer(policy: RuntimePolicy) {
|
|
70
|
+
const dropPctMax = Number((policy.slo_defaults ?? {}).drop_pct_max ?? 0.1)
|
|
71
|
+
const p99MsMax = Number((policy.slo_defaults ?? {}).p99_ms_max ?? 20)
|
|
72
|
+
const maxRetryAttempts = Number((policy.tuning ?? {}).max_retry_attempts ?? 3)
|
|
73
|
+
const retriableStatusCodes = Array.isArray((policy.tuning ?? {}).retriable_status_codes)
|
|
74
|
+
? ((policy.tuning ?? {}).retriable_status_codes as unknown[])
|
|
75
|
+
.map((value) => Number(value))
|
|
76
|
+
.filter((value) => Number.isFinite(value))
|
|
77
|
+
: [503]
|
|
78
|
+
|
|
79
|
+
const tuning = policy.tuning ?? {}
|
|
80
|
+
const batchSize = tuning.batch_size as Record<string, unknown>
|
|
81
|
+
const inflightCap = tuning.inflight_cap as Record<string, unknown>
|
|
82
|
+
const retryBackoff = tuning.retry_backoff_ms as Record<string, unknown>
|
|
83
|
+
|
|
84
|
+
const getRanges = (trafficClass: 'low' | 'medium' | 'high') => ({
|
|
85
|
+
batchSizeRange: asNumberPair(batchSize?.[`${trafficClass}_traffic`], `batch_size.${trafficClass}_traffic`),
|
|
86
|
+
inflightCapRange: asNumberPair(inflightCap?.[`${trafficClass}_traffic`], `inflight_cap.${trafficClass}_traffic`),
|
|
87
|
+
retryBackoffMsRange: asNumberPair(retryBackoff?.[`${trafficClass}_traffic`], `retry_backoff_ms.${trafficClass}_traffic`),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function selectMode(input: ModeSelectionInput): RuntimeMode {
|
|
91
|
+
if (input.concurrency >= 100) return 'http-binary-batch'
|
|
92
|
+
if (input.latencySensitive) return 'ws-batch'
|
|
93
|
+
if (input.compatibilityFirst) return 'http'
|
|
94
|
+
return 'ws-batch'
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function tune(input: TuningInput): TuningDecision {
|
|
98
|
+
const ranges = getRanges(input.trafficClass)
|
|
99
|
+
const actions: string[] = []
|
|
100
|
+
|
|
101
|
+
if (typeof input.dropPct === 'number' && input.dropPct > dropPctMax) {
|
|
102
|
+
actions.push('reduce inflight cap')
|
|
103
|
+
actions.push('reduce batch size')
|
|
104
|
+
actions.push('increase retry backoff')
|
|
105
|
+
actions.push('switch to binary-batch mode')
|
|
106
|
+
}
|
|
107
|
+
if (typeof input.p99Ms === 'number' && input.p99Ms > p99MsMax) {
|
|
108
|
+
actions.push('reduce batch size')
|
|
109
|
+
actions.push('reduce inflight cap')
|
|
110
|
+
actions.push('re-run adaptive benchmark')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return {
|
|
114
|
+
...ranges,
|
|
115
|
+
actions,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function shouldRetry(input: RetryInput): boolean {
|
|
120
|
+
if (input.attempt >= maxRetryAttempts) return false
|
|
121
|
+
const message = input.error instanceof Error ? input.error.message : String(input.error)
|
|
122
|
+
const statusFromMessage = /http status\s+(\d+)/i.exec(message)
|
|
123
|
+
const statusCode = statusFromMessage ? Number(statusFromMessage[1]) : NaN
|
|
124
|
+
return Number.isFinite(statusCode) && retriableStatusCodes.includes(statusCode)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function assertInvariant(config: InvariantConfig): void {
|
|
128
|
+
const failures: string[] = []
|
|
129
|
+
if (!config.signatureVerification) failures.push('signatureVerification=false')
|
|
130
|
+
if (!config.canonicalization) failures.push('canonicalization=false')
|
|
131
|
+
if (!config.replayDefense) failures.push('replayDefense=false')
|
|
132
|
+
if (!config.ttlClockSkewEnforcement) failures.push('ttlClockSkewEnforcement=false')
|
|
133
|
+
if (failures.length > 0) {
|
|
134
|
+
throw new Error(`Invariant violation: ${failures.join(', ')}`)
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
selectMode,
|
|
140
|
+
tune,
|
|
141
|
+
shouldRetry,
|
|
142
|
+
assertInvariant,
|
|
143
|
+
metadata: {
|
|
144
|
+
policyName: policy.name,
|
|
145
|
+
policyVersion: policy.version,
|
|
146
|
+
policyStatus: policy.status,
|
|
147
|
+
enforcedSlo: {
|
|
148
|
+
dropPctMax,
|
|
149
|
+
p99MsMax,
|
|
150
|
+
},
|
|
151
|
+
},
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export type PolicyEnforcer = ReturnType<typeof createPolicyEnforcer>
|
|
156
|
+
|
|
157
|
+
export type BootstrapRuntimePolicyEnforcerOptions = LoadRuntimePolicyOptions
|
|
158
|
+
|
|
159
|
+
export async function bootstrapRuntimePolicyEnforcer(
|
|
160
|
+
options: BootstrapRuntimePolicyEnforcerOptions = {},
|
|
161
|
+
): Promise<{ policy: RuntimePolicy; enforcer: PolicyEnforcer }> {
|
|
162
|
+
const policy = await loadRuntimePolicy(options)
|
|
163
|
+
const enforcer = createPolicyEnforcer(policy)
|
|
164
|
+
return { policy, enforcer }
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isRuntimeMode(value: string): value is RuntimeMode {
|
|
168
|
+
return includeHttpLiteral(value)
|
|
169
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { recommendPolicyAdjustments } from './policyTelemetryFeedback'
|
|
3
|
+
|
|
4
|
+
describe('policy telemetry feedback', () => {
|
|
5
|
+
it('returns critical recommendation for severe drop/latency', () => {
|
|
6
|
+
const rec = recommendPolicyAdjustments({
|
|
7
|
+
mode: 'http',
|
|
8
|
+
p99Ms: 120,
|
|
9
|
+
dropPct: 5,
|
|
10
|
+
concurrency: 200,
|
|
11
|
+
})
|
|
12
|
+
expect(rec.severity).toBe('critical')
|
|
13
|
+
expect(rec.actions.length).toBeGreaterThan(0)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it('returns normal when metrics are healthy', () => {
|
|
17
|
+
const rec = recommendPolicyAdjustments({
|
|
18
|
+
mode: 'http-binary-batch',
|
|
19
|
+
p99Ms: 8,
|
|
20
|
+
dropPct: 0,
|
|
21
|
+
concurrency: 100,
|
|
22
|
+
})
|
|
23
|
+
expect(rec.severity).toBe('normal')
|
|
24
|
+
})
|
|
25
|
+
})
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export interface TelemetryFeedbackInput {
|
|
2
|
+
mode: string
|
|
3
|
+
p99Ms: number
|
|
4
|
+
dropPct: number
|
|
5
|
+
concurrency: number
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export interface TelemetryFeedbackRecommendation {
|
|
9
|
+
severity: 'normal' | 'warn' | 'critical'
|
|
10
|
+
actions: string[]
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function recommendPolicyAdjustments(input: TelemetryFeedbackInput): TelemetryFeedbackRecommendation {
|
|
14
|
+
const actions: string[] = []
|
|
15
|
+
let severity: TelemetryFeedbackRecommendation['severity'] = 'normal'
|
|
16
|
+
|
|
17
|
+
if (input.dropPct > 0.1) {
|
|
18
|
+
actions.push('reduce inflight cap')
|
|
19
|
+
actions.push('reduce batch size')
|
|
20
|
+
actions.push('increase retry backoff')
|
|
21
|
+
if (input.mode === 'http' && input.concurrency >= 100) {
|
|
22
|
+
actions.push('switch to http-binary-batch')
|
|
23
|
+
}
|
|
24
|
+
severity = input.dropPct > 2 ? 'critical' : 'warn'
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
if (input.p99Ms > 20) {
|
|
28
|
+
actions.push('reduce batch size')
|
|
29
|
+
actions.push('run adaptive benchmark and re-baseline')
|
|
30
|
+
if (severity === 'normal') severity = 'warn'
|
|
31
|
+
if (input.p99Ms > 100) severity = 'critical'
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
severity,
|
|
36
|
+
actions,
|
|
37
|
+
}
|
|
38
|
+
}
|