Building Port Adapters
Port adapters let non-MCP applications route through the PRECINCT gateway's full 13-layer security stack (identity, policy, DLP, audit, rate limiting) without reimplementing any of it. This guide walks through the complete adapter architecture using the reference adapter (from the case study) as a worked example.
Read the Integration Guide first to understand SPIFFE identity registration, tool setup, and OPA policy configuration. This guide assumes your workload already has a SPIRE entry and can reach the gateway.
When You Need an Adapter
Not every integration requires a port adapter. PRECINCT offers three integration paths, each appropriate for a different situation. Use the simplest one that meets your needs.
1. MCP-Native
Your application already speaks MCP (Model Context Protocol). Point it at the PRECINCT gateway as an MCP proxy. All 13 middleware layers apply automatically. No code needed.
Use when: the client supports MCP natively (e.g., Claude Desktop, Cursor, any MCP-compliant agent).
2. SDK
Use the Python or Go SDK to call the gateway's JSON-RPC API directly from your application code. The SDK handles SPIFFE identity injection, error mapping, and retry logic.
Use when: you control the application code and want to add individual tool calls or model requests behind the gateway.
3. Port Adapter
Write a port adapter when your application has its own HTTP/WebSocket/webhook protocol that differs from MCP. The adapter translates between your protocol and the gateway's internal plane APIs.
Use when: the application has a pre-existing wire format (e.g., OpenAI Responses API, custom WebSocket RPC, platform webhooks).
Architecture Overview
A port adapter sits between the external application and the gateway's internal
processing pipeline. The gateway's HTTP handler tries each registered adapter in
order; the first adapter whose TryServeHTTP returns true
claims the request. Unclaimed requests fall through to the default MCP handler.
The adapter never touches the middleware chain directly. Instead, it calls
methods on the PortGatewayServices facade to build plane requests,
evaluate policy, execute egress, write audit logs, and handle errors. The facade
delegates to the same internal functions that the MCP path uses, so every
adapter gets identical security enforcement.
Request Dispatch Flow
The PortAdapter Interface
The contract for a port adapter is deliberately minimal. It consists of two methods
defined in POC/internal/gateway/port.go:
// PortAdapter is the extension point for third-party agent integrations.
// Each adapter claims a set of URL paths and handles matching requests.
type PortAdapter interface {
// Name returns a short, unique identifier for the port.
Name() string
// TryServeHTTP inspects the request and, if the path belongs to this port,
// serves it and returns true. Returns false if the request is not handled.
TryServeHTTP(w http.ResponseWriter, r *http.Request) bool
}
Contract Rules
Name()must return a unique string identifier. It is used in logging and metrics. The reference adapter returns"openclaw".TryServeHTTPmust inspectr.URL.Pathand returnfalseif the path does not belong to this adapter. If the path matches, handle the full request and returntrue. Never write towwhen returningfalse.- Use a compile-time check to verify your struct satisfies the interface:
var _ gateway.PortAdapter = (*Adapter)(nil)
Reference Adapter Example: TryServeHTTP Dispatch
func (a *Adapter) TryServeHTTP(w http.ResponseWriter, r *http.Request) bool {
if r == nil || r.URL == nil {
return false
}
// WS path
if r.URL.Path == openClawWSPath {
a.handleWSEntry(w, r)
return true
}
// Webhook paths
if strings.HasPrefix(r.URL.Path, webhookBasePath) {
a.handleWebhook(w, r)
return true
}
// HTTP paths
switch r.URL.Path {
case protocol.ResponsesPath:
a.handleResponses(w, r)
return true
case protocol.ToolsInvokePath:
a.handleToolsInvoke(w, r)
return true
default:
return false
}
}
The dispatch is a simple path-matching switch. Each matched path delegates to a
dedicated handler method. Unrecognized paths return false to let the
gateway try the next adapter or fall through to the default MCP handler.
The PortGatewayServices Facade
The PortGatewayServices interface is the narrow API surface that
adapters use to access gateway internals. It is defined in
POC/internal/gateway/port.go and implemented by the
*Gateway struct in port_services.go.
Model Plane
// Build a PlaneRequestV2 from an HTTP request and OpenAI-compatible payload.
BuildModelPlaneRequest(r *http.Request, payload map[string]any) PlaneRequestV2
// Evaluate model-plane policy. Returns (decision, reason, httpStatus, metadata).
EvaluateModelPlaneDecision(r *http.Request, req PlaneRequestV2) (Decision, ReasonCode, int, map[string]any)
// Execute model egress: proxy to the upstream LLM provider.
ExecuteModelEgress(ctx context.Context, attrs map[string]any,
payload map[string]any, authHeader string) (*ModelEgressResult, error)
// Check if policy-intent projection is enabled.
ShouldApplyPolicyIntentProjection() bool
Tool Plane
// Evaluate a tool request against the tool-plane policy engine.
EvaluateToolRequest(req PlaneRequestV2) ToolPlaneEvalResult
Messaging Egress
// Execute messaging egress to a platform (WhatsApp, Telegram, Slack).
ExecuteMessagingEgress(ctx context.Context, attrs map[string]string,
payload []byte, authHeader string) (*MessagingEgressResult, error)
// Redeem a SPIKE secret token. Used for per-message token resolution
// in WebSocket frames that bypass the HTTP middleware chain.
RedeemSPIKESecret(ctx context.Context, tokenStr string) (string, error)
Audit and Logging
// Log a plane decision with full correlation metadata.
LogPlaneDecision(r *http.Request, decision PlaneDecisionV2, httpStatus int)
// Write a structured audit event.
AuditLog(event middleware.AuditEvent)
Error Writing
// Write a structured v24 gateway error response.
WriteGatewayError(w http.ResponseWriter, r *http.Request,
httpCode int, errorCode string, message string,
middlewareName string, reason ReasonCode, details map[string]any)
Approval Capabilities
// Validate and consume a one-time step-up approval token.
ValidateAndConsumeApproval(token string, scope middleware.ApprovalScope) (
*middleware.ApprovalCapabilityClaims, error)
// Check if the approval capabilities service is configured.
HasApprovalService() bool
Connector Conformance
// Validate a connector's identity and signature via CCA runtime check.
// Returns (allowed, reason).
ValidateConnector(connectorID, signature string) (bool, string)
Exported Helpers
The port.go file also exports several utility functions for use by
adapters:
// Correlation IDs from request context.
GetDecisionCorrelationIDs(r *http.Request, env RunEnvelope) (traceID, decisionID string)
// Merge metadata maps (non-destructive).
MergeMetadata(base, extra map[string]any) map[string]any
// Safe string extraction with fallback.
GetStringAttr(attrs map[string]any, key, fallback string) string
DefaultString(v, fallback string) string
// Policy-intent projection helpers.
BuildModelPolicyIntentProjection(attrs map[string]any, envelope RunEnvelope) string
PrependSystemPolicyIntentMessage(payload map[string]any, projection string) bool
ProjectionHeaderValue(enabled, applied bool) string
// Header and status helpers.
CopyHeaderIfPresent(dst, src http.Header, key string)
StatusForToolReason(reason ReasonCode) int
Protocol Layer Design
A well-structured adapter separates protocol types (wire-format parsing
and serialization) from gateway interaction (policy evaluation, egress
execution). The reference adapter places all protocol types and parsing functions
in a dedicated protocol/ subpackage.
Keeping protocol types in their own package lets you unit-test parsing logic in isolation (no gateway dependency) and swap wire formats without changing the adapter's gateway interaction code.
Key Protocol Types
From POC/ports/openclaw/protocol/http_adapter.go:
const (
ResponsesPath = "/v1/responses"
ToolsInvokePath = "/tools/invoke"
)
type ResponsesRequest struct {
Model string
Messages []ResponseMessage
Instructions string
Stream bool
User string
MaxOutputTokens int
}
type ResponseMessage struct {
Role string
Content string
}
type ToolsInvokeRequest struct {
Tool string
Action string
Args map[string]any
SessionKey string
DryRun bool
ApprovalToken string
}
type ToolPolicyTarget struct {
Action string
Resource string
CapabilityID string
Adapter string
}
Protocol Functions
The protocol package also provides pure parsing and translation functions that contain no gateway dependencies:
// Parse raw JSON into a typed ResponsesRequest.
func ParseResponsesRequest(raw []byte) (ResponsesRequest, error)
// Convert ResponsesRequest messages into OpenAI-compatible format.
func BuildOpenAIMessages(req ResponsesRequest) []map[string]any
// Parse raw JSON into a typed ToolsInvokeRequest.
func ParseToolsInvokeRequest(raw []byte) (ToolsInvokeRequest, error)
// Map a tool invocation to its policy target (action, resource, capability).
func ResolveToolPolicyTarget(req ToolsInvokeRequest) ToolPolicyTarget
// Check if a tool is on the dangerous-tool blocklist for HTTP.
func IsDangerousHTTPTool(tool string) bool
Dangerous Tool Blocklist
The protocol layer defines a set of tools that are blocked on the HTTP wrapper path for safety. These tools are only permitted through the WebSocket control plane where session-level authorization applies:
var dangerousHTTPToolSet = map[string]struct{}{
"sessions_spawn": {},
"sessions_send": {},
"gateway": {},
"whatsapp_login": {},
"exec": {},
"shell": {},
"bash": {},
}
Building an HTTP Handler
HTTP handlers are the most common adapter entry point. The reference adapter
exposes two HTTP routes: /v1/responses for the model plane and
/tools/invoke for the tool plane.
Model Plane: /v1/responses
The model-plane handler follows a 10-step flow. Each step calls a
PortGatewayServices method rather than reimplementing the logic:
| Step | Action | Facade Method |
|---|---|---|
| 1 | Validate HTTP method (POST only) | WriteGatewayError |
| 2 | Read and parse request body | protocol.ParseResponsesRequest |
| 3 | Reject stream=true (unsupported on secure wrapper) | WriteGatewayError |
| 4 | Translate to OpenAI-compatible payload | protocol.BuildOpenAIMessages |
| 5 | Build the model-plane request | BuildModelPlaneRequest |
| 6 | Evaluate model-plane policy (OPA, DLP, identity) | EvaluateModelPlaneDecision |
| 7 | Apply policy-intent projection (if enabled) | BuildModelPolicyIntentProjection / PrependSystemPolicyIntentMessage |
| 8 | Execute egress to upstream LLM provider | ExecuteModelEgress |
| 9 | Log the plane decision with correlation IDs | LogPlaneDecision |
| 10 | Translate response to application format, set correlation headers | (adapter-specific) |
Correlation Headers
Every response from the adapter must include PRECINCT correlation headers so that clients and auditors can trace decisions back to specific policy evaluations:
w.Header().Set("X-Precinct-Decision-ID", decisionID)
w.Header().Set("X-Precinct-Trace-ID", traceID)
w.Header().Set("X-Precinct-Reason-Code", string(finalReason))
w.Header().Set("X-Precinct-Provider-Used", egress.ProviderUsed)
w.Header().Set("X-Precinct-Policy-Intent-Projection",
gateway.ProjectionHeaderValue(projectionEnabled, projectionApplied))
Tool Plane: /tools/invoke with Step-Up Approval
The tool-plane handler adds step-up approval support. When the policy engine
returns RequireStepUp: true, the adapter checks for an approval
token in the request and validates it:
func (a *Adapter) evaluateToolRequest(req gateway.PlaneRequestV2) gateway.ToolPlaneEvalResult {
eval := a.gw.EvaluateToolRequest(req)
if !eval.RequireStepUp {
return eval
}
// Look for approval token in multiple field names.
token := strings.TrimSpace(
gateway.GetStringAttr(attrs, "approval_capability_token", ""))
if token == "" {
token = strings.TrimSpace(
gateway.GetStringAttr(attrs, "step_up_token", ""))
}
if token == "" {
eval.Metadata = gateway.MergeMetadata(eval.Metadata,
map[string]any{"step_up_state": "missing_token"})
return eval
}
if !a.gw.HasApprovalService() {
eval.Metadata = gateway.MergeMetadata(eval.Metadata,
map[string]any{"step_up_state": "approval_service_unavailable"})
return eval
}
_, err := a.gw.ValidateAndConsumeApproval(token, middleware.ApprovalScope{
Action: strings.TrimSpace(req.Policy.Action),
Resource: strings.TrimSpace(req.Policy.Resource),
ActorSPIFFEID: req.Envelope.ActorSPIFFEID,
SessionID: req.Envelope.SessionID,
})
if err != nil {
eval.Metadata = gateway.MergeMetadata(eval.Metadata,
map[string]any{"step_up_state": "invalid_or_expired_token"})
return eval
}
eval.RequireStepUp = false
eval.Decision = gateway.DecisionAllow
eval.Reason = gateway.ReasonToolAllow
eval.HTTPStatus = gateway.StatusForToolReason(gateway.ReasonToolAllow)
eval.Metadata = gateway.MergeMetadata(eval.Metadata,
map[string]any{"step_up_state": "approved_token_consumed"})
return eval
}
Building a WebSocket Handler
WebSocket adapters are more complex than HTTP because they maintain session
state across multiple frames. The reference adapter implements a full WS RPC
protocol at /openclaw/ws.
Connection Lifecycle
Frame Schema
The WS protocol uses a simple request/response frame format:
type wsRequestFrame struct {
Type string `json:"type"` // Always "req"
ID string `json:"id"` // Client-generated correlation ID
Method string `json:"method"` // RPC method name
Params map[string]any `json:"params,omitempty"`
}
type wsResponseFrame struct {
Type string `json:"type"` // Always "res"
ID string `json:"id"` // Echoes the request ID
OK bool `json:"ok"`
Payload map[string]any `json:"payload,omitempty"`
Error *wsErrorShape `json:"error,omitempty"`
}
Connect Handshake
The first frame after the WebSocket upgrade must be a connect
request. The adapter validates:
- Role: must be
"operator"or"node". - SPIFFE identity: must be present in the request context from the upstream mTLS handshake.
- Device identity: required for the
"node"role, including anauth.tokenfield. - Scopes: parsed from the request and stored in the session for per-method authorization.
Role-Based Method Access
After connection, every method call is checked against the session's role and
scopes via the wsAllowed function:
func wsAllowed(session wsSession, method string) bool {
switch method {
case "health":
return true
case "devices.list":
if session.Role == "operator" { return true }
_, ok := session.Scopes["devices:read"]
return ok
case "devices.ping":
if session.Role == "operator" { return true }
_, ok := session.Scopes["devices:write"]
return ok
case "message.send":
if session.Role == "operator" { return true }
_, ok := session.Scopes["tools.messaging.send"]
return ok
case "message.status":
if session.Role == "operator" { return true }
_, ok := session.Scopes["tools.messaging.status"]
return ok
case "connector.register":
return session.Role == "operator"
default:
return false
}
}
WebSocket connections outlive the original HTTP upgrade request. The reference
adapter uses context.Background() for egress HTTP calls instead
of req.Context(), because the upgrade request context may be
canceled by the server while the WS connection is still active.
Per-Message Audit
Every WS frame, whether allowed or denied, is logged through the
AuditLog facade method with correlation IDs, the session role,
device ID, and the method name:
a.gw.AuditLog(middleware.AuditEvent{
SessionID: sessionID,
DecisionID: decisionID,
TraceID: traceID,
SPIFFEID: middleware.GetSPIFFEID(req.Context()),
Action: "openclaw.ws." + method,
Result: result,
Method: http.MethodGet,
Path: openClawWSPath,
StatusCode: statusCode,
})
Building a Webhook Receiver
Webhook receivers handle inbound platform callbacks (WhatsApp, Telegram, Slack)
and route them through the gateway's ingress security stack. The reference adapter
registers webhook routes under /openclaw/webhooks/.
Platform Extraction
The adapter determines the messaging platform from the URL path:
const (
webhookBasePath = "/openclaw/webhooks"
whatsappWebhookPath = webhookBasePath + "/whatsapp"
telegramWebhookPath = webhookBasePath + "/telegram"
slackWebhookPath = webhookBasePath + "/slack"
)
func platformFromPath(path string) string {
switch path {
case whatsappWebhookPath: return "whatsapp"
case telegramWebhookPath: return "telegram"
case slackWebhookPath: return "slack"
default: return ""
}
}
Connector Signature Computation
Before processing any webhook payload, the adapter computes a deterministic connector signature and validates it against the Connector Conformance Authority (CCA):
func computeWebhookConnectorSig(connectorID, platform string) string {
canon := map[string]any{
"connector_id": connectorID,
"connector_type": "webhook",
"platform": platform,
}
data, _ := json.Marshal(canon)
digest := sha256.Sum256(data)
return hex.EncodeToString(digest[:])
}
// In the handler:
connectorID := extractConnectorID(payload, platform)
connectorSig := computeWebhookConnectorSig(connectorID, platform)
allowed, reason := a.gw.ValidateConnector(connectorID, connectorSig)
if !allowed {
// Reject the webhook
}
Internal Loopback
After validation, the webhook receiver does not process the message
directly. Instead, it POSTs a PlaneRequestV2 to the gateway's own
/v1/ingress/submit endpoint via internal HTTP loopback. This ensures
the inbound message passes through the full ingress middleware chain (DLP scanning,
replay detection, freshness checks, etc.):
func (a *Adapter) postIngressLoopback(req gateway.PlaneRequestV2) (*http.Response, error) {
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal ingress request: %w", err)
}
loopbackURL := a.internalGatewayURL + "/v1/ingress/submit"
httpReq, err := http.NewRequest(http.MethodPost, loopbackURL, bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("build loopback request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
// Carry the webhook actor identity for the ingress middleware chain.
httpReq.Header.Set("X-SPIFFE-ID", req.Envelope.ActorSPIFFEID)
client := &http.Client{Timeout: 10 * time.Second}
return client.Do(httpReq)
}
The loopback pattern reuses the gateway's existing ingress middleware chain
instead of duplicating it. This means the webhook gets the same DLP scanning,
schema validation, replay detection, and audit logging that any other ingress
message receives. The internal gateway URL is configurable via the
GATEWAY_INTERNAL_URL environment variable (default:
http://localhost:8443).
Late-Binding Secrets
WebSocket frames bypass the HTTP middleware chain, which means they do not automatically benefit from the SPIKE token substitution middleware (layer 13). Adapters must handle SPIKE token resolution explicitly for per-message secrets.
$SPIKE{...} References
When a WS frame includes an auth_ref parameter, the adapter checks
whether the value is a SPIKE token reference. If so, it calls
RedeemSPIKESecret to resolve the actual secret value at call time:
func (a *Adapter) resolveSPIKERef(ctx context.Context,
authRef string, fallbackAuth string) (string, error) {
if strings.TrimSpace(authRef) == "" {
return fallbackAuth, nil
}
if strings.HasPrefix(authRef, "$SPIKE{") {
resolved, err := a.gw.RedeemSPIKESecret(ctx, authRef)
if err != nil {
return "", fmt.Errorf("SPIKE token resolution failed: %w", err)
}
return "Bearer " + resolved, nil
}
// Non-SPIKE value: use as Bearer token directly.
return "Bearer " + authRef, nil
}
Bearer Fallback Pattern
The resolution follows a three-tier fallback:
- If
auth_refis a$SPIKE{...}token, redeem it via SPIKE Nexus and use the resolved value as a Bearer token. - If
auth_refis a plain string, use it directly as a Bearer token. - If
auth_refis empty, fall back to theAuthorizationheader from the original WebSocket upgrade request.
This pattern ensures that secrets are never stored in the adapter's memory longer than the duration of a single egress call. The SPIKE redeemer in the gateway contacts SPIKE Nexus over mTLS to resolve the token and returns only the secret value.
Testing Your Adapter
Walking Skeleton
Before building a full adapter, create a walking skeleton that validates your
plane request shapes against the gateway's policy engine. The reference adapter
includes a walking skeleton in
POC/ports/openclaw/walking_skeleton/ that provides typed builders
for each plane without any gateway dependency:
// walking_skeleton/adapter.go
type EnvelopeParams struct {
RunID string
SessionID string
SPIFFEID string
Plane string
}
type ToolExecuteParams struct {
EnvelopeParams
Resource string
Attributes map[string]any
}
func BuildToolExecuteRequest(params ToolExecuteParams) map[string]any {
resource := params.Resource
if resource == "" {
resource = "tool/read"
}
return buildRequest(
params.EnvelopeParams,
"tool.execute",
resource,
params.Attributes,
)
}
The walking skeleton tests verify that request shapes are correct before you start wiring the adapter to real gateway services:
// walking_skeleton/adapter_test.go
func TestBuildToolExecuteRequestSupportsResourceOverride(t *testing.T) {
req := BuildToolExecuteRequest(ToolExecuteParams{
EnvelopeParams: EnvelopeParams{
RunID: "openclaw-it-tool-deny",
SessionID: "openclaw-it-session-1",
SPIFFEID: "spiffe://poc.local/agents/mcp-client/openclaw/dev",
Plane: "tool",
},
Resource: "tool/write",
Attributes: map[string]any{
"capability_id": "tool.unapproved.mcp",
"tool_name": "write",
},
})
policy := mapField(t, req["policy"])
if policy["resource"] != "tool/write" {
t.Fatalf("expected tool/write resource, got %v", policy["resource"])
}
}
Unit Tests with Mock PortGatewayServices
For unit testing the adapter itself, create a mock that implements the
PortGatewayServices interface. The interface has a deliberately
narrow surface (12 methods), making mocks straightforward:
type mockGatewayServices struct {
buildModelReq func(r *http.Request, payload map[string]any) gateway.PlaneRequestV2
evaluateModelDec func(r *http.Request, req gateway.PlaneRequestV2) (
gateway.Decision, gateway.ReasonCode, int, map[string]any)
executeModelEg func(ctx context.Context, attrs map[string]any,
payload map[string]any, auth string) (*gateway.ModelEgressResult, error)
evaluateToolReq func(req gateway.PlaneRequestV2) gateway.ToolPlaneEvalResult
// ... remaining methods
}
func (m *mockGatewayServices) BuildModelPlaneRequest(
r *http.Request, payload map[string]any) gateway.PlaneRequestV2 {
if m.buildModelReq != nil {
return m.buildModelReq(r, payload)
}
return gateway.PlaneRequestV2{}
}
// ... implement remaining interface methods
Integration Tests with Real Stack
Integration tests should run against a real gateway instance with SPIRE, OPA,
and SPIKE configured. Use docker compose or a Kubernetes cluster
to stand up the full stack, then send requests to your adapter's routes:
# Start the full stack
make demo-compose
# Test the model plane
curl -s -X POST http://localhost:9090/v1/responses \
-H "Content-Type: application/json" \
-H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
-d '{"model":"gpt-4o-mini","input":"Hello, world"}'
# Test the tool plane
curl -s -X POST http://localhost:9090/tools/invoke \
-H "Content-Type: application/json" \
-H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
-d '{"tool":"tavily_search","args":{"query":"test"}}'
# Verify correlation headers in response
curl -s -D - -X POST http://localhost:9090/v1/responses \
-H "Content-Type: application/json" \
-H "X-SPIFFE-ID: spiffe://poc.local/agents/mcp-client/openclaw/dev" \
-d '{"model":"gpt-4o-mini","input":"test"}' 2>&1 | grep X-Precinct
Registration
Registering an adapter is a single line in cmd/gateway/main.go.
After creating the gateway instance and before starting the HTTP server, call
RegisterPort:
// POC/cmd/gateway/main.go, line 96
gw.RegisterPort(openclaw.NewAdapter(gw))
The RegisterPort method appends the adapter to the gateway's
dispatch chain:
// POC/internal/gateway/port_services.go
func (g *Gateway) RegisterPort(adapter PortAdapter) {
g.portAdapters = append(g.portAdapters, adapter)
}
Multiple adapters can be registered. The gateway tries them in registration
order; the first one whose TryServeHTTP returns true
wins the request.
Adapter Constructor Pattern
Adapters receive the gateway as a PortGatewayServices interface,
not a concrete *Gateway pointer. This keeps the coupling narrow
and testable:
type Adapter struct {
gw gateway.PortGatewayServices
internalGatewayURL string
}
func NewAdapter(gw gateway.PortGatewayServices) *Adapter {
gwURL := os.Getenv("GATEWAY_INTERNAL_URL")
if gwURL == "" {
gwURL = "http://localhost:8443"
}
return &Adapter{gw: gw, internalGatewayURL: gwURL}
}
// For testing with an explicit loopback URL:
func NewAdapterWithLoopbackURL(
gw gateway.PortGatewayServices, loopbackURL string) *Adapter {
return &Adapter{gw: gw, internalGatewayURL: loopbackURL}
}
Checklist
Use this checklist to ensure your adapter is complete and ready to ship.
| Item | Description | File |
|---|---|---|
| Interface compliance | Struct implements gateway.PortAdapter with compile-time check |
adapter.go |
| Name() unique | Returns a short, unique identifier not used by other adapters | adapter.go |
| TryServeHTTP dispatch | Path matching returns false for unrecognized paths; never writes to w when returning false |
adapter.go |
| Protocol types separated | Wire-format types and parsing in a protocol/ subpackage |
protocol/ |
| Model plane handler | Calls BuildModelPlaneRequest, EvaluateModelPlaneDecision, ExecuteModelEgress, LogPlaneDecision |
http_handler.go |
| Tool plane handler | Evaluates tool policy, handles step-up approval, logs decisions | http_handler.go |
| Correlation headers | Every response sets X-Precinct-Decision-ID, X-Precinct-Trace-ID, X-Precinct-Reason-Code |
http_handler.go |
| Error responses | Uses WriteGatewayError for framework errors; includes correlation headers in custom error responses |
http_handler.go |
| WS handler (if needed) | Connect handshake, role/scope validation, per-message audit, context.Background() for egress |
ws_handler.go |
| Webhook receiver (if needed) | Connector signature validation, internal loopback to /v1/ingress/submit |
webhook_receiver.go |
| SPIKE token resolution | $SPIKE{...} references resolved via RedeemSPIKESecret with Bearer fallback |
messaging_egress.go |
| Walking skeleton tests | Request-shape builders with unit tests, no gateway dependency | walking_skeleton/ |
| Unit tests | Mock PortGatewayServices, test all handler paths (allow, deny, error) |
*_test.go |
| Integration tests | Real gateway + SPIRE + OPA stack, test full request lifecycle | e2e/ |
| Registration | One-line gw.RegisterPort(yourpkg.NewAdapter(gw)) in cmd/gateway/main.go |
main.go |