Documentation
On this page

Example: document management

Folders and documents shared with people and teams, every version a file, beside a shared drive with a restricted Finance folder. An intake agent files a scanned invoice, a colleague approves it, a manager's assistant finds what was shared with the manager, and unsharing ends that access to the document and its files at once.

This page walks through one complete scenario: the model, the six actions, and every request in order, including the ones that are refused. Everything here uses the public API, so you can load the same model into your own organization and replay it.

The invoice in the web app, with its owner, editors, team, and versions

Who takes part

IdentityConnects withTeam
intake-botan agent account that files scanned mailREST, with its API tokenfinance
Ann, in financea person, in your appREST, with a session tokenfinance
Ben, a managera person, through an assistantMCP, with an OAuth grant of ontology:read actions:apply files:readnone
Cleo, in recordsa person, in your appREST, with a session tokenrecords

Every member holds view_objects through everyone, as in any new organization. Only finance holds edit_objects, and it reaches only the drive: the folder and document types are restricted and grant nobody edit_objects. Folders are set up by the owner, and documents change only through the actions below, which every member is granted. The actions decide who may add a version, share, or finalize.

The model

Object typeProperties
foldername, owner, shared_with (a list of users), shared_teams (a list of teams)
documenttitle, owner, editors and viewers (lists of users), teams (a list of teams), status: draft, final, or archived; tags, current_version, current_file, versions

versions is a list of structs, one per version:

JSON
{ "api_name": "versions", "type": { "kind": "array", "items": { "type": { "kind": "struct", "fields": [
  { "api_name": "number", "type": { "kind": "integer" }, "required": true },
  { "api_name": "file",   "type": { "kind": "file_ref" }, "required": true },
  { "api_name": "note",   "type": { "kind": "string" } },
  { "api_name": "author", "type": { "kind": "user_ref" }, "required": true },
  { "api_name": "at",     "type": { "kind": "timestamp" }, "required": true } ] } } } }

Each version’s file is an attachment, uploaded with POST /v1/files. Whoever can see the document can read every one of its files, and nobody else can.

Link typeFrom → toCardinalityNames
folder_parentfolder → foldermany to oneparent / subfolders
document_folderdocument → foldermany to onefolder / documents

The owner seeds four folders:

FolderShared with
Companythe everyone team
Financethe finance team
Invoices, inside Financethe finance team
SalesAnn and Ben

Who sees what

Both types are restricted, with the people and teams they name as their access properties:

JSON
PUT /v1/ontology/object-types/document/access
{ "restricted": true,
  "access_properties": ["owner", "editors", "viewers", "teams"],
  "grants": [
    { "team_id": "<everyone>", "permission": "view_objects", "scope": "assigned" },
    { "team_id": "<records>", "permission": "view_objects" } ] }

Folders are the same, with owner, shared_with, and shared_teams. So every member sees the folders and documents that name them or a team they are on, and records sees all of them. Ben sees Company and Sales; Ann sees all four folders; the agent sees Company, Finance, and Invoices.

Postgres enforces it. A document that is not shared with Ben does not exist for Ben by any route, and neither do its files.

The drive

Beside the documents, drive is a filesystem type: one tree of folders and files, every write of a file a version, with deleted items kept for 14 days.

JSON
{ "api_name": "drive", "display_name": "Drive", "kind": "filesystem",
  "properties": [], "filesystem": { "trash_days": 14 } }

The owner creates /Company, /Finance, and /Finance/Invoices, and restricts /Finance:

JSON
PUT /v1/fs/nodes/<finance folder>/access
{ "restricted": true,
  "grants": [ { "team_id": "<finance>", "level": "write" },
              { "team_id": "<records>", "level": "read" } ] }
WhoSeesMay
everyone/Companyread it; only finance holds edit_objects, which writing needs
finance/Company, /Finance and everything in itwrite both
records/Company, /Finance and everything in itread both

To Ben, /Finance and everything in it do not exist, by any route.

The actions

ActionWho, per its criteriaDoes
create_documentanyone, in a folder they seefiles a document owned by the caller as a draft, with version 1 if a file is given, and links it to the folder
add_versionthe owner and editorsappends the next version, makes it current, and puts a final document back to draft; not for archived ones
share_documentthe owner; editors may add viewersadds a person as viewer or editor, and emails them
share_with_teamthe owner and editorslets a whole team view the document
unshare_documentthe ownerremoves a person or a team
finalize_documentthe owner, for a draft with a versionsets the document final and sends a webhook to the records archive

All six are granted to everyone. Who may do what is in their criteria, because it depends on the document: being its owner or one of its editors.

share_document

A Python criterion decides who may share, and Python edits move the person between the two lists:

Python
def check(ctx):
    doc = ctx['objects']['document']['properties']
    me = ctx['caller']['id']
    role = ctx['params']['role']
    if ctx['params']['user'] == doc['owner']:
        return 'the owner already has the document'
    if doc['owner'] == me:
        return True
    if me in (doc.get('editors') or []) and role == 'viewer':
        return True
    return 'only the owner shares a document, and editors may only add viewers'
Python
def edits(ctx):
    doc = ctx['objects']['document']['properties']
    user = ctx['params']['user']
    editors = [u for u in (doc.get('editors') or []) if u != user]
    viewers = [u for u in (doc.get('viewers') or []) if u != user]
    if ctx['params']['role'] == 'editor':
        editors = editors + [user]
    else:
        viewers = viewers + [user]
    return [{'op': 'update_object', 'object': {'param': 'document'},
             'properties': {'editors': editors, 'viewers': viewers}}]
JSON
"effects": [
  { "kind": "email", "to": { "param": "user" },
    "subject": "{{object.document.title}} was shared with you",
    "text": "You are now a {{param.role}} of {{object.document.title}}." }
]

add_version

A declarative criterion and a Python one:

JSON
"criteria": [
  { "name": "not_archived", "message": "archived documents get no new versions",
    "condition": { "op": "ne", "left": { "object": "document", "property": "status" },
                   "right": { "value": "archived" } } },
  { "name": "editor",
    "code": "def check(ctx):\n    doc = ctx['objects']['document']['properties']\n    me = ctx['caller']['id']\n    if doc['owner'] == me or me in (doc.get('editors') or []):\n        return True\n    return 'only the owner and editors add versions'\n" }
]
Python
def edits(ctx):
    doc = ctx['objects']['document']['properties']
    number = doc['current_version'] + 1
    version = {'number': number, 'file': ctx['params']['file'],
               'author': ctx['caller']['id'], 'at': ctx['now']}
    if ctx['params'].get('note') is not None:
        version['note'] = ctx['params']['note']
    versions = (doc.get('versions') or []) + [version]
    changes = {'versions': versions, 'current_version': number,
               'current_file': ctx['params']['file']}
    if doc['status'] == 'final':
        changes['status'] = 'draft'
    return [{'op': 'update_object', 'object': {'param': 'document'}, 'properties': changes}]

The file parameter is a file_ref, so the caller must be able to read the file: one they uploaded, or one already on an object they can see.

Setting it up

As an owner, through the REST API:

  1. Create the finance team, with edit_objects, and the records team, with no permissions (teams API). Add Ann and the agent to finance, and Cleo to records.
  2. Create the object types and link types (schema API), and restrict both types as above.
  3. Create the six action types and grant each to everyone (actions API).
  4. Create the four folders.
  5. Create the drive type, its three folders, and restrict /Finance (filesystem API).

1. The agent files an invoice

intake-bot uploads the scan:

HTTP
POST /v1/files?name=invoice-1042.pdf
Authorization: Bearer heat_...
Content-Type: application/pdf

201 { "id": "<scan>", "purpose": "attachment", "name": "invoice-1042.pdf", ... }

Only the agent can read the file so far. It also puts a copy in the drive’s /Finance/Invoices:

HTTP
PUT /v1/fs/nodes/<drive invoices>/files/invoice-1042.pdf
Content-Type: application/pdf

201 { "id": "<drive invoice>", "path": "/Finance/Invoices/invoice-1042.pdf",
      "content_version": 1, "level": "write", ... }

It files the document in Invoices with the scan as version 1:

HTTP
POST /v1/actions/create_document/apply
{ "params": { "title": "Invoice 1042 - Acme Paper", "folder": "<invoices>",
              "file": "<scan>", "note": "Scanned", "tags": ["invoice", "acme"] } }

201 { "created": [{ "type": "document", "ref": "document", ... }], ... }

The agent owns the document, so it sees it. Then it shares it with finance, with an idempotency key:

HTTP
POST /v1/actions/share_with_team/apply
{ "params": { "document": "<invoice>", "team": "<finance>" },
  "idempotency_key": "share-1042" }

201 { "application_id": "<a2>", "replayed": false, ... }

The answer is lost, so it sends the same request again: 200 with replayed: true and the same application_id. Nothing is applied twice.

2. Finance reads it; Ben does not

Ann is in finance, so Ann reads the invoice and downloads its scan: GET /v1/ontology/objects/document/<invoice> and GET /v1/files/<scan> are both 200. For Ben, not in finance, both are 404: to Ben neither the document nor the file exists.

Denied. Ann tries to change the title directly:

HTTP
PATCH /v1/ontology/objects/document/<invoice>
{ "properties": { "title": "x" } }

403 { "error": { "code": "forbidden", ... } }

Ann’s team holds edit_objects, but the document type is restricted and grants it to nobody: documents change only through actions.

Refused by a criterion. Ann uploads an approval stamp and tries to add it as a version. Seeing the document through a team makes Ann a viewer, not an editor:

JSON
422 { "error": { "code": "criteria_failed",
      "details": [{ "path": "criteria.editor",
                    "message": "only the owner and editors add versions" }] } }

3. Two versions at once

The agent makes Ann an editor (share_document, role editor), and Ann is emailed “Invoice 1042 - Acme Paper was shared with you”. Ann reads the document and notes its version, 3.

Meanwhile the agent uploads a better scan, writes it as the next version of the drive’s copy (PUT /v1/fs/nodes/<drive invoice>/content: 200, content_version 2), and adds it to the document, stating the version it read:

HTTP
POST /v1/actions/add_version/apply
{ "params": { "document": "<invoice>", "file": "<rescan>", "note": "Better scan" },
  "expected_versions": { "document": 3 } }

201 { "updated": [{ "type": "document", "version": 4, ... }], ... }

Conflict. Ann adds the approval against version 3, read before the rescan. Nothing is applied:

JSON
409 { "error": { "code": "version_conflict",
      "details": [{ "path": "params.document", "message": "..." }] } }

Ann reads the document again and retries with version 4: 201. The document now has three versions, by the agent, the agent, and Ann, and current_file is Ann’s approval. Every version’s file can be read by everyone who sees the document, including the scan the agent uploaded.

4. Ann shares a budget with Ben

Ann uploads q3-budget.pdf, files it in Sales with create_document, and shares it with Ben as a viewer:

HTTP
POST /v1/actions/share_document/apply
{ "params": { "document": "<budget>", "user": "<ben>", "role": "viewer" } }

201 { ... }

Ben is emailed “Q3 budget was shared with you”. Ben can now download the budget’s file, but still not the invoice’s (404).

5. Ben’s assistant

Ben connects an MCP client and grants it ontology:read actions:apply files:read. Asked for every document (query_objects on document, no filter), it gets one: the budget. Asked for folders, it gets Company and Sales. A search for budget in titles finds the same document:

JSON
{ "tool": "query_objects",
  "arguments": { "type": "document",
                 "filter": { "op": "contains", "property": "title", "value": "budget" } } }

Asked for the invoice by id (get_object), it gets an error: the invoice does not exist for Ben.

Search says the same. With ontology:read and files:read, the search tool looks through documents, folders, and the drive. budget finds the budget; invoice finds neither the invoice nor the drive’s copy of it, which exist only for finance and records:

JSON
{ "tool": "search", "arguments": { "query": "invoice" } }

→ { "results": [], "kinds": ["object", "file"], "next_cursor": null }

In the drive, it finds only Company:

JSON
{ "tool": "list_files", "arguments": { "type": "drive" } }

→ { "children": [ { "name": "Company", "kind": "folder", "level": "read", ... } ], ... }

Asked for the drive’s copy of the invoice by path, it gets not_found, as it would for a file that was never written:

JSON
{ "tool": "read_file",
  "arguments": { "type": "drive", "path": "/Finance/Invoices/invoice-1042.pdf" } }

→ error { "code": "not_found", ... }

Ben’s own GET /v1/fs/nodes/<drive invoice> is 404 too.

Refused by a criterion. Asked to make Ben an editor, it tries anyway:

JSON
{ "tool": "action_share_document",
  "arguments": { "document": "<budget>", "user": "<ben>", "role": "editor" } }

→ error { "code": "criteria_failed",
          "details": [{ "path": "criteria.may_share",
                        "message": "only the owner shares a document, and editors may only add viewers" }] }

Adding a version is refused the same way, at criteria.editor.

Denied. It tries to create a document of its own with create_object: forbidden. Ben holds no edit_objects, and the grant has no ontology:write.

6. Records, and the end of sharing

Cleo, in records, sees both documents, whoever owns them, but may not finalize one:

JSON
422 { "error": { "code": "criteria_failed",
      "details": [{ "path": "criteria.owner",
                    "message": "only the owner finalizes a document" }] } }

In the drive, Cleo reads the invoice’s copy at level read, lists its two versions (GET .../versions: 2, then 1), and downloads the first scan (GET .../content?version=1). Writing a stamped copy is 403: records may only read /Finance. For Ann, in finance, the same file shows level write.

Ann, the budget’s owner, finalizes it: 201, and after commit the records archive’s webhook is sent. Then Ann stops sharing it with Ben:

HTTP
POST /v1/actions/unshare_document/apply
{ "params": { "document": "<budget>", "user": "<ben>" } }

201 { ... }

At once, Ben’s assistant finds no documents, and Ben’s download of the budget’s file is 404.

What everyone saw

The audit log (GET /v1/action-applications) holds nine applications:

ActionByVia
create_documentintake-botrest, api_token
share_with_teamintake-botrest, api_token
share_document (Ann as editor)intake-botrest, api_token
add_version (rescan)intake-botrest, api_token
add_version (approval)Annrest, session
create_document (budget)Annrest, session
share_document (Ben as viewer)Annrest, session
finalize_documentAnnrest, session
unshare_documentAnnrest, session

The replayed share and every refused call appear nowhere: they changed nothing. The background job sent the two “was shared with you” emails and the archive’s webhook.

What this shows

  • Sharing is data. Who sees a document is in its own properties, users and teams alike, and the database enforces it for every read, query, event, action, and MCP tool.
  • Files follow the objects that refer to them. Sharing a document shares every version’s file, and unsharing it ends that at once. There is no separate file permission to keep in step.
  • The rules live in the actions. Who may add a version, share, or finalize depends on the document, and every client, the agent’s, Ann’s, and Ben’s assistant, meets the same rules.
  • Teams change access in one place. Adding someone to finance shows them every invoice shared with it; removing them hides those at once.
  • Concurrent edits do not overwrite each other. The second version stated what it read, and was refused until it read again.
  • A restricted folder hides everything in it. The drive’s /Finance is seen by finance and records only, and each reads or writes there at the level its grant gives. Ben’s assistant cannot list, find, or read anything in it.

What it does not do yet

  • Folder objects do not pass their sharing on. Each document carries its own access properties. Sharing a folder does not share its documents, and an action cannot yet update objects it was not given. The drive’s restricted folders do cover everything in them, but grant teams only, not single people.
  • No share links. Everyone who reads a document is a member of the organization.
  • Attachments are not searched. Search finds titles, tags, and the drive’s names and text files, but not what is inside the scanned PDFs.
  • MCP clients cannot upload attachments. They can refer to files their user can already read, and with files:write they could write files in the drive.
  • No upload quota, and attachments that no object refers to are kept.
Esc
↑ ↓ navigate↵ open