Edge-Function AAL2 — Runbook PREP · not executed

Item 1 / edge functions of the Auth Window. Ticket POPPY-SEC-AAL2-SERVER. This is a plan for a watched window — nothing here has been run. Deploy touches pure-mcp (~1,477 calls/48h), so it is the highest-blast-radius remaining step.

1 · Operator — the short list

2 · The core problem (grounded)

pure-mcp callers are MACHINE principals, not human sessions. Auth today: x-mcp-key principal (poppy, cron, lane-a…), and the function runs as service-role (bypasses RLS). There is no user JWT and no aal claim in a normal call. So “require an aal2 JWT” cannot be read from the existing request — the human’s step-up level must be forwarded to the function.

Consequence: aal2 gating applies only to human-initiated privileged actions (a person told Poppy/Chat to send/outbound/mutate). Pure machine crons have no human and must stay authorized by their token/scope — they are not aal-gated (or they break).

3 · Recommended design

Add an actor-JWT channel: the human-facing caller (pure-chat-api / client) forwards the signed-in user’s access token in a dedicated header x-actor-jwt. The edge function verifies it server-side (calls GET /auth/v1/user with that token 200 means valid & unexpired) and reads the aal claim. Privileged tools/action-classes require aal=aal2; everything else is unchanged.

4 · What gets gated

Action / toolGate?Why
connector.run (external_send, outbound)YES aal2real-world side effects on behalf of a human
release.publish / release.revertYES aal2deploys production
memory.set on standard.*, board.dispatch (privileged)YES aal2governance / config mutation
board.list, memory.get, qa.*, read toolsnoread-only
machine crons (no human actor)noauthorized by token/scope; no human aal exists

Exact privileged set is confirmed in pre-flight with the Operator before deploy.

5 · The code (drop-in helper)

// added near the top, after SB_URL / SRK
async function actorAal(req){
  const jwt = req.headers.get("x-actor-jwt") || "";
  if(!jwt) return {aal:null, ok:false, reason:"no actor jwt"};
  // verify live against GoTrue (rejects forged/expired)
  const u = await fetch(`${SB_URL}/auth/v1/user`,{headers:{apikey:SRK, Authorization:"Bearer "+jwt}});
  if(!u.ok) return {aal:null, ok:false, reason:"actor jwt invalid ("+u.status+")"};
  let aal="aal1"; try{ aal = JSON.parse(atob(jwt.split(".")[1].replace(/-/g,"+").replace(/_/g,"/"))).aal || "aal1"; }catch(_){}
  return {aal, ok:true};
}
const PRIVILEGED = new Set(["connector.run","release.publish","release.revert"]);
function needsAal2(name,a){
  if(PRIVILEGED.has(name)) return true;
  if(name==="memory.set" && String(a&&a.key||"").startsWith("standard.")) return true;
  return false;
}
// inside Deno.serve, AFTER principal check + BEFORE tool():
if(needsAal2(name,args)){
  const act = await actorAal(req);
  if(!act.ok || act.aal!=="aal2"){
    await audit(principal,name,args,false,"aal2 required: "+(act.reason||act.aal));
    return json({error:"step-up required (aal2) for "+name, code:"AAL2_REQUIRED"}, 403);
  }
}

Callers of privileged actions (pure-chat-api, Poppy’s human-triggered path) must add x-actor-jwt: <user access token>. Machine crons don’t call privileged actions, so no change.

6 · Deploy mechanics

Source of truth = GitHub. The deployed body is NOT retrievable via the Management API (returns {}). Redeploy from puremlstech/puremls @ Testin.MikeSupabase : supabase/functions/pure-mcp/index.ts. Before deploy, confirm that file is the live v53 source (its header says “paste this whole file … then Deploy”); if in doubt, reconcile first. A wrong source overwrites a live, heavily-used function.

7 · Pre-flight (before deploy)

8 · Verification matrix (after deploy)

TestExpect
Read tool (board.list) via any principalworks (unchanged)
Machine cron runs its normal toolsworks (no privileged/human actions)
Human (aal2) triggers connector.run w/ x-actor-jwtexecutes
Human (aal1) triggers connector.run403 AAL2_REQUIRED
connector.run with no/forged x-actor-jwt403

9 · Rollback

Redeploy the prior Git source (drop the helper + gate) via the same Composio call — seconds, no data change. Or, if only the gate misbehaves, ship a one-line needsAal2()false hotfix. Kill-switch option: env EDGE_AAL2_OFF=true read at top of needsAal2 (add in the same deploy) to disable without a rollback.

10 · Go / No-Go

GO if: privileged set confirmed, actor-JWT forwarding is live on the human path, Git source verified == deployed, crons confirmed clear of privileged actions, Operator signed-in at aal2 to test, low-traffic window.

NO-GO if: any cron/automation calls a privileged action (would 403), or the human path doesn’t yet send x-actor-jwt, or source-match is unconfirmed.

Prep authored 7/7/26 (Shattique). Companion to /pure-cp5-runbook.html and /pure-cp7-runbook.html. Client + RLS aal2 already live; this closes the edge-function half of POPPY-SEC-AAL2-SERVER. Status: awaiting scheduled watched window + Operator go.