P6M Dashboard

P6M knowledge base

P6M Knowledge Base

Search P6M docs with citations and Memory Graph context.

Results are pulled from the P6M knowledge domain and include citation metadata.

Source document

14. Develop a policy WASM module

Back to sources
active text/markdown v1 24 Jul 2026, 04:45

p6m://docs/140-develop-policy-wasm

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:

terminal
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:

terminal
P6M_WASM_RUNTIME=wazero go run -tags wazero ./cmd/daemon

Required exports

Your module must export:

terminal
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:

terminal
(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:

terminal
{
  "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:

terminal
{
  "allowed": true,
  "reason": ""
}

To deny:

terminal
{
  "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:

terminal
{
  "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.

terminal
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:

terminal
tinygo build -target=wasi -no-debug -o policy.wasm ./policy

Then compute the hash and base64:

terminal
sha256sum policy.wasm
base64 -w0 policy.wasm > policy.wasm.base64

Upload and activate

Upload the compiled bytes to the logic module API:

terminal
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:

terminal
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:

terminal
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.