---
title: The consent file
description: On-disk TOML format for recorded consent, the schema version rule, and the read/write/reset functions.
---
# The consent file
The `consent` module persists a user's decision about collection so the
answer survives between runs. It is a small, deliberately boring format.
## On-disk format
```toml
version = 1
state = "disabled"
decided_at = "2026-08-02T20:31:52.918653411Z"
```
| `version` | integer | yes | Schema version. Must be exactly `1`. |
| `state` | `"enabled"` \| `"disabled"` \| `"unset"` | yes | The recorded decision. |
| `decided_at` | string | no | RFC 3339 UTC timestamp of the decision. Omitted when absent. |
`decided_at` is written with the sub-second precision the clock reports, as
above — not truncated to whole seconds. It is stored as a plain string
rather than a parsed timestamp so that a future schema version can carry a
richer format without breaking deserialisation of version 1.
## Where the file lives
**This crate does not choose the path.** Every function takes a `&Path`
from the caller. The convention across the toolkit is
`<config_dir>/<tool>/consent.toml`, and the CLI runtime
([rtb-cli](https://cli.rust.phpboyscout.uk)) resolves `<config_dir>` from
`directories::ProjectDirs` and owns the `telemetry status / enable /
disable / reset` commands that write it. A tool not built on rtb-cli picks
its own path and gets to keep both halves.
## `ConsentState` and how it maps to a policy
| `Enabled` | `Enabled` |
| `Disabled` | `Disabled` |
| `Unset` (the `Default`) | `Disabled` |
`Unset` and `Disabled` both mean "do not collect". They are distinct
because a tool usually wants to prompt someone who has never been asked
and never prompt someone who has said no.
The conversion is a `From` impl, so `CollectionPolicy::from(state)` or
`state.into()` both work.
## Constructors
| `Consent::unset()` | `Unset` | `None` |
| `Consent::enabled_now()` | `Enabled` | current UTC time |
| `Consent::disabled_now()` | `Disabled` | current UTC time |
`Consent::SCHEMA_VERSION` is the constant `1`, and every constructor stamps
it.
## Reading, writing and deleting
```rust
consent::read(path) -> Result<Option<Consent>, TelemetryError>
consent::write(path, &consent) -> Result<(), TelemetryError>
consent::reset(path) -> Result<(), TelemetryError>
```
**`read`** returns `Ok(None)` when the file does not exist — a missing file
is not an error, and callers treat it as `Unset`. It returns
`TelemetryError::Serde` for malformed TOML and for a `version` other than
`1`:
```text
serialisation error: consent: unsupported schema version 99 (expected 1)
```
That is a hard refusal, not a downgrade: a file written by a future version
of the format will stop the tool reading consent rather than being
guessed at. Any other filesystem failure — a permissions problem, say —
comes back as `TelemetryError::Io`.
**`write`** creates parent directories on demand and serialises with
`toml::to_string_pretty`. It is **not atomic**: it writes in place, so a
process killed mid-write can leave a truncated file, which the next `read`
will reject as malformed. If that matters, write to a temporary file
yourself and rename it over the top.
**`reset`** deletes the file and returns `Ok(())` if it was already absent,
so `telemetry reset` is idempotent. It deletes the *decision*, not any
collected data — see
[What this does not do](../explanation/what-this-does-not-do.md#there-is-no-data-deletion-path).
## Reading consent at startup
```rust
let policy = match consent::read(&path)? {
Some(record) => CollectionPolicy::from(record.state),
None => CollectionPolicy::Disabled,
};
```
That is the whole integration. Nothing in the crate reads the file for you,
and nothing watches it for changes: a context built from a policy keeps
that policy until it is rebuilt.