> ## Documentation Index
> Fetch the complete documentation index at: https://docs.polycore.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Approvals and audit

> How your access policy routes requests through the approval path and how Polycore records work across every interface.

Polycore treats approval and audit as part of the execution path, not optional
logging added by each integration.

* Your access policy decides which invocations dispatch immediately.
* Invocations your policy holds require an approval record before dispatch.
* Approval or denial applies to one exact invocation.
* Every outcome is recorded under the original user-facing request.

## The approval gate

```mermaid theme={"system"}
stateDiagram-v2
  [*] --> Requested
  Requested --> Pending: policy holds for approval
  Pending --> Denied: human denies
  Pending --> Executing: human approves
  Executing --> Executed
  Executing --> Failed
  Denied --> [*]
  Executed --> [*]
  Failed --> [*]
```

Agents and unprivileged callers do not receive a tool that can turn a pending
request into an approved one. The human decision arrives through a separate
approval surface.

## What your policy is

Code in your runner repository. There is no rule builder and no JSON: a policy
is a function that receives one invocation and returns a decision.

```ts theme={"system"}
{ decision: "allow" }
{ decision: "admin_approval", because: "…" }
{ decision: "never",          because: "…" }
```

`because` is shown to whoever approves and stored on the audit record, so write
it as the sentence you would want an approver to read.

An **action** carries its own policy in its own file, next to the code it
governs, and reads that action's typed input:

```ts theme={"system"}
export default defineAction({
  input: z.object({ workspaceId: z.string(), durationDays: z.number() }),
  policy: ({ input, environment }) =>
    input.durationDays > 30 && environment === "production"
      ? {
          decision: "admin_approval",
          because: `A ${input.durationDays}-day grant is a commercial decision.`,
        }
      : { decision: "allow" },
  run({ input }) {
    /* … */
  },
});
```

The capabilities Polycore ships have no file of yours to live in, so their
policy goes in **`policy.ts`** at the runner package root:

```ts theme={"system"}
import { definePolicy } from "@polycore/runner";

export default definePolicy({
  "firestore.delete": ({ input }) => {
    /* … */
  },
  "postgres.update": ({ effect }) => {
    /* … */
  },
});
```

Every policy also sees `environment` (the environment the call is dispatched to)
and `via` (`"human"` or `"agent"`).

<Note>
  A capability with no policy falls to its hazard: reads dispatch, writes and
  delegated code wait for a human. Writing no policy at all changes nothing.
</Note>

<Note>
  A `never` is an answer, not a step toward one. Nothing is recorded as pending
  and no approver is asked, so it cannot be released by clicking a button.
</Note>

## Gating on effect, not on arguments

Most capabilities name their target in their arguments, so a policy reads
`input`. A SQL predicate does not: `update orders set status = 'x' where …`
names its rows only when it is evaluated.

So Postgres writes preview themselves. The statement is rewritten into a read
that reports the exact rows it would change, the columns it would assign, and
every table a foreign key would carry it into. Your policy decides on that:

```ts theme={"system"}
"postgres.update": ({ effect }) => {
  if (effect?.kind !== "postgres.write") {
    return { decision: "admin_approval", because: "This change could not be previewed." };
  }
  const scoped =
    effect.table === "public.orders" &&
    effect.columns.every((c) => c === "status" || c === "updated_at") &&
    effect.cascades.length === 0;
  return scoped && effect.rowCount <= 25
    ? { decision: "allow" }
    : { decision: "admin_approval", because: `${effect.rowCount} rows in ${effect.table}.` };
},
```

An agent can then write whatever SQL the task needs. A small, in-scope status
change dispatches; the same statement against a different table, or touching two
thousand rows, waits for a human who sees the actual row diff.

The invocation that runs is the one that was previewed. Approval dispatches the
previewed rows by primary key, matched on the version each row had at preview
time, so a row that changed while a human was deciding aborts the whole write
and a row that started matching in the meantime is not included.

A preview that fails is not consent: the invocation falls to its hazard, which
holds writes.

<Note>
  Direct web writes from an authenticated organization owner or admin can use
  inline self-approval: the control plane creates and records the approval under
  that web identity, then dispatches without a second approval card. Member
  requests that the policy holds remain pending. Delegated code with hazard
  `unknown` always remains pending for separate review.
</Note>

<Warning>
  Approval is not a substitute for least privilege. The action should still use
  the narrowest downstream credential and validate every input. Approval limits
  when authority is exercised; IAM limits what that authority can do.
</Warning>

## What an approver reviews

An approval is tied to the invocation already recorded by the control plane:

<ResponseField name="Capability">
  The stable operation name and human-readable title.
</ResponseField>

<ResponseField name="Target">
  The project and environment whose runner would execute the operation.
</ResponseField>

<ResponseField name="Arguments">
  The schema-validated input for this invocation.
</ResponseField>

<ResponseField name="Requester">
  The caller attribution carried from Slack, MCP, the web app, or another
  configured interface.
</ResponseField>

<ResponseField name="Decision">
  Approve or deny, plus the decision time and approver attribution available
  from the configured approval channel.
</ResponseField>

The available approvers and approval channels are defined during the assisted
integration. We validate both successful and denied paths before production
handoff.

## Pending approval lifecycle

Slack, MCP, and member-initiated web invocations that the policy holds, along
with `unknown` delegated workloads, follow this pending path:

<Steps>
  <Step title="Create the request">
    The caller submits a typed action. The control plane validates its routing
    context and sees that the policy holds it for approval.
  </Step>

  <Step title="Persist pending state">
    The control plane creates an approval request linked to the original ask and
    capability invocation. It does not dispatch the work yet.
  </Step>

  <Step title="Notify a human">
    Polycore presents the operation, target, and arguments in the configured
    approval surface, such as Slack or the web dashboard.
  </Step>

  <Step title="Apply one decision">
    The approval store accepts one transition out of pending state. A repeated
    or conflicting decision cannot release the same request twice.
  </Step>

  <Step title="Dispatch or close">
    Approval releases the recorded invocation to the selected runner. Denial
    closes it without downstream execution.
  </Step>

  <Step title="Record the outcome">
    Success, failure, or denial remains linked to the requester, approver,
    project, environment, capability, and timing.
  </Step>
</Steps>

## Audit model

Polycore records two useful levels of activity.

<Columns cols={2}>
  <Card title="Request" icon="message-square">
    One user-facing ask, including its caller, interface, input, overall
    outcome, and final answer. A request may involve more than one governed
    operation.
  </Card>

  <Card title="Capability invocation" icon="workflow">
    One dispatch attempt under that request, including capability, arguments,
    project, environment, runner, duration, result, and linked approval when
    applicable.
  </Card>
</Columns>

A third record stores the approval decision itself. This lets a reviewer move
from a human request to every production operation it caused without flattening
a multi-step ask into unrelated log lines.

### Example: one request, two invocations

```mermaid theme={"system"}
flowchart TD
  Ask["Request: Compare signups in staging and prod"]
  Ask --> A["Invocation 1<br/>staging · postgres.query"]
  Ask --> B["Invocation 2<br/>prod · postgres.query"]
  A --> Result["Combined answer"]
  B --> Result
```

## What is recorded

Audit records may include:

* Caller and originating interface.
* Project, environment, and runner.
* Capability name. Approval records also carry the gated hazard.
* Validated arguments.
* Start time, duration, and outcome.
* A structured result or failure, capped at approximately 32 KiB of JSON with
  explicit truncation metadata when the cap is exceeded.
* Approval status and decision attribution.
* The final user-facing answer for agent requests.

<Info>
  Inputs and outputs can contain customer data even though credentials stay
  runner-side. During integration, we identify sensitive fields, keep outputs
  bounded, and decide where actions should redact or summarize data before it
  returns.
</Info>

## Interface behavior

<Tabs>
  <Tab title="Slack">
    A request your policy clears replies in the conversation. A held request
    produces an approval card and waits. The outcome returns to the same request
    context.
  </Tab>

  <Tab title="MCP">
    The MCP call reports that approval is required. Approval remains
    out-of-band; the agent can observe the pending request but cannot approve
    its own operation through MCP.
  </Tab>

  <Tab title="Dashboard">
    Components your policy clears render returned data. A member's held request
    enters pending state. A direct action from an authenticated owner or admin
    can be self-approved inline by the web host, with that decision recorded.
    `unknown` code never uses inline self-approval.
  </Tab>
</Tabs>

## Validation checklist

Before a policy-gated capability is considered ready, we verify:

<Check>
  The runner executes no policy-gated dispatch without an approval decision
  recorded for that invocation.
</Check>

<Check>
  Denial leaves the downstream system unchanged and remains visible in audit.
</Check>

<Check>Repeated decisions do not execute the invocation twice.</Check>

<Check>
  Downstream failures are recorded and shown to the requester without being
  reported as success.
</Check>

<Check>
  Caller, target environment, arguments, and approver context are reviewable
  together.
</Check>
