AIdenID Glasswing — live demo runbook
One workflow, six beats, all against the real control plane and the real protected site. Every status, decision and reason code below comes from the server; the console only displays them. Judges can inspect the same facts through the site's operator API and the control-plane API.
State this runbook is tied to: main with the foundation (PR #7), the kernel PRs (#1 revocation and effect gate, #3 @aidenid/jev, #4 @aidenid/agent-client, #5 session signer and JWKS), the protected site with the Glasswing operator API (#9, #11), the Glasswing console (#8), the kernel end-to-end test (#6) and the demo launcher (#10). Per-PR evidence: docs/aidenid-build/evidence/PR-00-navigator.md.
Conventions:
SITE= the protected site,http://127.0.0.1:4100underpnpm dev. It serves the demo shop routes and the operator API under/glasswing/*.CP= the control plane,http://127.0.0.1:4000underpnpm dev. It is served by the protected-site process, so the site and the dashboard share one store, one revocation epoch and one decision log.OP= the operator token. The launcher generates it per run and hands it to the site and to the dashboard server; the browser never sees it. To use it from a terminal, setAIDENID_DEV_OPERATOR_TOKEN(32+ characters) beforepnpm devand sendAuthorization: Bearer <value>.- Console =
http://127.0.0.1:3000/glasswing. Panels, top to bottom: Agents (1 · Mint), Grants (2 · Assign scoped work), Run a task (3 · Agent acts), Review queue (Human escalation), What happened (Evidence). The header link Decision stream and receipts opens the main dashboard page with the live stream and signed receipts.
Setup
pnpm dev(details indocs/aidenid-build/LOCAL_DEVELOPMENT.md). It builds the workspace, then starts the protected site on 4100, its embedded control plane on 4000 and the dashboard on 3000: loopback only, in-memory state, login disabled. The first log lines name the three URLs. If the launcher printsapps/protected-site is not built, the build failed; fix that before anything else.- Health:
GET SITE/healthz→{ "status": "ok", "site_id": "sit_glasswing_demo" }.GET CP/healthz→{ "ok": true, "service": "aidenid-control-plane" }.GET http://127.0.0.1:3000/api/status/health→{ "state": "connected" }. - Keys:
GET CP/.well-known/aidenid-session-jwks.jsonreturns the control plane's public Ed25519 key with itskid. The site's verifier trusts exactly that key, so a judge can see which key every session token is checked against. - At startup the site registered its target on the control plane (
sit_glasswing_demo, tenantten_glasswing_demo, originhttps://aidenid.local) and loaded its policy:GET /catalogandPOST /items/:id/reserveallow a verified agent that holds the matching permission;GET /customers/exportis denied for every actor class;GET /reports/bulkallows a verified agent withreports:bulkand a stated purpose, then hands the request to Jev; every other path is denied by a catch-all. Only/healthzand the operator API under/glasswing/*are open to every actor class, and the operator API is guarded by the operator token in its handlers. There is nothing to register by hand. - Open the console. Expect the heading Operations console, the note
Protected site: http://127.0.0.1:4100, and the banner "Live: every outcome below is recorded by the control plane and the protected site." With login disabled the page issues its own session cookie (aidenid_operator_token, 8 h) through/api/glasswing/session. That route is enabled only by the launcher flagAIDENID_DASHBOARD_DEV_SESSIONand refuses without it, off loopback, or whenAIDENID_REQUIRE_LOGINis on. If the banner says the operator API is not reachable, see "If something fails".
Beat 1 — Mint an agent
- Console: Agents → Mint agent. A row appears and is selected: agent id
agt_…,key agk_…,jkt <thumbprint prefix>…. Click a row to select a different agent. - Timeline (What happened):
Minted agt_… (key agk_…, thumbprint …). - Server:
POST SITE/glasswing/agentswithOP→ 201{ agent: { id, keyId, thumbprint, publicJwk, createdAt } }. The Ed25519 private key is created and held by the site process and is never returned; the DPoP thumbprint is the subject binding for every later session. - Negative check: the same call without
OP→ 401operator_unauthorized.
Beat 2 — Assign scoped work (issue a grant)
- Console: Grants → Scope (one permission per grant), default
catalog:read→ Expires in (minutes), default 10 → Issue grant (enabled once an agent is selected). The row shows<agent> · catalog:read · chain chn_… · expires in 10 minand is selected. Click a grant to select a different one. - Timeline:
Grant grt_… to agt_…: catalog:read on https://aidenid.local/catalog, expires in 10 min. - Server:
POST SITE/glasswing/grantsbody{ "agentId", "permissions": ["catalog:read"], "expiresInSeconds": 600 }. The site callsPOST CP/v1/grantswithOP(subjectagent:<id>, resourcehttps://aidenid.local/catalog, issuer bound to the authenticated operator) → 201{ grant: { id, chainId, siteId, resource, permissions, expiresAt } }. Outbox eventGRANT_ISSUED_HASH. - A grant is bound to one route. Two permissions in one request → 400
unsupported_permission_scope. Later beats need one grant each foritems:reserve,customers:exportandreports:bulk; the list keeps them all.
Beat 3 — Agent makes an allowed request
- Console: Run a task → Read catalog — In scope: catalog:read → Run signed request (needs a selected agent and grant).
- Expected row: headline Allowed and executed, detail
matched_policy, decision pill allow, actor badge verified_agent, and the request id. - Server, first leg: the site exchanges the grant for a session,
POST CP/v1/sessions/exchange{ grant_id, audience: "sit_glasswing_demo", resource, proof_jkt, requested_permissions: ["catalog:read"] }→ 201 with a DPoP-bound access token signed by the published key andrevocation_epoch: 0. OutboxSESSION_ISSUED_HASH. The token stays inside the site process; the run result reports onlysession.sessionIdandsession.revocationEpoch. - Server, second leg: the site signs
GET https://aidenid.local/catalogwith a DPoP proof and an RFC 9421 HTTP message signature (components@method,@target-uri,authorization,dpop; nonce required) and sends it through its own verifier. Policy match → allow, actor classverified_agent; the decision is recorded in the control plane before the handler runs (fail closed), then the handler returns the catalog. - Optional: issue an
items:reservegrant, select it, run Reserve item → Allowed and executed, effect{ item_id: "demo-item", status: "reserved" }. The reserve handler runs insidewithChainAuthority, the same per-chain lease a revoke takes. A second reserve is refused by the shop and shown as Refused at the effect boundary (item_already_reserved). - Inspect: Decision stream and receipts, or
GET CP/v1/decisions?site_id=sit_glasswing_demo&limit=20withOP:decision: "allow",reason_codes,route_template: "/catalog",actor_class: "verified_agent", the signed receipt with its inclusion proof and thedecision_outbox_chainhashes. Live rows come fromGET CP/v1/decisions/stream?site_id=sit_glasswing_demo(SSE,OP).
Beat 4 — Agent is denied out of scope
Two walls; show both.
- Wall 1, the grant. With the
catalog:readgrant selected, run Export customers — Out of scope: expected deny. Row: Refused before signing (permission_scope_mismatch), detailThe grant does not authorize this task resource and permission.The site refuses to sign a request outside the grant's resource: no session was minted and nothing reached the verifier. (Site response: 403,decision: null.) - Wall 2, the policy. Issue a
customers:exportgrant, select it, run Export customers again. Row: Denied by policy, detailmatched_policy, pill deny. The site policy denies/customers/exportfor every actor class, verified or not; the handler never ran and the route returned no data. Server: signed GET → verifier deny, recorded with a receipt, response headerX-AIdenID-Decision: deny. - Inspect: the deny decision in the list and stream with
route_template: "/customers/export".
Beat 5 — Jev escalation on an ambiguous action
- Console: issue a
reports:bulkgrant and select it. Run a task → Bulk report — Ambiguous: purpose required, Jev review. The Stated purpose textarea appears (untrusted text the agent supplies; Jev reads it as data). Click Run signed request. - Expected row: Queued for human review. Detail without
ANTHROPIC_API_KEY:Jev unavailable (no_provider); the check did not run and nothing was inferred. With a key:Jev <low|elevated|high> risk, confidence N%, evidence <none|partial|full>: <rationale>. Reason codesmatched_policy, semantic_review_required, jev_unavailable(orsemantic_review_requiredafter an evaluated restrictive answer). No effect ran. - Review queue (1) gains
bulk-report by agt_…, the purpose, the Jev line, status Awaiting operator, buttons Approve and Deny.- Approve → Approved, released once; timeline
Review rev_… approved: queued effect released once. The site releases the report job exactly once, insidewithChainAuthorityfor the grant's chain, and records an allow withoperator_override. Clicking Approve again returns the same status and releases nothing. - Deny → Denied; timeline
Review rev_… denied: recorded as a deny decision. The control plane records a deny withsemantic_review_required, operator_override.
- Approve → Approved, released once; timeline
- Server: the policy allowed the route (
allowed_purposes: research, compare), thenassess()asked one bounded rubric question with structured output (riskClass,modelConfidence,evidenceCoverage,verificationStatus∈evaluated | unavailable | inconclusive | not_evaluated, obligationnone | review_required; timeout enforced; cache bound to the exact action digest, grant, policy version and purpose text).composeWithJev()turned allow plusreview_requiredinto queue, which is not dispatch-eligible. Response headersX-AIdenID-JEV-StatusandX-AIdenID-JEV-Obligation. Provider: the official Anthropic SDK with structured output, defaultclaude-opus-5at low effort,JEV_MODELoverride; without a key no provider is created. - What to say: "a safe verdict cannot lift a deterministic deny; a missing provider never becomes a pass; a prompt injected into the purpose text cannot widen the scope, and that case is a test in
packages/jev."
Beat 6 — Revoke, then refused
- Run Bulk report once more so a second review is Awaiting operator. Its authority was captured at revocation epoch 0.
- Console: Grants → Revoke on the
reports:bulkgrant. The button reads Revoked and the row showsrevoked. Timeline:Revoked chain chn_…: epoch 1. The next request by agt_… on this grant is refused. - Server:
POST SITE/glasswing/revoke{ chainId, reason: "owner_revoked_from_glasswing" }→POST CP/v1/revokewithOP→ 202{ revocation: { id, chainId, epoch: 1 } }. The grant is marked revoked before anything is published (a bus failure still leaves it revoked); outboxREVOCATION_EPOCH_BUMPthenGRANT_REVOKED_HASH. - Now click Approve on the pending review. Status: Denied; timeline
Review rev_…: approval refused by the chain-authority gate; nothing was released and a deny decision was recorded. The approval ran underwithChainAuthority(services, { chainId, tokenRevocationEpoch: 0 }, release); the chain is revoked and its epoch is 1, so the gate returned{ ok: false, reason: "grant_revoked" }, the job was not released, and the control plane recorded a deny withoperator_override. This is the effect boundary refusing a human approval issued after the owner changed their mind. - Then run Bulk report (or any task) on the revoked grant. Row: Session refused, detail
The grant is no longer active or could not be exchanged.The control plane refusedPOST /v1/sessions/exchangefor the revoked grant, so nothing was signed. A token minted before the revoke still passes the verifier's signature check while the verifier's revocation-epoch floor is unchanged: per-chain revocation is enforced at the control plane (no new session) and at the effect gate (no effect), and a verifier whose epoch floor is raised refuses the old token asrevoked. Both facts are asserted by the kernel end-to-end test (packages/agent-client/tests/kernel-e2e.test.ts), not clicked. - The revoke and the effect share one per-chain lease; an effect that waits more than 2 s for it fails closed instead of running (the gate reports
chain_busy; the site records a deny withstrict_route_degradedand answers 503). - Inspect: the revocation,
GRANT_REVOKED_HASH, and the deny decisions carryingoperator_overridein the list and stream.
Reset
Restarting pnpm dev is the reset. The console keeps its grant list and timeline in the page: reloading /glasswing mid-demo clears both (agents and reviews are fetched again, grants are not), so do not reload during the demo; if it happens, issue a new grant and continue. Targets, grants, sessions, decisions, outbox entries, agents, keys, reviews and released jobs are all in-process. Say this out loud rather than implying persistence.
If something fails
- Banner "The Glasswing operator API is not reachable: …": the site is down, or the dashboard is missing
AIDENID_PROTECTED_SITE_URL,AIDENID_OPERATOR_TOKENorAIDENID_DASHBOARD_OPERATOR_REQUEST_TOKEN(pnpm devsets them). CheckGET SITE/healthz, restartpnpm dev, reload. - Banner "Login is required on this dashboard…":
AIDENID_REQUIRE_LOGINis on, so the dev session is disabled. Run the local profile (pnpm dev). - Console stuck on "The Glasswing operator API is not reachable: dev_session_disabled": the dashboard was started without
AIDENID_DASHBOARD_DEV_SESSION=true;pnpm devsets it, a hand-started dashboard must too. - Banner "No live control plane configured…": the dashboard has no
AIDENID_CONTROL_PLANE_URL; startpnpm dev. - Error: unsupported_permission_scope when issuing a grant: one permission per grant; pick one scope.
- Error: grant_forbidden on a run: the selected grant belongs to a different agent; select the agent that owns it.
- Refused before signing (permission_scope_mismatch) on the task you meant to allow: the selected grant is for another route; select or issue the matching grant.
- Session refused before any revoke: the grant expired (see Expires in (minutes)); issue a new one.
- Refused at the effect boundary (strict_route_degraded), or Denied by policy with
strict_route_degraded, on Reserve item: a revoke held the per-chain lease for more than 2 s and the gate failed closed by design (chain_busyinside the site). Retry; do not bypass the gate. - Denied by policy with
strict_route_degradedon any other task: the verifier could not record the decision and failed closed. CheckGET CP/healthz, restartpnpm dev. - Jev shows
unavailable: no provider key, or the provider timed out. That is the honest state; use the Review queue. Never retry with a fake verdict. - The decision feed shows nothing after a run:
GET CP/v1/decisions?site_id=sit_glasswing_demowithOP. If it is empty, the site's decision emitter is not reaching the control plane; restartpnpm dev. - Anything else: open Decision stream and receipts or
GET CP/v1/decisions, and say what the server recorded. Never narrate a success the server did not record.