# Author a guardrail profile from the CLI

Copy a profile, publish an immutable revision, find out who still pins the old one, then move your gateway's pin.

Source: https://tokentriage.com/docs/build/authoring-a-guardrail

---
import Callout from '../../../components/Callout.astro';
import Details from '../../../components/Details.astro';
import NextSteps from '../../../components/NextSteps.astro';

{/* Every block below is a capture of the shipped binary run against a stubbed customer lane.
    Harnesses and raw output: .tmp/resume-logs/s07-gr-t10/{gr_transcripts.go,out.txt} for the
    profile verbs and the seven error codes, and
    .tmp/resume-logs/s07-gr-cli-b/detectors/gen_detectors.go for the catalogue, whose rows are
    read out of this build's own detector registry. Ports and request ids vary per run. */}

You have a gateway in production with an output check on it, and you want to tighten that check
without finding out at 3am which other teams were compiled against your edit. A guardrail profile
is a numbered ledger, so this is a sequence you can do in the open: copy, publish, look at who is
pinned where, then move one pin.

This page walks that sequence from `tokentriage cloud`. It assumes you already know what a binding
is; [Configuring a guard](/docs/guardrails/configuring-a-guard) has the field-by-field shape, and
[What gets inspected](/docs/guardrails/what-gets-inspected) covers which parts of a request reach a
detector. Editing profiles from the browser builder is out of scope here, and so is running a
profile against saved traffic before you arm it, which
[Proving it before you arm it](/docs/guardrails/proving-it-before-you-arm-it) handles.

Every command below takes the same three scope flags, and your management key comes from
`TOKENTRIAGE_MANAGEMENT_KEY`. `--app` is an authority flag rather than a filter: profiles belong to
the workspace, but a gateway developer's grant is usually bound at an application, so pass it when
that is where your access comes from.

## Read the catalogue before you write a binding

A binding names three things the deployment has an opinion about: a detector id, the phases to run
it in, and a configuration if the detector needs one. Get any of them wrong and the publish comes
back with a 422 and nothing written. `detectors` is the list those three come from, so you pick
from it instead of guessing and retrying.

```console
$ tokentriage cloud guardrail detectors --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
pii.email	pii.email-v1	pii	input,output,stream	built
  covers    email addresses of the shape local@label.tld where the domain has at least one dot and a 2-24 letter final label, including plus-tagged local parts and addresses inside RFC 5322 mail headers
  covers    an address disguised with zero-width characters or with Cyrillic/Greek/fullwidth homoglyphs, via a folding projection whose spans still index the ORIGINAL bytes
format.jsonschema	format.jsonschema-v1	format	output	needs-config
  covers    whether a response text field parses as JSON at all (entity FORMAT_NOT_JSON), and — when it does — whether it satisfies the operator-declared JSON Schema, via github.com/santhosh-tekuri/jsonschema/v6 (Apache-2.0, JSON Schema draft 2020-12 by default and every earlier draft the library supports)
  covers    per-field scoping: an instance may name the field paths it constrains, so a schema written for `choices[0].message.content` does not reject a system fingerprint
```

That capture is trimmed to two of the eighteen checks the build carried, and each check's remaining
`covers` lines were cut with them. The header line reads id, version, category, phases, and whether
the check has to be configured before it can run. `pii.email` is `built`, so a binding that names it
is complete. `format.jsonschema` is `needs-config`, which is the machine-readable form of "attach a
`schema` object or this will not publish".

Two pieces of the answer stay off the terminal. The summary line on stderr says so:

```text
cloud: 18 check(s); each header line is id, version, category, phases, configurable, and the `covers` lines under it are what that check catches. Every check also declares what it does NOT catch: read it with --json before you tell anyone what is covered
```

Run it with `--json` for the `not_covered` half and for `stream_incremental`, which tells you
whether a check inspects a streamed response as it arrives or waits for the buffered one.
[Phases and streaming](/docs/guardrails/phases-and-streaming) is the background for that choice.

<Callout type="warning" title="Two kinds of empty answer">

A deployment whose operator composed no guardrail compiler has no registry to read, and the lane
answers `501` rather than an empty list. The CLI names the operation and exits non-zero, because
"this deployment arms no checks" and "this deployment cannot tell you" are different answers:

```text
Error: the detector catalogue is unavailable on this deployment: the server has not implemented customer.guardrail_detectors.list yet (HTTP 501); detail: this deployment composes no guardrail compiler …
```

</Callout>

## Copy the profile you are about to change

A published revision is immutable, and the one thing a gateway pins is a numbered revision. So
"editing" a profile means appending revision *n+1* and then moving pins to it. When the profile you
want to start from is shared, copy it first: the copy carries the content and none of the
consumers, so you can iterate on it without anything compiling your half-finished work.

```console
$ tokentriage cloud guardrail copy --profile grp_5nWq8Yt2 --revision 3 --display-name a2-response-schema --json --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
{
  "schema": "customer/v1",
  "data": {
    "id": "grp_2Qh6Ts9v",
    "display_name": "a2-response-schema",
    "revision": 1,
    "digest": "sha256:7c40e912"
  }
}
```

`--revision` is required and has to be a number. There is no "copy whatever is current", because
the content you copy is content you are about to take responsibility for, and a head that moved
between your read and your copy would leave you responsible for something you did not look at.

## Publish a revision, and read the scope line first

Publishing appends an immutable revision under a compare-and-set on the head version you read.
`--expected-revision` is that head version. Because the write changes what a security control will
enforce, the command prints the scope it resolved on stderr and stops there unless you pass
`--yes`:

```console
$ tokentriage cloud guardrail publish --profile grp_2Qh6Ts9v --file - --expected-revision 1 --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
scope     workspace t_4fQ1 of organization org_9k2m, application app_7Lx2
Error: this guardrail profile revision was NOT published: it appends an immutable revision under a security control, and the ceiling above is the scope it lands in. Re-run with --yes to publish (or with --json, which is the scripted path and skips this confirmation)
```

Add `--yes` and the same command writes. `--json` is the scripted path and skips the stop, not the
disclosure: the scope line is printed either way, so a CI job's log still records where the revision
landed.

```console
$ tokentriage cloud guardrail publish --profile grp_2Qh6Ts9v --file - --expected-revision 1 --yes --json --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
scope     workspace t_4fQ1 of organization org_9k2m, application app_7Lx2
{
  "schema": "customer/v1",
  "data": {
    "id": "grp_2Qh6Ts9v",
    "revision": 2,
    "digest": "sha256:9b1f4a03"
  }
}
```

Nothing moved. Revision 2 exists and every draft and release still compiles the revision it was
already pinned to. That is what makes publishing a safe thing to do in the middle of an afternoon,
and it is why the pin is a separate step further down this page.

## What the seven error codes mean

The content is checked against the operator's detector registry before anything is written, so the
codes below arrive at publish time rather than at the next release of every gateway that pinned the
profile. Each one is a different repair at a different control, which is why they are separate codes
rather than one `validation_failed`.

| Code | HTTP | What to change |
|---|---|---|
| `guardrail_schema_invalid` | 422 | The `schema.document` did not compile. The detail carries the JSON pointer. |
| `guardrail_detector_unconfigured` | 422 | A `needs-config` check was armed bare. Attach its `schema` object. |
| `guardrail_phase_not_supported` | 422 | Drop the phase, or bind a check whose `phases` column lists it. |
| `reserved_binding_id` | 403 | Rename the binding. `mandatory-` and `platform-` ids belong to the composed tier. |
| `head_revision_stale` | 412 | Someone published first. Re-read the head and retry with its version. |
| `guardrail_profile_revision_not_found` | 404 | The revision does not exist, or you cannot see this profile. |
| `mandatory_rule_not_removable` | 409 | A company-composed rule cannot be disabled from a gateway draft. |

<Details summary="The captures behind that table">

```text
Error: customer.guardrail_profiles.publish was refused (HTTP 422); detail: /bindings/0/schema/document: schema is not valid JSON; type=guardrail_schema_invalid; request_id=req_8h2Nq3vLp0

Error: customer.guardrail_profiles.publish was refused (HTTP 422); detail: /bindings/0/schema: this response check needs a JSON schema; type=guardrail_detector_unconfigured; request_id=req_8h2Nq3vLp0

Error: customer.guardrail_profiles.publish was refused (HTTP 422); detail: /bindings/0/phases/1: "format.jsonschema" declares phase output only; type=guardrail_phase_not_supported; request_id=req_8h2Nq3vLp0

Error: customer.guardrail_profiles.publish was refused (HTTP 403); detail: binding "mandatory-pii-block" belongs to the company's composed tier and cannot be authored here; type=reserved_binding_id; request_id=req_8h2Nq3vLp0

Error: customer.guardrail_profiles.publish was refused as a stale revision (HTTP 412): re-read the resource and retry with its current --expected-revision; detail: the head is now at revision 3; re-read and retry with that revision; type=head_revision_stale; request_id=req_8h2Nq3vLp0

Error: customer.guardrail_profiles.copy was refused as not found (HTTP 404): the lane conceals organizations and resources the caller has no binding for, so this can mean it does not exist OR that you cannot see it; detail: grp_2Qh6Ts9v has no revision 9; type=guardrail_profile_revision_not_found; request_id=req_8h2Nq3vLp0

Error: customer.gateways.draft.put was refused (HTTP 409); detail: /document/output_protection/disabled_rule_ids/0: "mandatory-pii-block" is composed by the company and cannot be disabled here; type=mandatory_rule_not_removable; request_id=req_8h2Nq3vLp0
```

Each line carries a `request_id`. Quote it to support; the ids above came from a stub and are not
yours.

</Details>

The 404 is worth one extra sentence, because it is the one code that does not tell you which
problem you have. The lane conceals resources you hold no grant over, so "no revision 9" and "not
your profile" arrive as the same answer. Check the revision number against `guardrail get` before
you go looking for a permissions bug.

## Find out who still pins the old revision

Before you move anything, ask who is on the revision you are replacing. `impact` answers for one
exact revision, because one exact revision is what a protection card holds.

```console
$ tokentriage cloud guardrail impact --profile grp_5nWq8Yt2 --revision 3 --json --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
{
  "schema": "customer/v1",
  "data": {
    "revision": 3,
    "authorized": [
      {
        "gateway_id": "gw_3mPq7Xn1",
        "environment_id": "env_prod9k",
        "kind": "release",
        "card": "output_protection"
      },
      {
        "gateway_id": "gw_3mPq7Xn1",
        "environment_id": "env_stg2Lm",
        "kind": "draft",
        "card": "output_protection"
      }
    ],
    "elsewhere": {
      "drafts": 3,
      "releases": 1
    }
  }
}
```

The consumers you have read access to are named, with the environment and the card they pin from.
Everything else is counted rather than named, which is the `elsewhere` block: four more consumers
exist, and you learn that they exist without learning about gateways you hold no grant over. A
profile you copied a minute ago reports nobody in both halves.

## Move the gateway's pin

The pin lives in the gateway document, not in the profile, so the last step is a draft write and
not a guardrail command. Put the new `profile_revision` on the protection card and save the draft
with its own expected revision, then release the draft the way you normally would.

```json
{
  "schema_version": "gateway-config/v1",
  "output_protection": {
    "profile_id": "grp_2Qh6Ts9v",
    "profile_revision": 2
  }
}
```

```console
$ tokentriage cloud draft put --gateway gw_3mPq7Xn1 --env env_prod9k --file - --expected-revision 5 --org org_9k2m --workspace t_4fQ1 --app app_7Lx2
```

One field on that card is not yours to set. `disabled_rule_ids` will take your own binding ids and
turn away the ids the company composed onto every gateway:

```text
Error: customer.gateways.draft.put was refused (HTTP 409); detail: /document/output_protection/disabled_rule_ids/0: "mandatory-pii-block" is composed by the company and cannot be disabled here; type=mandatory_rule_not_removable; request_id=req_8h2Nq3vLp0
```

Until that draft is saved and released, the environment keeps enforcing the revision it already
pinned. [Configure, test and promote a gateway](/docs/build/gateway-builder) is the full draft and
release path.

<NextSteps
  items={[
    {
      title: 'Check a response against a JSON schema',
      href: '/docs/guardrails/response-schema',
      description: 'The schema document itself, and the one detector that needs configuring.',
    },
    {
      title: 'Configuring a guard',
      href: '/docs/guardrails/configuring-a-guard',
      description: 'Every field of a binding: modes, thresholds, severity bands, error handling.',
    },
    {
      title: 'Proving it before you arm it',
      href: '/docs/guardrails/proving-it-before-you-arm-it',
      description: 'Run the new revision against saved traffic before a gateway pins it.',
    },
  ]}
/>
