---
title: "Protected files"
description: "Protect a file so it opens only under its policy on a TAPS Node, can be released at a set time, revoked in seconds and records every open."
section: "interfaces"
status: "Building for 0.4 · ships as a experimental API"
spec: "protect-taps"
canonical: "https://mainsdk.dev/docs/interfaces/protect"
updated: "2026-10-28"
---

# Protected files

> Protect a file so it opens only under its policy on a TAPS Node, can be released at a set time, revoked in seconds and records every open.

Status: Building for 0.4 · ships as a experimental API · Canonical: https://mainsdk.dev/docs/interfaces/protect · Updated: 2026-10-28

`protect` keeps a file under your control after you share it: it stays ciphertext until a TAPS Node issues a licence, so you can time-release it, revoke it in seconds and see who opened it.

> **Note:** `protect` is in 0.4 (spec `protect-taps`) and experimental. The interface and the `local` driver are in `mainsdk` and run the whole thing offline in your app and in tests. The real drivers (`taps-node`, `taps-hosted`, `sovedocs`) are in the package `@mainsdk/protect-taps` in this repository, which installs the TAPS SDK from a local folder until `@taps/*` is on npm; none of it is published to npm. See [TAPS Node and SoveDocs](https://mainsdk.dev/docs/integrations/taps).

For a walk-through, see [Protect a file so only the right people open it](https://mainsdk.dev/docs/guides/protect-an-app).

## At a glance

- Status: Building for 0.4 · ships as a experimental API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/protect-taps.md
- Category: Security and privacy

Calls:

- `protect(file, { label, policy })`
- `protect.get(asset)`
- `protect.list({ cursor })`
- `protect.update(asset, { policy, label })`
- `protect.versions.create(asset, file)`
- `protect.revoke(asset, { reason, person })`
- `protect.erase(asset, { reason, approvals })`
- `protect.transfer(asset, { to })`
- `protect.activity(asset)`
- `protect.usage(asset)`
- `protect.open(asset, { purpose })`
- `protect.policy.check(asset, { as, agent, purpose })`
- `protect.policy.simulate(asset, policy)`
- `protect.policy.lint(policy)`
- `protect.zones.list()`
- `protect.zones.update(zones, { approvals })`
- `protect.threshold.get(asset)`
- `protect.share(asset, { to })`
- `protect.share.get(link)`
- `protect.share.list(asset)`
- `protect.share.update(link, change)`
- `protect.share.revoke(link)`
- `protect.share.stats(link, { consent })`
- `protect.people.create(asset, { person })`
- `protect.people.list(asset)`
- `protect.people.remove(asset, person)`
- `protect.agents(asset, { allowed })`
- `protect.delegate.create({ agent, assets, actions, purposes, expiresIn })`
- `protect.delegate.get(id)`
- `protect.delegate.list()`
- `protect.delegate.revoke(id)`
- `protect.approvals.list({ state })`
- `protect.approvals.get(id)`
- `protect.approvals.update(id, { decision })`
- `protect.query(assets, { question, purpose })`
- `protect.mcp.tools()`
- `protect.mcp.manifest()`
- `protect.mcp.verify(manifest, tools)`
- `protect.attest.agent(agent)`
- `protect.attest.verify(evidence, { kind })`
- `protect.inspect(asset, { function })`
- `protect.environment.get()`
- `protect.ledger.list({ asset })`
- `protect.ledger.proof(asset)`
- `protect.ledger.verify()`
- `protect.ledger.export()`
- `protect.leaks.create({ asset, evidence })`
- `protect.leaks.trace(caseId, { approvals })`
- `protect.lockdown.create({ reason, approvals })`
- `protect.lockdown.delete(id, { approvals })`
- `protect.channels.list()`
- `protect.channels.events(pattern)`
- `protect.channels.watch(pattern, fn)`
- `protect.channels.security()`
- `createProtect(deps)`
- `new ProtectError(code, message, opts?)`
- `PROTECT_CHANNELS(const)`
- `PROTECT_DRIVERS(const)`
- `localProtect(deps)`
- `lintPolicy(policy)`
- `parseProtectSettings(raw)`
- `protectHosts(settings)`
- `routeShare(base, built, { protect })`

In main.yaml:

```yaml
protect:
  driver: taps-node          # local in dev and tests
  url: https://taps.acme.dev
  share-domain: open.acme.dev
  key: secret:MAINSDK_PROTECT_KEY
  mode: viewer               # viewer | wrapper
  suite: TAPS-CS1            # TAPS-CS1 | TAPS-CS2
  renew: 10s                 # licence renewal, 5s to 5m
  mcp: false                 # true adds the taps_* tools to the MCP server
```

Guarantees, whatever the driver:

- Ciphertext without a licence
- No licence before releaseAt, or before t of n parties release
- Revocation takes effect within one renew interval (10 s by default)
- Opens, denials and revocations in the MainSDK decision log and the TAPS ledger
- Agents act only inside a delegation from the person they act for
- Share links and QR codes through getqr.id
- No post-quantum claim until TAPS-CS3 is registered


## Drivers

Pick one in main.yaml with `protect.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Local fake TAPS Node | `local` | yes |  |
| Self-hosted TAPS Node | `taps-node` |  |  |
| Hosted TAPS Node | `taps-hosted` |  |  |
| SoveDocs | `sovedocs` |  |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-PROTECT-0020` | check | A `@taps/*` package is imported outside `@mainsdk/protect-taps` |
| `MAIN-PROTECT-0021` | check | The `protect` section is invalid: unknown key or driver, no `url` or `share-domain` for a `taps-*` driver, no `key`, `suite: TAPS-CS3`, `renew` outside 5s to 5m, or a threshold party without a URL |
| `MAIN-PROTECT-0022` | check | A production build resolves `@taps/*` from a folder (`link:` or `file:`) instead of the registry |
| `MAIN-PROTECT-0001` | runtime | Licence denied by the policy (403); the TAPS error type is in `taps`, the failed condition only in the decision log |
| `MAIN-PROTECT-0002` | runtime | Time-locked: before `releaseAt`, or the release round isn't available yet (403, `retryAfter`) |
| `MAIN-PROTECT-0003` | runtime | The asset, the person's access, the link or the licence was revoked, or the asset was erased (403; 410 when erased) |
| `MAIN-PROTECT-0004` | runtime | No such asset, version, link, delegation or approval in this tenant (404, same body either way) |
| `MAIN-PROTECT-0005` | runtime | A person must approve first; the problem carries `approvalId` and `retryAfter` (403) |
| `MAIN-PROTECT-0006` | runtime | The approver declined, or the approval timed out (403) |
| `MAIN-PROTECT-0007` | runtime | Delegation invalid: wider than the person, expired, revoked, used up or deeper than 3 (403) |
| `MAIN-PROTECT-0008` | runtime | A query or play budget, or a delegation's `maxUses`, is used up (429) |
| `MAIN-PROTECT-0009` | runtime | A session, print, device or recipient-verification rate limit was passed (429, `retryAfter`) |
| `MAIN-PROTECT-0010` | runtime | Suite not supported: `TAPS-CS3` is refused until it is registered; no post-quantum protection is claimed (400) |
| `MAIN-PROTECT-0011` | runtime | Threshold release not reached: fewer than `t` parties have released (403, retryable) |
| `MAIN-PROTECT-0012` | runtime | The asset or its label is locked down, suspended or quarantined (423) |
| `MAIN-PROTECT-0013` | runtime | Step-up required: the policy needs a fresher or stronger sign-in, listed in `requirements` (401) |
| `MAIN-PROTECT-0014` | runtime | The TAPS Node, a threshold party or the sealing service is unreachable (503, retryable); nothing opens without a licence |
| `MAIN-PROTECT-0015` | runtime | Invalid input: unknown policy key, label `pan`, a zone id that isn't opaque, `t` outside 2 to n, `releaseAt` in the past, an unknown party or an undeclared agent (400) |
| `MAIN-PROTECT-0016` | runtime | The file's version is superseded; `location` names the current version (409) |
| `MAIN-PROTECT-0017` | runtime | The ledger is stale or the registration is pending (503, `retryAfter`) |
| `MAIN-PROTECT-0018` | runtime | A query answer was refused by the output controls: the whole document, a verbatim span over the limit, or a denied field (422) |
| `MAIN-PROTECT-0019` | runtime | The driver's profile lacks the capability the call needs, such as `query` or `web3` (501) |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| Protect, get, list, update, versions, revoke, erase, transfer, activity and usage | 1 | 0.4 | `protect-taps#local-core` |
| Policy by person, device, network zone, geo, time and purpose | 1 | 0.4 | `protect-taps#policy` |
| Release on time and threshold release | 1 | 0.4 | `protect-taps#release` |
| Share through getqr.id into the TAPS Web Reader | 1 | 0.4 | `protect-taps#share` |
| Agent delegation and human approval | 1 | 0.4 | `protect-taps#agents` |
| Compute-only query | 1 | 0.4 | `protect-taps#query` |
| Signed taps_* MCP tools | 1 | 0.4 | `protect-taps#mcp` |
| Attestation, inspection, holdings and assistive access | 1 | 0.4 | `protect-taps#attest-inspect` |
| Channels, decision log, ledger and security events | 1 | 0.4 | `protect-taps#rails` |
| Post-quantum claim gate | 1 | 0.4 | `protect-taps#pq-gate` |
| TAPS Node, hosted and SoveDocs drivers in @mainsdk/protect-taps | 1 | 0.4 | `protect-taps#driver-package` |

## MainSDK Cloud

Everything on this page is free in your app.
Cloud adds:

| Plan | What it adds |
| --- | --- |
| fleet | Estate view of protected files, and revoke every file an app or person shared since a date |


## Quick start

The `local` driver needs no setup. Protect a file, then share it. The share link is a getqr.id URL that opens the TAPS Web Reader.

```yaml title="main.yaml"
protect:
  driver: local          # taps-node in production
```

TypeScript:

```ts
import { auth, protect } from "mainsdk";

export async function POST(req: Request) {
  await auth.require(req, { role: "exam-admin" });
  const form = await req.formData();
  const paper = form.get("paper") as File;
  const examiners = form.getAll("examiner").map(String);

  const asset = await protect(paper, {
    label: "sensitive",
    policy: { releaseAt: "2027-03-01T09:00:00Z" },   // nobody opens it before this time
  });
  const { url } = await protect.share(asset.id, { to: examiners });
  return Response.json({ url, asset: asset.id });
}
```

Rust:

```rust
use mainsdk::prelude::*;
let app = App::test();
Context::new().tenant("tnt_a").user("u1").scope(async {
let id = app.protect().open("u1", b"plan").await?;
app.protect().share(&id, "u1", "u2").await?;
assert_eq!(app.protect().get(&id, "u2").await?, b"plan");
Ok::<_, MainError>(())
}).await?;
```


Every call reads the tenant, the user and the agent from the request context. Outside a request, run the call as a person with `tenant.as({ user }, fn)`; an explicit `user` or `agent` option overrides the context for one call and is audited. With no user, a call fails with `MAIN-PROTECT-0015`.

## Configuration

```yaml title="main.yaml"
protect:
  driver: taps-node                 # local | taps-node | taps-hosted | sovedocs
  url: https://taps.acme.dev        # the TAPS Node (not for local)
  share-domain: open.acme.dev       # where the Web Reader runs (not for local)
  key: secret:MAINSDK_PROTECT_KEY   # the app's issuer identity
  mode: viewer                      # viewer | wrapper
  suite: TAPS-CS1                   # TAPS-CS1 | TAPS-CS2
  renew: 10s                        # licence renewal, 5s to 5m
  threshold:
    board: https://taps.board.example
    regulator: https://taps.regulator.example
  mcp: false
  environment: https://seal.acme.dev
```

| Key | Type | What it does |
| --- | --- | --- |
| `driver` | string | `local` fakes a TAPS Node in process (the default). `taps-node` is a self-hosted TAPS Node, `taps-hosted` a hosted one, `sovedocs` a SoveDocs tenant's Node. |
| `url` | URL | The TAPS Node. Required for the `taps-*` drivers and added to the driver's hosts, so the [network](https://mainsdk.dev/docs/interfaces/network) allowlist lets it through. |
| `share-domain` | host | The tenant's TAPS share domain, where the Web Reader runs. Required for the `taps-*` drivers. |
| `key` | secret | The app's issuer identity: an ed25519 PKCS#8 key, as `mainsdk seal keygen` writes it. Required for the `taps-*` drivers; read through `secrets`, never from code. |
| `mode` | string | The default delivery. `viewer` gives pages and a text layer; `wrapper` also allows `read()`. |
| `suite` | string | `TAPS-CS1` (default) or `TAPS-CS2`. `TAPS-CS3` is refused until it is registered. |
| `renew` | duration | How often a licence renews: the bound on revocation. Default `10s`. |
| `threshold` | map | Party names for `policy.threshold.parties`; for the `taps-*` drivers each is the party's Node URL. |
| `mcp` | boolean | `true` adds the `taps_*` tools to the app's MCP server. |
| `environment` | URL | The TAPS sealing service for `releaseAt` and threshold assets (`taps-*` drivers). |

`mainsdk check` reports a bad section as `MAIN-PROTECT-0021`, a `@taps/*` import outside the driver package as `MAIN-PROTECT-0020`, a production build that resolves `@taps/*` from a folder as `MAIN-PROTECT-0022`, and the `local` driver in production as `MAIN-CHECK-0026`.

### The policy

Policy is default deny and runs on every licence request and every renew. The owner is never restricted by their own policy.

| Key | Meaning |
| --- | --- |
| `people` | User ids, `team:<id>`, `role:<name>` and email addresses |
| `devices` | `{ managed, attested }` |
| `zones` | Opaque network zone ids such as `z-7f3a` |
| `geo` | `{ allow, exclude, confidence }`, ISO 3166-1 countries |
| `time` | `{ from, until, hours, weekdays, timeZone }` |
| `purposes` | What agents may do; `train` only when named |
| `releaseAt` | RFC 3339; no licence before it, for anyone |
| `threshold` | `{ t, parties }`: `t` of the named parties must release |
| `holdings` | Token-gated access through a chain driver |
| `offline`, `watermark`, `assistiveTools` | Offline window, watermark, assistive tools |

Labels `public`, `internal`, `confidential` and `restricted` are the TAPS labels. `personal` maps to `confidential`; `sensitive`, `child` and `phi` map to `restricted`. The label `pan` is refused: card numbers are never protected or stored.

## What you get

- A protected file carries its own identity, provenance and policy, and stays ciphertext until a TAPS Node issues a licence.
- Release at a set time, or when enough parties agree.
- Revoke in seconds, and see who opened it.
- App agents read it only inside a delegation.
- Use it for exam papers nobody can open early, tenders, results or any document you may need to pull back.
- Built on TAPS (Trust Anchor Provenance System), HubXero Labs' open protocol.

## Calls

| Group | Calls |
| --- | --- |
| Assets | `protect()`, `protect.get()`, `protect.list()`, `protect.update()`, `protect.versions.create()`, `protect.revoke()`, `protect.erase()`, `protect.transfer()`, `protect.activity()`, `protect.usage()` |
| Opening | `protect.open()` returns `licence`, `pages()`, `page()`, `text()`, `read()`, `print()`, `renew()` and `close()` |
| Policy and zones | `protect.policy.check()`, `.simulate()`, `.lint()`, `protect.zones.list()`, `.update()`, `protect.threshold.get()` |
| Sharing | `protect.share()`, `.get()`, `.list()`, `.update()`, `.revoke()`, `.stats()`; `protect.people.create()`, `.list()`, `.remove()` |
| Agents | `protect.agents()`, `protect.delegate.create()`, `.get()`, `.list()`, `.revoke()`, `protect.approvals.list()`, `.get()`, `.update()`, `protect.query()` |
| MCP | `protect.mcp.tools()`, `.manifest()`, `.verify()` |
| Attestation and inspection | `protect.attest.agent()`, `.verify()`, `protect.inspect()`, `protect.environment.get()` |
| Ledger and response | `protect.ledger.list()`, `.proof()`, `.verify()`, `.export()`, `protect.leaks.create()`, `.trace()`, `protect.lockdown.create()`, `.delete()` |
| Events | `protect.channels.list()`, `.events()`, `.watch()`, `.security()` |

Lists return `{ items, next }`. Changes that need a second pair of eyes (`protect.erase()`, `protect.transfer()`, lowering a label with `protect.update()`, `protect.zones.update()`, `protect.lockdown.create()` and `.delete()`, `protect.leaks.trace()`) take `approvals` from people other than the caller, and fail with `MAIN-PROTECT-0005` without them.

## Examples

### Release an exam paper at a set time

Before `releaseAt`, no licence is issued and the attempt is written to the decision log and `protect.activity()`.

```ts title="app/exams/schedule.ts"
import { protect } from "mainsdk";

export async function scheduleRelease(paper: File, examiners: string[]) {
  const asset = await protect(paper, {
    label: "sensitive",
    policy: { people: examiners, releaseAt: "2027-03-01T09:00:00Z" },
  });
  return asset.id;
}
```

### Open a file

`protect.open()` asks for a licence for the current user or agent and returns nothing without one. A viewer gives pages and the accessible text layer; only `mode: "wrapper"` allows `read()`. `text()` returns `{ page, blocks, text }`.

```ts title="app/papers/read.ts"
import { protect } from "mainsdk";

export async function readPaper(id: string) {
  const opened = await protect.open(id);
  try {
    return (await opened.text()).text;   // the text layer a screen reader uses
  } finally {
    await opened.close();
  }
}
```

### Revoke a file in seconds

A revoked asset, person or link fails the next open and the next renew, so an open session stops within one `renew` interval.

```ts title="app/api/assets/[id]/revoke/route.ts"
import { auth, protect } from "mainsdk";

export async function POST(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req, { role: "exam-admin", fresh: "5m" });
  const { id } = await params;
  await protect.revoke(id, { reason: "paper leaked" });
  return new Response(null, { status: 204 });
}
```

`protect.revoke(id, { reason, person })` stops one person and leaves the rest.

### See who opened a file

```ts title="app/api/assets/[id]/activity/route.ts"
import { auth, protect } from "mainsdk";

export async function GET(req: Request, { params }: { params: Promise<{ id: string }> }) {
  await auth.require(req, { role: "exam-admin" });
  const { id } = await params;
  const opens = await protect.activity(id);   // { items, next }
  return Response.json(opens);
}
```

### Open only inside a network zone

```ts title="app/papers/office-only.ts"
import { protect } from "mainsdk";

export async function officeOnly(paper: File, approvedBy: string) {
  await protect.zones.update(
    { "z-7f3a": { name: "Head office", cidrs: ["203.0.113.0/24"] } },
    { approvals: [{ by: approvedBy }] },
  );
  return protect(paper, { label: "confidential", policy: { zones: ["z-7f3a"] } });
}
```

Zone ids are opaque; `protect.policy.lint()` flags a name such as `head-office`.

### Require several parties to release

Name each party under `protect.threshold` in `main.yaml` first; a party that isn't named fails with `MAIN-PROTECT-0015`. For the `local` driver a name with no URL is enough:

```yaml title="main.yaml"
protect:
  driver: local
  threshold: { board: null, regulator: null, centre: null }
```

```ts title="app/papers/board-release.ts"
import { protect } from "mainsdk";

export async function boardRelease(minutes: File) {
  const asset = await protect(minutes, {
    label: "restricted",
    policy: {
      threshold: { t: 2, parties: ["board", "regulator", "centre"] },
      releaseAt: "2027-03-01T09:00:00Z",
    },
  });
  return protect.threshold.get(asset.id);   // { t: 2, n: 3, released: [] }
}
```

No licence is issued until `t` parties have released their shares, and not before `releaseAt` when both are set.

### Let an agent work within a delegation

An app agent acts only inside a delegation from the person it acts for, never wider than that person. An action in `approve.for` waits for the person's approval.

Run `delegate.create` as the person (in a signed-in request, or inside `tenant.as({ user }, fn)`): a delegation always starts with a person, and an agent can't make one (`MAIN-PROTECT-0007`).

```ts title="app/agents/reader.ts"
import { protect } from "mainsdk";

export async function letReaderSummarise(assetId: string) {
  const delegation = await protect.delegate.create({
    agent: "reader-bot",              // declared under agents in main.yaml
    assets: [assetId],
    actions: ["view", "print"],
    purposes: ["summarize"],
    expiresIn: "1h",
    approve: { for: ["print"], timeout: "15m" },
  });
  return delegation.id;
}

export async function approvePrint(approvalId: string) {
  return protect.approvals.update(approvalId, { decision: "approve" });
}
```

`protect.query()` answers a question over protected assets without returning their bytes: answers are marked `generated`, carry citations and never hold a verbatim span over 25 tokens. An agent never approves its own request (`MAIN-AGENT-0003`).

### Share through getqr.id

`share(asset, { to })` and `protect.share()` return a getqr.id link and QR whose target is the TAPS link on your share domain. Outside the test kit they use your getqr.id key (`secret:GETQR_API_KEY`, see [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share)); the kit makes local links. The TAPS link token is never in the getqr.id path, in logs or in channel data, and every link page shows "Powered by getqr.id".

```ts title="app/papers/share.ts"
import { protect, share } from "mainsdk";

export async function sharePaper(assetId: string, email: string) {
  const link = await protect.share(assetId, { to: [email], expiresIn: "7d", maxOpens: 3 });
  return { url: link.url, id: link.id };
}

export async function stop(linkId: string) {
  await share.revoke(linkId);   // revokes the getqr.id link and the TAPS link
}
```

## Reference

Every call also takes the call options `user`, `agent` and `signal`. Signatures are in `mainsdk/protect`.

### Policy

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.policy.simulate(asset, policy)` | `{ gained, lost }` | Compares a new policy with the asset's current one without changing anything. An invalid policy fails with `MAIN-PROTECT-0015`. |

### Share links

A link is `{ id, url, qr, asset, expiresAt, opens }`. The `url` and `qr` are getqr.id; the TAPS token is never in them. A link still being registered fails with `MAIN-PROTECT-0017`.

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.share.get(link)` | The link | Reads one link. |
| `protect.share.list(asset, { cursor, limit })` | `{ items, next }` | Lists the links of an asset. |
| `protect.share.update(link, change)` | The link | Changes `expiresIn`, `maxOpens` (a whole number from 1) or `actions`. |
| `protect.share.revoke(link, { keepPeople })` | Nothing | Revokes the link and its getqr.id short link. If getqr.id is unreachable the link is already revoked, and the call fails with `MAIN-PROTECT-0014`; call it again to finish. |
| `protect.share.stats(link, { consent })` | `{ opens, optedIn }` | Counts opens. Without `consent: true` it fails with `MAIN-SHARE-0004`. |

### People

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.people.list(asset, { cursor, limit })` | `{ items, next }` | Lists the direct grants on an asset. |
| `protect.people.remove(asset, person)` | Nothing | Removes a person's grant. |

### Delegations and approvals

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.delegate.get(id)` | The delegation | Reads one delegation. Its `state` is `active`, `revoked`, `expired` or `used-up`. |
| `protect.delegate.list({ agent, cursor, limit })` | `{ items, next }` | Lists delegations, optionally for one agent. |
| `protect.delegate.revoke(id, { reason })` | Nothing | Revokes a delegation. |
| `protect.approvals.get(id)` | The approval request | Reads one pending or decided approval. Its `state` is `pending`, `approved`, `denied` or `expired`. |

### MCP tools

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.mcp.manifest()` | `{ tools, keyId, signature }` | The name and description hash of each `taps_*` tool, signed by the app's key. |
| `protect.mcp.verify(manifest, tools)` | `boolean` | Checks the `taps_*` entries of a tools list against a signed manifest. It returns `false` when the key, the signature, a tool or a description does not match. |

### Attestation, ledger and lockdown

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.attest.verify(evidence, { kind })` | `{ kind, accepted, root, reason? }` | Appraises evidence bytes of kind `device`, `environment` or `renderer`. The driver must support attestation. |
| `protect.ledger.proof(asset)` | `{ proof, verdict }` | Proves an asset's registration is in the ledger. The verdict is `valid`, `invalid` or `unanchored`. |
| `protect.lockdown.delete(id, { approvals })` | Nothing | Lifts a lockdown. It needs `approvals` from other people. |

### Events

| Call | Returns | What it does |
| --- | --- | --- |
| `protect.channels.events(pattern)` | `ChannelEvent[]` | Events seen on the channels, optionally filtered by a pattern. |
| `protect.channels.watch(pattern, fn)` | A function | Calls `fn` for each matching event. Call the returned function to stop. |

### Building it yourself

`mainsdk` builds `protect` for you from `main.yaml`. These are for driver authors, tests and tools.

| Name | What it is |
| --- | --- |
| `createProtect(deps)` | Builds the interface on a driver, with the settings and the SDK's services. |
| `localProtect(deps)` | Builds the `local` driver. |
| `new ProtectError(code, message, opts?)` | The error every call throws: a `MAIN-PROTECT-*` code, with the TAPS type in `taps` and the approval in `approvalId` when there is one. |
| `PROTECT_CHANNELS(const)` | The eight `mainsdk:protect.*` channel names. |
| `PROTECT_DRIVERS(const)` | The driver ids a `protect` section may name: `local`, `taps-node`, `taps-hosted` and `sovedocs`. |
| `lintPolicy(policy)` | Checks a policy and returns its issues; `protect.policy.lint()` calls it. |
| `parseProtectSettings(raw)` | Reads and checks the `protect` section, returning `{ settings, issues }`. |
| `protectHosts(settings)` | The hosts the driver reaches, so the network allowlist can declare them. Empty for `local`. |
| `routeShare(base, built, { protect })` | Routes `share(asset, { to })` and `share.revoke()` for a protected asset to `protect`, and everything else to the plain getqr.id share. |

## Protecting web requests is a different tool

`protect` guards files. The headers, CSRF checks, CORS rules and cookie flags that guard your web responses are [inbound protection](https://mainsdk.dev/docs/guides/sanitize-and-protect-inbound), and showing user or model text as safe HTML is `sanitize`; both are covered in that guide and have no interface page of their own.

## Channels, logs and the ledger

Every decision and side effect goes on one of eight `mainsdk:protect.*` channels, to the decision log (`log.audit`, with the actor, `on_behalf_of`, purpose and policy hash) and to the ledger. Channel data is ids, codes and kinds only: never content, names, emails, tokens or IP addresses.

| Channel | Fires on |
| --- | --- |
| `mainsdk:protect.create` | Every `protect()` and `protect.versions.create()` |
| `mainsdk:protect.licence` | Every licence request and renew, issued or denied |
| `mainsdk:protect.revoke` | Every revoke of an asset, person, link or delegation, and every erase |
| `mainsdk:protect.share` | Every link made, changed or revoked, and every grant |
| `mainsdk:protect.delegate` | Every delegation made, refused or revoked, and every approval decision |
| `mainsdk:protect.query` | Every query, answered or refused |
| `mainsdk:protect.inspect` | Every inspection verdict |
| `mainsdk:protect.policy` | Every policy, zone, label, lockdown or owner change |

The local ledger is append-only and hash-chained. `protect.ledger.verify()` finds any edit, deletion or reorder, and `protect.ledger.export()` returns an in-toto Statement in a DSSE envelope, signed by the app's key. Security events are raised for `protect.denied_burst` (20 denials of one asset in a minute), `protect.leak_reported`, `protect.lockdown` and `protect.quarantined`; read them with `protect.channels.security()`.

## Errors and checks

| Case | What happens | Fix |
| --- | --- | --- |
| The policy does not allow the open | `MAIN-PROTECT-0001`; the TAPS type (`zone-denied`, `geo-denied`, `purpose-denied`) is in `taps`, and the failed condition only in the decision log | Check with `protect.policy.check()` |
| Opened before `releaseAt` | `MAIN-PROTECT-0002` with `retryAfter` | Wait for the release time |
| Opened after a revoke or erase | `MAIN-PROTECT-0003` (410 when erased) | Protect and share a new copy |
| Fewer than `t` parties released | `MAIN-PROTECT-0011` | Wait for more parties |
| A change needs a second person | `MAIN-PROTECT-0005` | Pass `approvals` from another person |
| The TAPS Node is unreachable | `MAIN-PROTECT-0014`; nothing opens, the rest of the app keeps serving | Check `protect.url` and the network |
| A call asks for suite `TAPS-CS3` | `MAIN-PROTECT-0010`; no protection beyond CS1 and CS2 is claimed | Use the default suite |

See [Errors](https://mainsdk.dev/docs/reference/errors) for every `MAIN-PROTECT-*` code.

## Swapping drivers

Change one line of `main.yaml`; no call changes.

```yaml title="main.yaml"
protect:
  driver: taps-hosted   # was taps-node
```

The `local` driver is a fake for development and tests, never a Node: `mainsdk check` refuses it in production (`MAIN-CHECK-0026`). Assets protected on `local` stay in development; moving assets between drivers is not specified. A driver that lacks a capability says so in its profile (`query`, `threshold`, `timelock`, `inspect`, `web3`, `delivery`); the call then fails with `MAIN-PROTECT-0019`.

## Testing

`createTestApp()` from [`@mainsdk/testing`](https://mainsdk.dev/docs/interfaces/testing) runs the `local` driver offline with the test clock, so `releaseAt`, time windows, renewal and expiry move with `app.clock`. Run each call as a person with `app.tenancy.as()`.

```ts title="app/papers/protect.test.ts"
import assert from "node:assert/strict";
import { createTestApp } from "@mainsdk/testing";

const app = createTestApp({ yaml: "schema: 1\nproject: papers\n" });
const asset = await app.tenancy.as({ user: "u1" }, () =>
  app.protect(new TextEncoder().encode("Q1 paper"), { label: "sensitive" }),
);
const opened = await app.tenancy.as({ user: "u1" }, () => app.protect.open(asset.id));
assert.equal((await opened.text()).text, "Q1 paper");
```

The kit adds `app.protect.signals({ ip, country, confidence, managed, attested })`, `app.protect.party(name).release(asset)` and `.withhold(asset)`, and `app.protect.chain(holdings)`. The suite in `@mainsdk/testing` runs every call on any driver and lists the cases a profile lacks as skipped, by name.

## Security and privacy

- A protected file is ciphertext without a licence, wherever a copy ends up. Content keys are wrapped with [keys](https://mainsdk.dev/docs/interfaces/keys), and every unwrap is an audit record.
- Each user is a tenant-pairwise principal to TAPS: no user id, email or name reaches a Node or ledger in clear.
- Every asset belongs to one tenant and opens only in its data region. Another tenant gets the same `MAIN-PROTECT-0004` as for an unknown id.
- Every request to a Node, party or sealing service goes through the network layer, so the allowlist, timeouts and retries apply.
- MainSDK claims no post-quantum protection for `protect`. The `TAPS-CS3` suite is refused until it is registered.

### What Cloud adds

`protect` is free in your app. The Fleet plan adds an estate view of protected files and a response action to revoke every file an app or person shared since a date. See [Free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud).

## FAQ

**How is this different from a share link?** A [share link](https://mainsdk.dev/docs/interfaces/share) controls the link; once someone downloads the file, the copy is theirs. A protected file stays ciphertext and asks the TAPS Node for a licence every time it opens.

**Do readers need to install anything?** No. Links open in the TAPS Web Reader.

**What does not work offline or on `local`?** Offline licences, the wrapper session's device-sealed keys and DRM key delivery are out of scope for 0.4. A TAPS Node, a threshold party and a sealing service are not run inside MainSDK.

## Next

- [TAPS Node and SoveDocs](https://mainsdk.dev/docs/integrations/taps): the real drivers and how they install.
- [Share links and QR codes](https://mainsdk.dev/docs/interfaces/share): how protected files are shared.
- [Policy decisions](https://mainsdk.dev/docs/interfaces/policy): the decision log opens are written to.
- [Free in-app and Cloud](https://mainsdk.dev/docs/concepts/free-and-cloud): what Fleet adds.
