Govern Resource usage

Return typed Resource controls and hard admission limits from executable Policy packages.

Before you begin

Governance narrows an invokable Resource's immutable baseline. It does not change Resource configuration, provider selection, pricing, credentials, or wallet balance. Identify the target Resource's governance contract and decide which controls or admission limits the Policy must narrow. Unknown controls, meters, contracts, or unenforceable combinations deny before dispatch.

Steps

  1. Use the contract's typed helpers from executable policy() code. This example applies sandbox controls and a customer-scoped concurrency limit:
ts
import { policy, sandboxControls, sandboxLimit } from "@constal/sdk";

export default policy({
  id: "customer-sandbox-boundary",
  version: "1.0.0",
  evaluate(input) {
    const allowed = input.action === "resource:invoke"
      && input.context["resource.operation"] === "exec";
    return allowed
      ? {
          kind: "constrain" as const,
          constraints: [
            sandboxControls({
              cpu: 2,
              memory_gib: 4,
              command_timeout_ms: 120_000,
              network_mode: "domain-allow",
              network_domains: ["api.example.com"],
            }),
            sandboxLimit({
              id: "customer-concurrent-commands",
              meter: "commands",
              scope: "customer",
              window: { kind: "concurrency" },
              maximum: 3,
              continuity: "target",
            }),
          ],
        }
      : { kind: "deny" as const, code: "operation-denied", reason: "operation denied" };
  },
});

sandboxControls() and sandboxLimit() pin the exact built-in sandbox governance contract. modelControls() and modelLimit() do the same for Models. A custom Resource package publishes its contract and typed helpers together.

  1. Choose a trusted scope for each admission limit: run, session, subject, customer, tenant, binding, or resource. The platform derives the identity from authenticated authority and the accepted Run snapshot; Policy code and Agent arguments cannot supply it.
  2. Choose a concurrency window for simultaneous work, a fixed window for bounded rates, or a lifetime window for a durable total. continuity: "target" preserves the limit authority across ordinary Policy and Resource revisions. Use target-version only when a Resource revision intentionally needs a separate counter.
  3. Unit test an allowed invocation and failures for an unknown contract, control, meter, or unenforceable combination.

Limit concurrent Runs

agentLimit() pins the built-in constal.agent contract, whose runs meter counts the Runs an Agent is running at the same time. Return it from a Policy that targets the Agent and governs agent:invoke. The Session enforces it where each top-level Run starts, whatever sent the request:

ts
import { agentLimit, policy } from "@constal/sdk";

export default policy({
  id: "plan-3",
  version: "1.0.0",
  evaluate(input) {
    if (input.action !== "agent:invoke") return { kind: "allow" as const };
    return {
      kind: "constrain" as const,
      constraints: [
        agentLimit({
          id: "worker-runs",
          meter: "runs",
          scope: "customer",
          window: { kind: "concurrency" },
          maximum: 3,
          continuity: "target",
          overLimit: "queue",
        }),
      ],
    };
  },
});
  • overLimit says what happens when the limit is full. reject, the default when it is absent, refuses the start. queue makes the request wait in its Session's inbox, in an ordered Run queue, until a slot frees. queue requires a concurrency window and continuity: "target", so the queue survives Agent rollouts. agentLimit() accepts only a concurrency window.
  • The supported scopes are session, subject, customer, tenant, and resource. A Run limit scoped to run or binding has no identity when the Run starts and is refused with PolicyConstraintFailed.
  • A Run is governed by at most one Run limit. A start whose Policies state more than one is refused with 403 PolicyConstraintFailed.
  • A slot is held for the Run's whole life. It is freed when the Run completes, fails, stops, or is cancelled, or when its Session is deleted. Pause, truncate, and rebind do not end a Run, so a paused Run keeps its slot. Branch Runs and child Runs are part of their parent's work and hold no slot.
  • A Run stopped at its maxTurns or maxMicroUsd limit frees its slot too. A policy control that resumes it takes a slot again first; it never waits, and while the limit is full it is refused with 429 AdmissionLimitExceeded and the Run stays stopped.
  • A Session admits its inbox in order, so at most one request per Session waits. Requests that arrive behind it are decided queued and stay in the inbox.
  • A limit is identified by its id, the Agent, the scope identity, and the window, not by the Policy that states it. Plan Policies that state the same id share one counter and one queue. A session identity is a Session within its Customer, so Customers whose Sessions have the same id never share a limit.

Change a Customer's plan

Bind each Customer to one plan Policy through Customer Policy bindings. Every plan states the same limit, for example worker-runs with scope customer and overLimit: "queue", and its own maximum: plan-1 allows 1, plan-3 allows 3, and plan-10 allows 10. To change plans, bind the new plan Policy and delete the old binding.

  • While two plans are bound, the lowest maximum applies.
  • The limit uses the maximum carried by the most recent new request. Raising it grants waiting requests head-first up to the new maximum; an arriving request starts only if capacity is left and no request is waiting.
  • Lowering it does not preempt. Held slots stay held until their Runs end, and nothing more is granted until fewer Runs than the new maximum hold slots.
  • Waiting requests keep their positions across a change.
  • Rebinding advances the Customer's authorizationSeq. A waiting Agent send (ctx.send() or a schedule) whose sender authority predates the change is refused when it starts with 403 initiating authority is no longer current, as sends queued behind a live Run already are. Channel and HTTP events keep their stored authority and start normally.
  • An event whose stored authority predates the change applies the old maximum when it first acquires, and the next request carrying the new authority sets it back. Policies that state the same limit for one scope identity should state one maximum.

What senders see

SituationDecision (GET /events/:id, HTTP invoke)Agent send (agent.result)Channel
Admittedacceptedthe Run's resultunchanged
Waiting202 { outcome: "queued", admission: { state: "waiting", queue, scope, position }, runId?, run? }nothing yet; the Handle stays pendingnothing
Rejected429 { error: "AdmissionLimitExceeded", queue, scope, maximum }status: "failed"channel.failed with code AdmissionLimitExceeded
Dropped409 { error: "RunQueueItemDropped", detail, actor }status: "stopped"; the Handle is cancelledchannel.failed with code RunQueueItemDropped

A waiting request keeps the Run id its sender reserved; a request that reserved none has no Run id while it waits.

Verify

Invoke the selected Resource and confirm the invocation records its effective governance hash and admission receipts. Confirm the provider receives only effective controls for the exact contract and returns enforcement evidence. The platform must reserve non-monetary admission limits and confirm a positive account balance before the external effect; exact settled usage is debited afterward under a stable ID. Provider-specific compute, network, filesystem, token, or connection enforcement stays inside the provider integration.

Next steps

Continue with Author a Policy for the package and target-selector workflow, or Operate Policies to inspect selected Resources. Governance uses the existing selector, Policy, Resource invocation, and provider path; it does not introduce a second limit service or Resource-specific admission endpoint.