Skip to content

Edit permissions

Folio’s permission model is intentionally tiny: each property carries an x-editable-by array of fnmatch patterns (ADR-0007). A direct write is allowed when the actor matches at least one pattern.

The shape

- name: company_name
logicalType: string
required: true
x-editable-by: ["agent:human", "agent:ops:*"]

When Sheet.upsert_records is called with actor="agent:ops:akiko", Folio matches against each pattern. "agent:ops:akiko" matches agent:ops:* (the * glob), so the write is allowed.

If a write fails to match any pattern, Folio raises PermissionDeniedError (HTTP 403 in the Viewer) and writes nothing.

Patterns

x-editable-by accepts any fnmatch pattern. The full set is:

PatternMatches
*Anything
agent:*agent:human, agent:ops:akiko, agent:enrichment, …
agent:humanexactly that string
agent:ops:*agent:ops:akiko, agent:ops:lin, … but not agent:human
agent:human:?agent:human:a, agent:human:b, … (one-character suffix)
[ab]a or b

Folio does not invent its own DSL. The principle is “you already know fnmatch from POSIX shells, no surprise glyphs.”

Convention for actor strings

Folio doesn’t enforce a format, but the convention used throughout the docs and examples/:

  • agent:human — generic interactive human (the Viewer’s default).
  • agent:human:<id> — a specific human, e.g. agent:human:akiko.
  • agent:<role> — a non-human, e.g. agent:enrichment, agent:research, agent:demo.

So a typical x-editable-by for a field humans can edit but bots can’t:

x-editable-by: ["agent:human", "agent:human:*"]

Or, more permissively:

x-editable-by: ["agent:human*"]

(matches agent:human and any agent:human:<id>).

What permission does not affect

  • Reads. query, list_records, get_record ignore actor entirely. Anyone with the directory can read.
  • Materialize writes. Derivations write derived fields. The actor on the materialize call is logged in provenance but is not matched against x-editable-by — derivations are governed by the contract’s x-derived and x-inputs instead.
  • Deletes. delete_records removes whole rows; it does not check x-editable-by (which is per-field).

If you need to gate reads or deletes, that’s an application-level concern, not something the spec covers.

Default behaviour

A property without x-editable-by (or with x-editable-by: []) is not human-editable. Only a derivation may write it.

This is the right default: a field that no one is authorized to edit is either fully derived or fully read-only — both safer than “anyone can edit because we forgot to declare anything.”

Worked example

- name: id
logicalType: string
primaryKey: true
required: true
# primary key — never editable
- name: company_name
logicalType: string
required: true
x-editable-by: ["agent:human"]
- name: industry_tag
logicalType: string
x-derived: true
x-inputs: [company_name]
# not editable: only the derivation writes it
- name: notes
logicalType: string
x-editable-by: ["*"] # anyone, for demo only

A write tries upsert_records:

sheet = open_sheet("./customers", actor="agent:enrichment")
sheet.upsert_records([{"id": "cust_001", "company_name": "X"}])
# → PermissionDeniedError: agent:enrichment may not write company_name
sheet = open_sheet("./customers", actor="agent:human")
sheet.upsert_records([{"id": "cust_001", "company_name": "X"}])
# → OK; appends a "human" provenance line for company_name

Patterns in the wild

Use casePatterns
HR-only fields["agent:hr*", "agent:human:hr-*"]
Anything writeable by humans["agent:human*"]
Specific bots only["agent:enrichment", "agent:summary"]
Demo / sandbox["*"]
Read-only / derivation-only[] (or omit the attribute)

Combined with respect_human_override

x-editable-by decides who can write. respect_human_override (on materialize) decides whether the derivation can overwrite a human edit.

A field with x-editable-by: ["agent:human"] can be edited by a human; the next materialize call sees source: human on the latest provenance line and skips that cell unless --force is passed.

This is the pattern Folio is designed for: agents do the bulk, humans correct the long tail, and respect_human_override keeps the human’s word final.