Agent Governance — Developer Reference
Agent Governance — Developer Reference
Section titled “Agent Governance — Developer Reference”There is no standalone “Action Gateway” microservice. Agent governance is implemented as three plain Strapi content-types in sencai.space/src/api/: agent-policy, approval-request and elevation-request. Enforcement is deny-by-default and lives in Strapi controllers/policies — not in a separate service, and not in OPA/Rego.
Autonomy Ladder
Section titled “Autonomy Ladder”agent-policy.autonomy_level is an enumeration L0–L3 with these exact semantics:
| Level | Name | Behaviour |
|---|---|---|
| L0 | Observe only | Agent only reports and queries. No writes. |
| L1 | Suggest only | Agent proposes actions but nothing executes — purely advisory, no writes even if a human “approves” the suggestion. Do not conflate with L2. |
| L2 | Execute with approval | A human approves a pending request, then the agent executes. |
| L3 | Auto-execute within policy | Pre-approved bounds — no human step, action runs automatically as long as it stays within the policy (max_blast_radius, allowed_action_types, freeze windows). |
Default is L0. A policy with no matching active row for the requested org/environment/action_type is a block (deny-by-default).
agent-policy content-type
Section titled “agent-policy content-type”Scoped by scope_type (org / environment / action_type) + scope_value, per organisation. Key fields:
autonomy_level—L0–L3(see ladder above)freeze_windows(JSON) — time-based blocks, e.g. deploy freezes. Format:[{ name, start: "HH:MM", end: "HH:MM", days: number[], timezone }](days: 0=Sunday…6=Saturday). All agent actions are blocked while any window matches, regardless of autonomy level.max_blast_radius— enumsingle_resource/service_tier/availability_zone/region; caps the maximum scope of impact a single action under this policy may haveallowed_action_types/blocked_action_types(JSON arrays) —blocked_action_typesalways takes precedenceis_active(boolean)created_by_agent(boolean) — guards against agent-initiated self-mutation of its own policy
The schema also carries a set of fields explicitly documented in the schema itself as “Legacy: OPA-compatible” — policy_document (JSON), scope (global/per-provider/per-env/per-action), provider, environment, action_pattern. These exist for backward-compatible data shape only. There is no live OPA evaluation anywhere in the codebase: no .rego policy files and no opa eval CLI invocation exist in this monorepo. Do not write policy-authoring docs that assume a Rego bundle pipeline — enforcement is plain TypeScript in the Strapi controllers (agent-policy controller + the approval/elevation controllers below).
Custom routes on agent-policy:
POST /api/agent-policies/:documentId/activatePOST /api/agent-policies/:documentId/deactivateBoth are org-scoped (caller must be a member of the policy’s organisation, or a platform admin) and emit gateway.* audit events.
Approval Workflow (approval-request)
Section titled “Approval Workflow (approval-request)”Stored in the approval-request content-type (sencai.space/src/api/approval-request/), not cloud-approval-request.
Approval request fields
Section titled “Approval request fields”action_type(string) — machine-readable action identifier, e.g.backup.policy.apply,runbook.execute,bulk.deauthpayload(JSON) — the action payload to execute on approvalstatus— enumpending/approved/rejected/expiredblast_radius(JSON) —{ resource_count, resource_types, estimated_impact }, populated by dry-runcost_delta_usd(decimal) — estimated cost change in USD (negative = savings)dry_run_status— enumpending/running/completed/faileddry_run_result(JSON) — result of the pre-execution simulationrollback_plan(text)expires_at(datetime) — set tocreated_at + 4 hoursat create time
The 4-hour TTL is a fixed constant (TTL_MS = 4 * 60 * 60 * 1000) in the controller. No per-organisation override field exists on the organisation content-type or anywhere else in the codebase — the TTL is currently not configurable.
Approval request routes
Section titled “Approval request routes”All routes require global::hybrid-auth. The mutation sub-routes are declared before the generic /:id routes to avoid Strapi resolving them as findOne/update:
GET /api/approval-requestsGET /api/approval-requests/:idPOST /api/approval-requestsPOST /api/approval-requests/:id/approvePOST /api/approval-requests/:id/rejectPOST /api/approval-requests/:id/dry-runPUT /api/approval-requests/:idDELETE /api/approval-requests/:idapprove— only valid frompending; ifexpires_athas passed, the record is flipped toexpiredand the call returns 400 instead of approving. On success setsapproved_at,approved_by, emitsapproval.approvedaudit event.reject— only valid frompending; requires a non-emptyrejection_reason. Emitsapproval.rejected.dry-run— simulates blast radius and cost delta without executing anything real. Counts affectedcloud-instance/cloud-networkrecords for the request’s organisation, flags destructive action types (destroy/terminate/delete/bulk.deauthpattern), and writesdry_run_status,blast_radius,dry_run_result,cost_delta_usd. Emitsapproval.dry_run.completed.update(PUT /:id) — allowlisted metadata edit only (action_label,rollback_plan). It can never changestatus,organisation,requester,approved_by,expires_at, or cost/blast-radius fields — those are exclusively owned by the approve/reject/dry-run actions.find/findOne/deleteare org-scoped: a caller only sees/deletes requests belonging to organisations they are a member of (platform admins see everything).findOneon a request outside the caller’s orgs returns 404, not 403, to avoid leaking existence across tenants.
Approval request lifecycle
Section titled “Approval request lifecycle”Agent / operator → POST /api/approval-requests (action_type, payload, blast_radius?, cost_delta?, ...) status = "pending", expires_at = now + 4h → optionally: POST /api/approval-requests/:id/dry-run simulates blast radius + cost delta, no execution → Approver reviews in the platform UI → POST /api/approval-requests/:id/approve (rejected instead → POST .../reject with rejection_reason) → status = "approved" → caller executes the actual action out-of-band (cloud-connector / fleet agent / runbook engine) → each transition emits an approval.* audit event via @sencai/auditThere is no dedicated real-time push channel for approval requests (no WebSocket endpoint exists for this). Approval state changes are only observable by polling the REST endpoints above or via the audit event they emit; a broader real-time notification would go through the platform’s general notification.events/platform.events event architecture (see notification-service and event-store-consumer), but no approval-specific wiring into that path currently exists — confirm with the service owner before documenting a live notification flow here.
Cross-tenant capability enforcement
Section titled “Cross-tenant capability enforcement”Cross-tenant capability checks (MSP/agency access to another organisation’s resources) are enforced by the Strapi policy global::is-organisation-scoped-or-cross-tenant (src/policies/is-organisation-scoped-or-cross-tenant.ts, WAVE-19 / F2.GATEWAY.06):
- Reads the active tenant context (
TenantContext, populated fromorganisation-relationship.scope) viagetTenantContext(). - If the request is not cross-tenant (
isCrossTenant === false), the policy is a no-op and passes through — direct org members are governed by ordinary org-scoping policies instead. - If the request is cross-tenant, it checks whether the relationship’s granted
capabilities(e.g.*:read,*:*,compute:read,network:read,cost:read,iam:read) cover the capability required by the route’sconfig.capability. - Missing
config.capabilityon the route fails closed (deny). - On denial: returns 403 and publishes a
CROSS_TENANT_ACCESS_DENIEDaudit event with the required/available capabilities and the relationship id.
Applied on routes that expose cross-tenant compute/network/iam/cost data, alongside route-specific policies:
policies: [ 'global::hybrid-auth', { name: 'global::is-organisation-scoped-or-cross-tenant', config: { capability: 'compute:read' } },]Note from the policy’s own documentation: this policy alone does not provide tenant isolation for non-cross-tenant calls — routes that rely on it as their only policy need global::is-organisation-scoped (or equivalent controller-level org scoping) alongside it.
JIT Elevation (elevation-request)
Section titled “JIT Elevation (elevation-request)”Just-in-time, cross-tenant privilege elevation lives in the elevation-request content-type.
Elevation request fields
Section titled “Elevation request fields”requesting_user,requesting_org,target_org— the user and the two organisations involved in the cross-tenant grantrequested_capabilities(JSON array) — capability strings being requestedjustification(text, required)duration_minutes(integer, 1–480, i.e. max 8 hours) — not a TTL-in-seconds fieldstatus— enumpending/approved/denied/expired/revokedapprover,approved_at,auto_expire_at(=approved_at + duration_minutes)denial_reasonused(boolean) /used_at(datetime) — single-use guard: the grant is consumed on first use, not merely time-bound. Onceused = true, the elevation check treats the grant as inactive even ifauto_expire_athasn’t passed yet.relationship— link to theorganisation-relationshiprecord backing the cross-tenant granthome_region(eu/us/apac) andcell_id— CELL data-residency fields (F2.CELL.01)
Elevation request routes
Section titled “Elevation request routes”GET /api/elevation-requestsGET /api/elevation-requests/:idPOST /api/elevation-requestsPUT /api/elevation-requests/:id/approvePUT /api/elevation-requests/:id/denyPOST /api/elevation-requests/:id/consumecreate— caller must be a member ofrequesting_org(or platform admin). Validatesrequested_capabilitiesas a non-empty array andduration_minutesas an integer in1..480. Setsstatus = "pending",used = false. EmitsELEVATION_REQUESTED.approve(PUT :id/approve) — gated byglobal::hybrid-auth+global::is-elevation-approver(owner/admin oftarget_org— not a platform-wide admin check). Setsstatus = "approved",approved_at = now,auto_expire_at = approved_at + duration_minutes,approver = caller. EmitsELEVATION_APPROVED(risk_levelcritical).deny(PUT :id/deny) — same approver gating. Setsstatus = "denied",denial_reason,approver,approved_at. EmitsELEVATION_DENIED.consume(POST :id/consume) — only the originalrequesting_usermay consume; authorization is enforced in the controller itself (not a route policy). Delegates to the service’sconsumeElevation(), which flipsused = true/used_atas a single-use guard. EmitsELEVATION_USED.find/findOne— non-admin callers only see requests where they are the requester, or a member ofrequesting_org/target_org.
Note: there is no plain PUT /api/elevation-requests/:id generic-update path used for approval — approval and denial are dedicated actions (:id/approve, :id/deny) gated by global::is-elevation-approver, distinct from the generic core update handler.
Elevation request lifecycle
Section titled “Elevation request lifecycle”Requesting user (member of requesting_org) → POST /api/elevation-requests { requesting_org, target_org, requested_capabilities, justification, duration_minutes } → status = "pending" (ELEVATION_REQUESTED audit event) → owner/admin of target_org: PUT /api/elevation-requests/:id/approve → status = "approved", auto_expire_at set PUT /api/elevation-requests/:id/deny → status = "denied" → requesting_user calls POST /api/elevation-requests/:id/consume to actually exercise the grant — single-use: used = true after first consume → grant is inactive once used=true OR auto_expire_at has passed, whichever comes firstTroubleshooting
Section titled “Troubleshooting”Approval request stuck in pending
Section titled “Approval request stuck in pending”Check expires_at — after 4 hours from creation the request auto-expires on the next approve call attempt (or via the enforcement check inline), not via a separate polling cron. Expired requests must be re-submitted; there is no re-open path.
Cross-tenant capability denied unexpectedly (403, CROSS_TENANT_ACCESS_DENIED)
Section titled “Cross-tenant capability denied unexpectedly (403, CROSS_TENANT_ACCESS_DENIED)”Check the organisation-relationship.scope for the relationship between the caller’s org and the target org — the granted capability strings (*:*, *:read, <resource>:<action>) must cover the capability configured on the route. There is no OPA bundle or .rego file to inspect; the matching logic is the plain hasCapability() function in src/policies/is-organisation-scoped-or-cross-tenant.ts.
L3 auto-execute not triggering
Section titled “L3 auto-execute not triggering”Verify the relevant agent-policy row is is_active = true, autonomy_level = "L3", the action type isn’t in blocked_action_types, and no freeze_windows entry currently matches (time/day/timezone).
Elevation grant approved but action still rejected
Section titled “Elevation grant approved but action still rejected”Confirm the grant hasn’t already been consumed (used = true) — grants are single-use regardless of how much of duration_minutes remains. Also check auto_expire_at hasn’t passed.