Fabric
You're on the list! We'll be in touch when we launch.
Please enter a valid email address.
Something went wrong. Please try again.

Configuration

A repo’s Fabric settings live in a file at its root: fabric.yaml. It is an ordinary tracked file, versioned in the operation log like everything else. Edit it in a change, propose, and accept, and the new settings take effect the moment the change lands on trunk. There is no separate settings screen and no out-of-band state.

Every setting is optional. A repo with no fabric.yaml behaves exactly as Fabric did before the file existed, and an omitted key keeps its default. You only write down what you want to change.

Where it lives and how it is read

fabric.yaml sits at the repository root. When Fabric needs a setting, it reads the file from the trunk and parses it. Because the file is versioned, the settings in force are always the ones on trunk, and you can see exactly when they changed by looking at the file’s history.

A recommended header points an editor at this page and the machine-readable schema:

# Fabric repository configuration.
# Docs:   https://fabricemr.com/docs/config/
# Schema: https://app.fabricemr.com/config/schema.json

The schema

fabric.yaml is validated against a published JSON Schema. The schema is the validator: the same document your editor can load for completion and inline checking is the one Fabric uses. Point your editor’s YAML schema setting at https://app.fabricemr.com/config/schema.json to get completion and error squiggles as you type.

Validation runs when you propose a change that edits fabric.yaml. A malformed or out-of-spec file is rejected there, with a message naming the problem, so a bad file is caught against the change that introduced it. A change that does not touch fabric.yaml is never affected.

Referencing secrets

Some settings carry sensitive values: a notification webhook URL, a deploy token. You do not want those bytes sitting in a shared, versioned file. Reference them by name instead, with a ${secret.NAME} token:

notifications:
  destinations:
    ops_slack:
      provider: slack
      name: Ops Slack
      webhook: ${secret.NOTIFY_OPS_SLACK_WEBHOOK}

The file records the name, never the value. The value lives encrypted in the repo’s secret store, which you manage from the Secrets card on the organization settings page. When Fabric needs the setting, it looks the name up in the store, decrypts the value, and uses it. The decrypted value is held in memory for that one use. It is never written back into the file, the operation log, the application logs, or any settings page. Those surfaces show the name ${secret.NAME}, so anyone reading the file or the history sees which secret is used, never its contents.

NAME is an uppercase env-var key: an uppercase letter followed by uppercase letters, digits, or underscores (for example SLACK_WEBHOOK_URL), the same shape the secret store enforces.

A reference must point at a secret the store already defines. If you propose a change whose fabric.yaml names a secret that does not exist, propose is refused with a message naming the missing secret, so the gap is caught against the change that introduced it rather than at some later enforcement. Define the secret on the organization settings page first, then propose.

Change approval

The approval section controls how many reviews a change needs before it can be accepted into trunk.

approval:
  required: 2              # distinct approving reviews needed to accept (default: 0)
  allow_self_approve: false # may the change's author approve their own change? (default: false)
  reviewers:               # whose approvals count (default: anyone with write access)
    - alice
    - bob
  • required is the number of distinct approving reviews a change needs. The default is 0, which accepts without any approval, matching a repo that has no fabric.yaml.
  • allow_self_approve decides whether the change author’s own approval counts toward the total. It defaults to false, so by default someone other than the author must approve.
  • reviewers restricts whose approvals count. Leave it empty (the default) and an approval from anyone with write access counts. List identities and only their approvals count.

Record an approval with fabric approve (or fabric approve --request-changes for the opposite). When you run fabric accept, Fabric counts the distinct approving reviews and lands the change only if the policy is met. If it is not, accept is refused with a message saying how many approvals are still needed.

An approval is a judgment of specific content, so it is pinned to the change’s tip at the moment it was recorded. Pushing more ops moves the tip and clears the prior approvals: the change has to be approved again against its new content. This is why you cannot approve a change, quietly amend it, and land the amended version unreviewed.

A change is judged by the policy it forked from

A change is measured against the approval policy in force at the point it forked from trunk, not the policy written inside the change itself. This matters when a change raises the bar. If a change edits fabric.yaml to require more approvals, that change is still judged by the old policy, so it can be accepted normally and the stricter rule applies to changes that fork after it. Raising the requirement never traps the change that raises it.

Required checks

The checks section blocks accept while a required check is still running or has failed. Without it, a failing or in-flight check does not stop a change from landing; with it, the check has to pass first.

checks:
  required:                 # checks that must all pass before accept (default: none)
    - test
    - .github/workflows/ci.yml / build
  • required lists the checks that must have passed in the change’s latest run batch. Each name matches a check either by its bare job name (for example test) or by its full workflow / job title (for example .github/workflows/ci.yml / build). The default is an empty list, which gates on nothing, matching a repo that has no fabric.yaml.

When you run fabric accept, Fabric reads the change’s most recent run and lands it only when every required check has passed. A required check that is missing, still running, or failed refuses the accept with a message naming the checks that are blocking. Re-run checks with fabric checks (or the Action runs view) and accept again once they are green. Like the approval policy, the required-checks gate is judged by the policy the change forked from, so tightening it never traps the change that tightens it.

Require a ticket

The tickets section ties every change to a ticket. With it on, a change must be linked to exactly one existing ticket before it can be proposed or accepted, and a second open change may not reference a ticket that already has a live one.

tickets:
  required: true            # every change must reference one ticket (default: false)
  • required (default false) turns the policy on. When true:
    • A change must be linked to an existing ticket to be proposed or accepted. Link one with fabric change create --ticket TIX-N or fabric change ticket TIX-N.
    • A second open (draft or proposed) change may not reference a ticket that already has a live one, so two changes never compete on the same ticket.
    • Multiple accepted changes per ticket over time are always allowed. Fabric is append-only, so a follow-up fix or revert is a new change against the same ticket, which is honest history rather than fragmentation.

Like the other gates, the ticket policy is judged by the config the change forked from, so turning it on never traps a change that predates it.

Manual workflow runs

Workflows run automatically on the change lifecycle: a push-triggered workflow runs when a change is accepted to trunk, a pull_request-triggered one when a change is proposed. A workflow that declares on: workflow_dispatch can also be run by hand from its file page in the web UI, so you can trigger it whenever you want instead of waiting for propose or accept.

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Where to deploy
        type: choice
        options: [staging, production]
        default: staging
      version:
        type: string
        required: true
      dry_run:
        type: boolean
        default: true

When you open such a workflow at a change, Fabric shows a form built from its declared inputs. Supported types are string, number, boolean (a checkbox), and choice (a dropdown of options). Each input can set a default, and a required input has to be filled in before the run starts. Submitting the form runs the workflow with those values, available in steps as ${{ inputs.<name> }} and ${{ github.event.inputs.<name> }}. A workflow that does not declare workflow_dispatch shows the plain Run button instead, with no inputs form. A manual run fires only the one workflow you dispatched, the same as GitHub: other dispatchable workflows in the repo are left untouched. Manual runs show up in the Action runs view under the “manual” trigger.

Scheduled runs

A workflow that declares on: schedule runs automatically on a cron cadence, with no change and no person involved. Fabric reads the cron expressions from your trunk workflow files and fires a run when each one comes due.

on:
  schedule:
    - cron: "0 * * * *"    # every hour, on the hour
    - cron: "30 2 * * 1"   # 02:30 every Monday
jobs:
  nightly:
    runs-on: ubuntu-latest
    steps:
      - run: ./scripts/report.sh

Cron uses the standard five-field syntax (minute, hour, day-of-month, month, day-of-week) in UTC, the same format and timezone as GitHub Actions. A workflow may list several cron entries; each fires on its own schedule. The finest resolution is one minute.

A scheduled run is planned against the repo’s root trunk: it materializes the current trunk frontier and runs the workflow’s on: schedule jobs against it, exactly as an accepted change would. A cron only reads the workflows on trunk, so a schedule you add takes effect once the change that adds it is accepted, and a schedule fires the same way no matter which hub instance happens to be running: it fires exactly once per due tick across the whole deployment, never twice.

Because a scheduled run has no human proposing or reviewing it, Fabric treats it like an untrusted trigger: it counts against your organization’s run quota and it runs with no secrets injected at any scope (${{ secrets.* }} resolves empty; ${{ vars.* }} still resolves, since variables are non-secret). Use scheduled runs for checks, reports, and housekeeping that do not need credentials; keep secret-bearing deploys on the change lifecycle or a manual dispatch. Scheduled runs show up in the Action runs view under the “scheduled” trigger.

Custom runner labels

Every workflow job names the runner it wants with runs-on:. Out of the box Fabric ships four labels, the same ones GitHub-hosted Linux runners use:

  • ubuntu-latest
  • ubuntu-24.04
  • ubuntu-22.04
  • ubuntu-20.04

To run a job on something else, declare a custom label in .fabric/runners.toml, a versioned file in your repository:

[[runner]]
label = "gpu-large"
image = "catthehacker/ubuntu:act-22.04"
group = "gpu"            # optional: organizes related labels into a named group

[[runner]]
label = "arm64"
image = "catthehacker/ubuntu:act-latest"

A job can then select it:

jobs:
  train:
    runs-on: gpu-large
    steps:
      - run: ./scripts/train.sh

Each [[runner]] maps one label to a runner image. Labels are case-insensitive. You cannot redefine one of the four built-in labels, and a runner entry must set both a label and an image, so a typo fails the run with a clear message instead of quietly creating a label that never matches.

The image picks the container a job runs in when the hub executes checks in Docker mode. On the Railway-native runner, which runs each job directly on the host process (no container), the image is metadata and a custom label runs on the host just like a built-in one; the label still has to be declared so the job resolves to a real runner.

A job whose runs-on: label is neither built-in nor declared in .fabric/runners.toml fails with a clear error naming the unknown label and listing the labels that are configured. Fabric never silently falls back to a default runner: an unrecognized label is always a hard failure, so a mistyped or removed label surfaces immediately.

The optional group field organizes related labels under a named runner group, a label you can use to describe a pool of similar runners. Grouping is descriptive today; routing a job to a specific runner pool by group is not yet wired up.

Workflow secrets and variables

Workflows resolve two kinds of named values at run time: secrets (${{ secrets.NAME }}), which are encrypted and masked from logs, and variables (${{ vars.NAME }}), which are plaintext configuration and appear in logs as-is. Both are managed from the organization settings page and both come in three scopes, matching how GitHub Actions works:

  • Organization values apply to every workflow run in the workspace.
  • Repository values apply to the repo’s runs (secrets live on the Secrets card; variables on the Variables card).
  • Environment values apply to a run that targets a named environment.

When the same name is defined at more than one scope, the most specific wins: environment overrides repository overrides organization. So a DEPLOY_TOKEN set at both the organization and an environment resolves to the environment’s value for a run in that environment, and to the organization’s value everywhere else.

Manage them under Automations & CI on the organization settings page:

  • Secrets holds the repo’s secrets (the deploy token and anything a workflow reads as ${{ secrets.NAME }}). Organization & environment secrets holds the wider-scoped ones. Secret values are write-only: they are stored encrypted and never shown again. To change one, save a new value; to remove it, delete it.
  • Variables holds non-secret ${{ vars.NAME }} values at any of the three scopes. Variable values are plaintext, shown in the list, and not redacted from run logs, so put configuration here (a region, a feature flag, a build target), never a credential.

NAME is an uppercase env-var key (an uppercase letter followed by uppercase letters, digits, or underscores, for example DEPLOY_TOKEN or NODE_ENV).

Secrets follow the same trust rule as the repo’s deploy token: a run only receives them when a human the workspace trusts is behind it. An accepted change (a push run of the reviewed trunk workflow) always gets them; a proposed change or a manual dispatch gets them only when its author already holds write access; and an automated trigger with no human (a schedule, and the API and dispatch triggers) gets none. Variables are non-secret, so they resolve for every run regardless of trigger or trust, the same way GitHub exposes vars.* to fork pull requests.

API-triggered runs

You can start a run from outside the change lifecycle with an authenticated API call, the Fabric analog of GitHub’s workflow_dispatch and repository_dispatch. This is how an external system (a deploy tool, a webhook relay, another repo’s job) kicks off a run without a person opening a change. Like a scheduled run it is planned against the repo’s root trunk frontier and carries no change.

Authenticate with an API key (fabric token create, or mint one in org settings) and POST to the dispatch endpoint. The repo is resolved from the key, so you never name it.

workflow_dispatch runs a single workflow that declares on: workflow_dispatch, with the inputs you supply. Only the named workflow fires, not every dispatchable one:

curl -X POST "$HUB/v1/vcs/dispatch" \
  -H "Authorization: Bearer $FABRIC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "event": "workflow_dispatch",
        "workflow": "deploy.yml",
        "inputs": { "environment": "staging" }
      }'

The workflow’s declared inputs are the source of truth: defaults are applied and required inputs are enforced from the trunk copy of the file, so an unknown input is ignored and a missing required one is rejected.

repository_dispatch fires every workflow that declares on: repository_dispatch, passing an event_type and an arbitrary client_payload. Inside the workflow they resolve as github.event.action and github.event.client_payload.*:

curl -X POST "$HUB/v1/vcs/dispatch" \
  -H "Authorization: Bearer $FABRIC_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
        "event": "repository_dispatch",
        "event_type": "deploy",
        "client_payload": { "ref": "main", "force": true }
      }'

The key needs write access; a read-only key is rejected (403). Because an API trigger has no human proposing or reviewing it, Fabric treats it exactly like a scheduled run: it counts against your organization’s run quota (over budget returns 429) and runs with no repo secrets injected (${{ secrets.* }} resolves empty). API-triggered runs show up in the Action runs view under the “dispatch” trigger.

Protected environments and deployment approvals

A workflow job can name a deployment target with the job-level environment key, exactly as on GitHub Actions:

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production      # or: { name: production, url: https://prod.example.com }
    steps:
      - run: ./deploy.sh

When a run has a job that targets an environment, that environment’s scoped secrets and variables resolve for the run (see the precedence above). Naming an environment is enough to pick up its ${{ secrets.* }} and ${{ vars.* }} values.

An environment becomes protected when you add one or more required reviewers to it. A run whose job targets a protected environment pauses before that job runs and waits for approval:

  • The run shows a Pending approval status in the Action runs view, and the run detail shows a banner naming the environment and its required reviewers.
  • Any one required reviewer can Approve (the run resumes and the job executes with the environment’s secrets) or Reject (the run fails, and the required-checks gate counts it as a failed check). Non-reviewers, including admins who are not on the reviewer list, can see the pending state but cannot decide.

Manage environments under Automations & CI on the organization settings page:

  • Environments lists the repo’s environments. Add an environment by name (letters, digits, dashes, underscores, and dots, for example production or staging), then add required reviewers to protect it. An environment with no reviewers is unprotected: its scoped secrets and variables still resolve, but a run targeting it never pauses.
  • Removing every reviewer makes an environment unprotected again; deleting the environment removes its reviewers too.

Approving a deployment is a write action, so a required reviewer needs write access to act on a pending run. Reviewers are org members; add them from the environment’s reviewer list.

Concurrency

A workflow can declare a concurrency group so rapid re-pushes or re-proposes do not stack up runs. Fabric honors the same concurrency key GitHub Actions uses, in either form:

concurrency:
  group: deploy-${{ github.ref }}
  cancel-in-progress: true

The group is a key you choose. Any two runs that resolve to the same group are treated as mutually exclusive. Expressions in the group expand against the run’s context, so deploy-${{ github.ref }} scopes the group to one change, while a literal production shares the group across every change in the repo. ${{ github.workflow }} resolves to the workflow’s own name.

When a new run enters a group that already has one in flight:

  • With cancel-in-progress: true, the older run is canceled and the new one takes over. The canceled run shows as superseded in the Action runs view, distinct from a run you canceled by hand.
  • Without it (the default), the new run waits for the older one to finish, so same-group runs execute one at a time in order.

A workflow with no concurrency key is unbounded, exactly as before: its runs never cancel or wait on each other.

Job timeouts

A job can declare timeout-minutes to bound how long it may run. Fabric honors the same key GitHub Actions uses:

jobs:
  build:
    runs-on: ubuntu-latest
    timeout-minutes: 20
    steps:
      - run: ./scripts/build.sh

A job that exceeds its timeout-minutes is terminated and recorded as a failed run, with the reason (“job exceeded its timeout-minutes”) shown in its log. The bound is per job: one job timing out does not stop the others in the run. You can also set timeout-minutes on an individual step to bound that step alone.

Without timeout-minutes, a job runs under Fabric’s default whole-run cap of 10 minutes. Declaring a longer timeout-minutes raises the cap for that run so a legitimately long job is not cut off early. A ceiling of 6 hours applies to any single job regardless of the declared value, so a run can never occupy a runner indefinitely.

If a long-running job is also a required check, raise the accept queue’s check_timeout to match (see Queue settings). That is a separate wall-clock budget the queue applies while a change waits to land, and it defaults to 10 minutes, so a check that runs longer needs both its own timeout-minutes and a check_timeout large enough to let it finish.

Workflow annotations

Workflow steps can flag problems with the GitHub workflow commands ::error::, ::warning::, and ::notice::, each with optional file, line, and col params:

- run: echo "::error file=src/app.js,line=12,col=5::Missing semicolon"

Fabric parses these out of a job’s live log as it runs and shows them as a structured summary at the top of the run detail, above the raw logs. Errors are listed first, then warnings, then notices. When an annotation names a file, its location links into the file view at that path (and line, when given), so you can jump straight from a failing check to the code. The raw logs are untouched: annotations are an extra layer, not a replacement.

Dependency caching

Workflows can cache dependencies between runs with the standard actions/cache action, so a job restores an installed dependency directory instead of downloading and building it from scratch every time. It works the same way it does on GitHub: give it a path to cache and a key, and Fabric stores the directory after the run and restores it on the next run whose key matches.

- uses: actions/cache@v4
  with:
    path: ~/.npm
    key: npm-${{ hashFiles('package-lock.json') }}
    restore-keys: |
      npm-

On a cache hit the path is restored before your install step runs; on a miss the step installs normally and the directory is saved under key when the job finishes. restore-keys are tried as prefixes when the exact key is not found, so a lockfile change still restores the closest earlier cache and only re-fetches what changed.

Caches are scoped to your organization and repository: one repo never sees another’s caches, and one organization never sees another’s. A cache not read for a while is evicted automatically, so storage tracks what your workflows actually use. There is nothing to configure and no key to manage: caching is available to every run.

Status badge

Fabric serves an embeddable SVG badge that shows your repo’s latest trunk run status, the same green “passing” / red “failing” / amber “pending” you see in the Action runs view. Drop it in a README or a dashboard:

![checks](https://your-hub.example.com/badge/<repo-id>/status.svg)

The <repo-id> is the id in the address bar of any repo page (for example the Files or Runs view, /repos/<repo-id>/...). The badge reflects the most recent run that landed on trunk (an accepted change), so it tracks what is actually shipped, not in-flight proposals or local pre-flight runs. Before a repo has any trunk run it reads a neutral gray “no runs”.

The endpoint is public: it takes no login, because a README image is loaded without your session. It is scoped by the repo id itself and reveals nothing beyond that one repo’s pass/fail/pending, so it is safe to embed anywhere you would embed a GitHub Actions badge.

Two optional query parameters tune it:

  • ?workflow=<name> scopes the badge to a single workflow file instead of the whole run. The name matches the workflow file with or without its extension, so ?workflow=ci matches .fabric/workflows/ci.yaml.
  • ?label=<text> overrides the left-hand label (default checks).
![ci](https://your-hub.example.com/badge/<repo-id>/status.svg?workflow=ci&label=ci)

Roles

The roles section defines your organization’s custom roles and the permissions each one grants. Roles are keyed by a short identifier; each role has a display name, an optional description, and a list of permissions.

roles:
  reviewer:
    name: Reviewer
    description: Reviews and approves changes
    permissions:
      - vcs:read
      - vcs:write
      - settings:read
  editor:
    name: Editor
    permissions:
      - vcs:read
      - vcs:write

Each permission is a resource:action key from Fabric’s permission vocabulary, such as vcs:write or settings:read. The schema lists the full set, and your editor will complete and check them. The built-in Admin role grants every permission and is defined in code, so you never list it here.

When the roles section is absent, Fabric falls back to the roles it stored before roles moved into the file, so an organization that has not yet written any roles keeps working unchanged. When the section is present, a role defined in the file takes precedence over a stored role with the same key.

Editing roles from settings

You edit roles under Settings, Team, Roles. Creating, editing, or deleting a role there does not write a database record directly: it produces a change that edits fabric.yaml, the same as any other configuration edit. The change is proposed and, if your approval policy allows, accepted immediately; otherwise it waits for review. While a role change is awaiting review, the Roles page shows a banner linking to the pending change, and the new permissions take effect only once that change is accepted. Editing roles requires repository write access (vcs:write), because it edits a versioned file.

Role definitions live in the file. Who holds each role (the assignment of a person to a role) is managed under Settings, Team and stays with your organization’s membership, because it points at a specific user account rather than at repository state.

Agent

The agent section holds org-wide agent settings. Today that is the voice dictation hotkey.

agent:
  voice_hotkey: ctrl+space
  • voice_hotkey is the keyboard shortcut that starts voice dictation for everyone in the organization. When the key is absent, Fabric falls back to the hotkey stored for the organization, then to its built-in default.

A person’s own hotkey preference is not set here. It stays with their user account, because it is a personal choice rather than repository policy.

Session recording

The recording section controls whether the session recorder runs.

recording:
  session_recording: true
  • session_recording turns the recorder on or off for the whole organization. When the key is absent, the deploy-time default applies: off in production unless it was enabled at deploy, on everywhere else. Setting it here overrides that default without a redeploy.

Colors

The ui.colors section renames the badge and status color presets offered in pickers. The palette itself is fixed, so every color keeps resolving to the same built-in style; only the label shown for each preset changes.

ui:
  colors:
    - key: blue
      label: In Review
    - key: green
      label: Accepted
    - key: red
      label: Blocked
  • key is one of Fabric’s palette keys: gray, brown, orange, yellow, green, blue, purple, pink, red.
  • label is the name shown for that preset.

List only the presets you want to rename. When the section is absent, the built-in labels are used.

Automations

The automations section overrides the trigger of a workflow, keyed by the workflow’s name.

automations:
  Nightly Export:
    trigger_type: scheduled
    trigger_config:
      cron: "0 2 * * *"
  • trigger_type is the event class that starts the workflow, such as scheduled or change_accepted.
  • trigger_config is an optional filter compared against the trigger event. Omit it to match on the trigger type alone.

A workflow named in this section runs on the trigger the file gives it; a workflow not listed keeps its stored trigger. When the section is absent, every workflow keeps its stored trigger.

Editing settings from the app

Each of these sections has a card on the organization settings page (the voice hotkey, session recording, colors, and automations). Saving a card does not write a database record directly: it produces a change that edits fabric.yaml, the same as any other configuration edit. The change is proposed and, if your approval policy allows, accepted immediately; otherwise it waits for review, and the settings page shows a banner linking to the pending change until it is accepted. Saving a setting requires repository write access (vcs:write), because it edits a versioned file.

Notifications

The notifications section routes version-control events to your team’s chat. It has two parts: destinations, the chat sinks a message can go to, and subscriptions, which event goes to which sink.

notifications:
  destinations:
    ops_slack:
      provider: slack                       # slack | discord
      name: Ops Slack                       # shown in settings
      channel: "#ops"                       # optional (Slack)
      webhook: ${secret.NOTIFY_OPS_SLACK_WEBHOOK}
      enabled: true                         # optional, defaults to true
  subscriptions:
    - event_type: change.proposed           # route this event...
      sink: chat                            # ...to a chat destination...
      destination: ops_slack                # ...named by its key
    - event_type: check.failed
      sink: in_app

Each destination is keyed by a short identifier (a lowercase slug). A subscription names that key to send an event to it.

  • provider is slack or discord.
  • webhook is a ${secret.NAME} reference to the delivery URL. It is never written into the file. Store the URL in the organization settings page’s Secrets card (or let the settings page store it for you) and reference it by name. See Referencing secrets.
  • channel is an optional Slack channel; providers that do not use a channel ignore it.
  • enabled defaults to true; set it to false to keep a destination in the file without delivering to it.

A subscription’s event_type is one of change.proposed, change.accepted, change.abandoned, or check.failed. Its sink is chat (with a destination key) or in_app for an in-app notification, delivered to each subscribed user’s Inbox in the web app.

When the notifications section is absent, Fabric falls back to the destinations and subscriptions it stored before routing moved into the file, so an organization that has not yet written any routing keeps delivering unchanged.

Editing notifications from settings

You edit routing under Settings, Notifications. Adding a destination, changing its events, or removing it does not write a database record directly: it produces a change that edits fabric.yaml, the same as any other configuration edit. When you enter a webhook URL, Fabric stores it as an encrypted repo secret and writes only the ${secret.NAME} reference into the file. The change is proposed and, if your approval policy allows, accepted immediately; otherwise it waits for review, and the page shows a banner linking to the pending change. Editing notifications requires repository write access (vcs:write), because it edits a versioned file.

The Test button sends a sample message through a destination’s provider using the resolved webhook, so you can confirm delivery works. A destination is testable once its change is accepted and live.