Develop a policy WASM module
Policy WASM modules are small sandboxed programs that receive a P6M event as JSON and return an allow/deny decision, plus optional allowlisted HTTP request or response mutations.
The module is not an HTTP service. It cannot open sockets, read files, call Stripe, create tenants, mint tokens, write audit logs, or change database state. P6M owns the public API, authorization, storage, usage, billing, audit, and state machines. The WASM module only answers one bounded question: should this host-owned operation continue?
Modules are scoped to one enabled service and one event. P6M looks up the active module by enabledServiceId plus event. This keeps policies domain-local: a knowledge policy is not a metadata policy, and a support policy is not a notifications policy.
Current events
The supported events are:
http.before_request
http.after_response
metadata.before_write
P6M calls the active before-request module before a customer API handler, and the active after-response module after a successful handler. metadata.before_write runs before a metadata write commits. If the module returns allowed false, the guarded operation is rejected.
Build target
Use a WebAssembly module that can run under Wazero without JavaScript glue. TinyGo is the most practical toolchain for this contract.
Do not build a module that depends on Go's browser wasm_exec.js runtime. P6M does not load JavaScript glue and does not provide browser-style host imports.
The host starts P6M with Wazero support:
P6M_WASM_RUNTIME=wazero go run -tags wazero ./cmd/daemon
Required exports
Your module must export:
alloc(len: i32) -> i32
dealloc(ptr: i32, len: i32)
eval_json(ptr: i32, len: i32) -> i64
P6M calls alloc, writes the request JSON into guest memory, then calls eval_json with the pointer and length. eval_json must return one i64 that packs the output pointer and output length:
(ptr << 32) | len
P6M then reads that output JSON and calls dealloc for both the input and output buffers.
Request JSON
For http.before_request, eval_json receives a request like:
{
"event": "http.before_request",
"orgId": "org-id",
"workspaceId": "workspace-id",
"enabledServiceId": "enabled-service-id",
"actor": {
"kind": "customer",
"userId": "user-id",
"orgId": "org-id"
},
"payload": {
"request": {
"method": "POST",
"path": "/v1/metadata",
"query": {},
"headers": {
"X-Tenant-Code": "acme"
},
"body": {
"orgId": "org-id",
"enabledServiceId": "enabled-service-id",
"value": {
"text": "Hello"
}
}
},
"context": {
"orgId": "org-id",
"workspaceId": "workspace-id",
"enabledServiceId": "enabled-service-id"
}
}
}
Treat orgId, workspaceId, enabledServiceId, actor, event, and payload as the stable policy inputs. Do not infer authority from display names or legacy tenant vocabulary.
Response JSON
Return:
{
"allowed": true,
"reason": ""
}
To deny:
{
"allowed": false,
"reason": "email domain is not allowed"
}
The reason should be short and safe to show to an application developer. Do not include secrets or raw customer data in denial messages.
HTTP hooks may also return allowlisted mutations:
{
"allowed": true,
"mutations": {
"requestBodyMergePatch": {
"value": {
"tenantCode": "acme"
}
},
"requestQuerySet": {
"includePolicyContext": "true"
},
"requestHeadersSet": {
"X-Customer-Policy": "checked"
}
}
}
For http.after_response, use responseBodyMergePatch and responseHeadersSet. P6M rejects attempts to mutate immutable identity, authorization, billing, usage, credential, forwarding, or P6M internal header fields.
Rejected mutation attempts are audited as logic_module.mutation_rejected. Treat that as a deployment signal: either the module is buggy or it is trying to cross a security boundary P6M will not allow.
Minimal TinyGo shape
The module needs allocator functions and an eval_json function that reads bytes from memory, decodes JSON, produces a decision, stores the response bytes, and returns the packed pointer and length.
package main
import (
"encoding/json"
"unsafe"
)
type Request struct {
Event string
OrgID string
Payload struct {
Request struct {
Headers map[string]interface{}
}
}
}
//export alloc
func alloc(size uint32) uint32 {
buf := make([]byte, size)
return uint32(uintptr(unsafe.Pointer(&buf[0])))
}
//export dealloc
func dealloc(ptr uint32, size uint32) {}
//export eval_json
func eval_json(ptr uint32, size uint32) uint64 {
in := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(ptr))), size)
var req Request
decision := map[string]interface{}{"allowed": true}
if err := json.Unmarshal(in, &req); err != nil {
decision = map[string]interface{}{"allowed": false, "reason": "invalid policy request"}
}
if tenant, ok := req.Payload.Request.Headers["X-Tenant-Code"].(string); !ok || tenant == "" {
decision = map[string]interface{}{"allowed": false, "reason": "tenant header is required"}
}
out, _ := json.Marshal(decision)
outPtr := alloc(uint32(len(out)))
outMem := unsafe.Slice((*byte)(unsafe.Pointer(uintptr(outPtr))), len(out))
copy(outMem, out)
return (uint64(outPtr) << 32) | uint64(len(out))
}
func main() {}
Allocator details are easy to get wrong. Keep modules small, deterministic, and covered by a host-side smoke test before using them on production metadata writes.
Compile
For TinyGo, compile with a WASI-compatible target:
tinygo build -target=wasi -no-debug -o policy.wasm ./policy
Then compute the hash and base64:
sha256sum policy.wasm
base64 -w0 policy.wasm > policy.wasm.base64
Upload and activate
Upload the compiled bytes to the logic module API:
curl -X POST "$P6M_URL/v1/logic-modules" \
-H "Authorization: Bearer $P6M_API_KEY" \
-H 'Content-Type: application/json' \
-d '{
"orgId": "'"$ORG_ID"'",
"enabledServiceId": "'"$ENABLED_SERVICE_ID"'",
"name": "Require tenant header",
"event": "http.before_request",
"abiRevision": "v1",
"maxExecutionMs": 50,
"maxMemoryPages": 16,
"wasmBase64": "'"$WASM_BASE64"'",
"expectedSha256": "'"$WASM_SHA256"'"
}'
Activate it:
curl -X POST "$P6M_URL/v1/logic-modules/$MODULE_ID/activate" \
-H "Authorization: Bearer $P6M_API_KEY"
Runtime limits
Current limits are intentionally tight:
- module size is capped
- request JSON is capped
- response JSON is capped
- execution time defaults to 50 ms unless the module metadata sets another limit
- memory defaults to 16 WebAssembly pages unless the module metadata sets another limit
- there are no host imports
- there is no network or filesystem access
If an active module exists and evaluation fails, P6M fails closed and denies the guarded operation.
Testing
Use the WASM smoke script as the reference integration flow:
scripts/smoke-wasm-policy.sh
That smoke covers upload, activation, old-module demotion, denied metadata writes, disable, and delete. For customer-specific policies, add a staging workspace and test both allowed and denied customer API requests before activating in production.