1. Introduction

Plaid is a platform, which means that it is not a complete app on its own. Rather, it provides an implementation of the "boring" parts of a linguistic annotation app, including a user system, a database layer, and more. Building an app with Plaid allows you to focus your attention on just the interesting parts of your app including the UI and logic which is specific to your annotation framework.

The purpose of this document is to teach you how to think about and use Plaid. If you’d rather just dive into a real app programmed with Plaid, check out the UD editor example app.

2. Key Concepts

2.1. Projects and Layers

In Plaid a project is a collection of documents which all have the same data model. Plaid’s data model is configurable on a per-project basis, allowing you to have as many or as few annotation types as you would like. For example, perhaps in one project you might only annotate a single part-of-speech tag per word, while in another you might want a POS tag, a lemma, and a gloss for each word. These projects would have different configurations in order to appropriately accommodate the annotation needs of each.

The means by which this configuration is performed is the layer. (If you have used ELAN before, note that this is similar to ELAN’s tiers.) Loosely speaking, a layer corresponds to a single "type" of annotation: for instance, in the example above where we want to collect a POS tag, lemma, and gloss for each word, we would have three span layers associated with the project. We will discuss layers more shortly.

2.2. Users and Permissions

Plaid offers a built-in user system which allows individuals to log in with a password. By default, each project is private, and one of three permissions levels is needed in order to interact with one:

  • Maintainers have full privileges for working with projects: they may edit documents and also modify the project’s configuration.

  • Writers may edit documents belonging to a project, but may not modify the project’s configuration.

  • Readers may only read documents belonging to a project—they may not make any edits to the project’s documents or configuration.

Additionally, a global admin role exists, which allows the user to see and edit all data in the Plaid instance.

A request for a project, document, vocabulary or user that does not exist answers 404 to an admin. Anyone else gets the 403 they would get for one that exists but is not theirs, so the status does not tell them whether it exists. When the id a request names (in its path, its body, or an op of a /batch) resolves to nothing, the 403’s body also carries "unresolved": true beside its error. A client reads that as "changed or removed by someone else" rather than as a missing permission. A user’s record and profile picture are readable by every signed-in user, so an unknown user there is a 404 to everyone. Adding or removing a project member or vocabulary maintainer who has no account is a 400.

Permissions say what a person may do, not how far their work is trusted. A project may separately mark members whose work needs a second look, whatever their role; that mark lives in the project’s configuration and is described under Provenance.

Each user may upload a profile picture, which appears wherever they are named in the apps: the header, project and vocabulary rosters, and the user administration screen. Pictures are normalized on the server, which crops them to a square, scales them to a single configured size ([media] avatar_size_px), and re-encodes them. That last step also strips camera metadata, so GPS coordinates embedded in a phone photo are not served back out to everyone else in the instance. You may set or remove your own picture, and an admin may set or remove anyone’s. A user with no picture is shown their initials instead.

Signing out of an app forgets the sign-in in that browser tab only. To end every sign-in of your account on every device, for example on a laptop left signed in elsewhere, call client.auth.logout_everywhere() (client.auth.logoutEverywhere() in JavaScript), which sends POST /api/v1/logout. Every sign-in token of yours stops working at once, the calling client’s included. Named API tokens are not affected: revoke one with api_tokens.revoke (apiTokens.revoke).

2.2.1. Private user data

Each user also has a small private key/value store (/users/{id}/data/{key}), meant for application state that should follow a person across devices and sessions but belongs to nobody else: assistant conversations, drafts, interface preferences. A key is any string the client chooses (apps namespace theirs, e.g. igt:assistant:<project>:…​), the value is any JSON document up to 1 MB, and the store can be listed by key prefix or by a glob over the whole key ( for any run, ? for one character, e.g. igt:assistant::meta:*), optionally with values. The listing is ordered by key and paginated like every other collection (see Pagination). A page asked for with values is the largest response the API can be made to produce, so the clients page this one at 100 entries a request rather than the usual 1000, a bound their list methods take as page_size / pageSize. Only the owning user and admins can read or write it; nothing in it is audited or time-travelable, and it is deleted with the user. An admin can also list entries across every account at once (/admin/user-data), narrowing by key prefix or by a glob over the whole key — which is how an app browses one feature’s records without dragging every other key’s value along. It is not a place for annotation data, which belongs in layers where it is shared, audited, and queryable, nor for media, which has its own endpoints.

2.2.2. Guidelines

A project may carry a flat list of guidelines (/projects/{id}/guidelines, /guidelines/{id}): short Markdown documents recording the conventions the people working on it have agreed to — how a hard case is annotated, what a field is for, what the project leaves alone. Each has a title (the handle an assistant asks for one by), a Markdown body, and a pinned flag that an assistant reads as "send this one in full on every turn". Titles are deliberately not unique: refusing a write because a title is taken would throw away a document that had just been typed, so a client warns about it instead, and anything resolving a guideline by title has to answer for every match rather than pick one. Reading takes Reader access and writing takes Writer, matching comments rather than layer configuration: the people who annotate are the people who discover what the conventions have to be. The list is ordered by title and paginated like every other collection (see Pagination); without include-bodies each entry carries body-chars instead of its text, so a caller can budget before fetching any. A PATCH may be made CONDITIONAL by passing updated-at (the updated-at the caller last read): if somebody else saved in between the write is refused with a 409 and nothing is written, which is what stops two people editing one guideline from silently overwriting each other. Omit it and the write is unconditional, which is what a pin toggle or a script wants. Writes ARE audited, so a change appears in the project’s audit feed with the row as it now stands and announces on its event stream — but guidelines are project-scoped rather than document-scoped, so they are not time-travelable: ?as-of= on these routes is a 400, not an answer. They are deleted with the project.

2.2.3. Research telemetry

A project may record how the people working in it respond to machine suggestions, for research on how linguists work with agents. It is off unless a maintainer switches it on, which sets config.plaid.research.telemetry to true on the project (the Research box in the igt and ud project settings, or projects.set_config(id, "plaid", "research", {"telemetry": True})). While it is off the apps send nothing and the server refuses every event with a 403, so off means off whatever a client does.

What is recorded is a closed set of four event types. The server refuses any other.

Type Recorded when

suggestion.shown

A machine suggestion becomes visible, today a guess in an igt cell. target-id is the word or morpheme, data holds value (the suggested value), source (the producer’s name or model) and field. Recorded once per target, field and value in each page session.

suggestion.adopted

The suggestion is taken as it is, in one cell or across a word. target-id and data as for suggestion.shown: value, source and field.

suggestion.dismissed

The suggestion is rejected, or a different value is written where it showed. target-id and data as for suggestion.shown, plus written, the value written instead.

plan.opened

An assistant plan card is expanded to show all of its changes. target-id is the plan, data holds conversation, the conversation’s id.

Nothing else is recorded: no keystrokes, clicks, focus, time spent or idle time. The server holds to this as it does to the types. data may hold only the keys its type names above, each a string of at most 200 characters or a number, and may leave any of them out. An event with any other key, or with a value that is null, true or false, a list, an object or a longer string, refuses the request with a 400 that names the event and the key. The JavaScript recorder leaves out a key with no value and cuts a longer string to 200 characters.

POST /api/v1/projects/{id}/events takes an array of at most 500 events, each {type, document-id?, target-id?, data?, client-ts?}, and needs write access. The server stamps the user and its own time ts; client-ts is the browser’s time when the event happened. One bad event refuses the whole request with a 400 that names it. GET /api/v1/projects/{id}/events lists them in arrival order for a maintainer or an admin, paginated like every other collection (see Pagination), and narrows with types (comma-separated) and start-time / end-time on ts. Both clients read them with events.list / list_page / iter_pages (listPage / iterPages in JavaScript); the JavaScript client also has events.record(type, {projectId, documentId, targetId, data}), which buffers events and sends them every ten seconds or fifty events, and when the page is hidden, dropping a batch that fails. It reads the project’s switch itself and sends nothing while it is off.

Events sit outside the audit log: they have no audit rows, change no document version and are not time-travelable. They are deleted with the project. An event about a document keeps that document’s id after the document is deleted, since the deletion itself stays in the audit log.

2.3. Clients

Plaid’s functionality is exposed for development as a REST API. This allows you to use Plaid from any programming language with an HTTP client library.

Additionally, we provide official clients for two programming languages, in plaid-client-py/ (Python) and plaid-client-js/ (JavaScript). These clients provide an API for interacting with Plaid that is idiomatic for each programming language and frees you from concern about low-level details of HTTP requests. Here is an example of how to use the Python client:

client = PlaidClient("http://localhost:8080", "<SECRET_TOKEN>")

projects = client.projects.list()
print("Available projects:", projects)

first_project_id = projects[0]["id"]
first_project = client.projects.get(first_project_id)
print("First project:", first_project)

documents = client.projects.list_documents(first_project_id)
first_document_id = documents[0]["id"]
first_document = client.documents.get(first_document_id, include_body=True)
print("First document:", first_document)

The same, in JavaScript:

const client = new PlaidClient("http://localhost:8080", "<SECRET_TOKEN>")

const projects = await client.projects.list()
console.log("Available projects:", projects)

const firstProjectId = projects[0].id
const firstProject = await client.projects.get(firstProjectId)
console.log("First project:", firstProject)

const documents = await client.projects.listDocuments(firstProjectId)
const firstDocumentId = documents[0].id
const firstDocument = await client.documents.get(firstDocumentId, true)
console.log("First document:", firstDocument)

2.3.1. Pagination

Collection endpoints (documents, users, API tokens, audit, and similar lists) are paginated. Over raw HTTP they return an envelope of the form {"entries": […​], "next-cursor": "…​"}, where each page holds at most 100 entries by default (1000 maximum, set via ?limit=). To fetch the next page, pass the opaque next-cursor value back verbatim as ?cursor= (it is null on the final page). The cursors are keyset-based, so pages stay stable under concurrent inserts.

The official Python and JavaScript clients handle this for you: their .list()-style methods (e.g. projects.list(), projects.list_documents(…​) / listDocuments(…​)) transparently follow the cursors and return the full flat list. Per-page variants (e.g. list_documents_page / listDocumentsPage) are available when you want to drive pagination yourself.

2.4. Audit Log

Every write to the database is recorded in an append-only audit log which provides an account of who performed every change. Audit history is retained indefinitely — it is both the forensic record and the source for document time travel (see below), so pruning it trades away history. Operators monitor disk via the /health endpoint’s audit block.

The log can be queried like so:

for entry in c.documents.audit("35424d64-a077-4a29-8006-5a0c3b76aedb"):
    time = entry["time"]
    who = entry["user"]["display_name"]
    label = entry.get("message") or "; ".join([o["description"] for o in entry["ops"]])
    print(f"{who}, {time}: {label} ({len(entry['ops'])} writes)")

# Output:
# Luke G, 2025-07-05T09:13:39.614Z: Create document "Document 1" in project 0f0f0574-ae5a-4060-814c-c5bbdce14d67 (1 writes)
# Luke G, 2025-07-09T20:27:59.611Z: Merge morphemes (4 writes)

2.4.1. Logical operations

Each entry in the log is a logical unit, not necessarily a single write. Many user-meaningful actions are implemented as a run of low-level writes: "merge two morphemes" deletes tokens, creates one, moves spans, and so on. To keep the log readable, a client can declare the run as one operation with a human label. Every write made while the operation is open is tagged with it, and the audit endpoints fold the tagged writes into ONE entry whose message is your label and whose ops are the individual writes (each with its own auto-generated description):

with c.operation("Merge morphemes"):
    c.tokens.delete(left_id)
    c.tokens.delete(right_id)
    c.tokens.create(...)
await c.withOperation("Merge morphemes", async () => {
  await c.tokens.delete(leftId);
  await c.tokens.delete(rightId);
  await c.tokens.create(...);
});

A manual form exists too (c.begin_operation(msg) / c.end_operation(), c.beginOperation(msg) / c.endOperation()) for an operation whose end is not lexically scoped, for example one that spans a UI interaction. Ending an operation is purely local unless you pass a refined label (c.end_operation("Merged 3 morphemes"), or op.set_message(…​) / setMessage(…​) inside the scoped form), which relabels the entry now that the outcome is known.

Things to know:

  • An operation is not a transaction. If the third of five writes fails, the first two stay committed and appear in the log under the operation. Use a batch (c.batched()) inside the operation for any step that must be all-or-nothing. A batch that is not inside an operation is still shown as one entry (its writes share a batch_id), just without a label.

  • Nesting flattens. Opening an operation while one is open joins the outer one; the outer label wins. This lets composed actions call each other without worrying about it.

  • Broadcast messages never join. A message sent with send_message/sendMessage is not saved and writes nothing to the log, so it is never part of an operation, even when sent inside one.

  • Services join automatically. A request to a service made while an operation is open carries the operation with it, and the service’s writes fold under your entry, so "re-transcribe" can be one entry covering both the client’s cleanup writes and everything the ASR service created. A request made with no_operation=True (noOperation: true in the options of requestService) carries none, and the service’s writes are an entry of their own. A client has one open operation at a time, so a request that is its own action, such as approving an assistant’s plan or starting a service run, is made that way: an edit still saving when it is sent would otherwise take in all of its writes. The apps make them so.

  • An operation is its caller’s. A write may join an operation that its caller started, or one handed to it with a service request that is still running. The caller is the user, whichever session or named token they write with, but a delegated token counts only for the operations its own writes started. A service joins the operation it was handed only with writes in the project the request was made in, and only until the request ends, which it also does when the service’s connection drops or the server restarts. A write that names any other operation is refused with 403 and writes nothing, and so is a service request that carries one. An operation that a service’s first write starts for a request is the requester’s, who may write into it and relabel it afterwards.

  • Time travel targets. Each entry carries time (its first write) and end_time, the time to read at to see the whole operation done. To view the document as it was after the operation, pass end_time as as_of. Each op in ops carries an end_time of its own as well: its time when it was written alone, or the time of its batch’s last write when it ran in a batch, because a read at a time inside a batch shows the document as it was before the whole batch. To view the state after one op, pass ops[i].end_time, not ops[i].time.

  • The label is client-supplied free text for readability only. The structured forensic record (the operation type and the per-row images) is unchanged.

Kinds of operation

An operation may also say what kind of operation it is and what it refers to. The label is for a person to read. The kind and the reference are for a program: a study of how a project was annotated can count its operations by kind without reading labels.

with c.operation("Assistant: gloss 12 words", kind="assistant-plan",
                 ref="conv:4f2a/plan:9c1e/service:igt:assist:glm-5.2"):
    ...
await c.withOperation("Import ELAN corpus", async () => {
  ...
}, { kind: "import", ref: "format:elan" });

The manual forms take them too: c.begin_operation(msg, kind=…​, ref=…​) and c.beginOperation(msg, { kind, ref }). On the wire they are ?group-kind= and ?group-ref= beside ?group-id=. The audit endpoints return them as kind and ref on the entry, and GET /operation-groups/:id returns them too, each only when the operation has one.

The kind is one of this list. The server refuses any other value with a 400, so a misspelled kind never reaches the log.

Kind What it is ref

assistant-plan

An assistant plan the user approved, being applied.

conv:<conversation id>/plan:<plan id>/service:<service id>. The service id comes last and may itself hold a /.

service-run

One run of a service, with every write the service made for it.

service:<service id>, or builtin:<name> for a rule an app runs in the browser: IGT writes rule-based-punctuation (its tokenizer), analysis-copy and precedent (Auto-analyze’s copy and linking steps)

import

A file or archive read into a project.

format:<format>: the apps write native, fwbackup, flextext, elan, cldf and table (a vocabulary’s Bulk Add) in igt, conllu in UD and umr in UMR

bulk-edit

One change made across many places at once, such as igt’s Bulk Edit or UD’s Grew rewrite.

action:<name>: igt writes respell, replace, reanalyze and merge (Bulk Edit) and vocab-replace (a vocabulary’s Replace), UD writes grew-rewrite

guess-adoption

A person taking a suggested value as their own: in igt, a guess or a value from a cell’s alternatives put into an empty cell, or a word accepted together with its guesses. Accepting what is already stored, with no guess in it, is a review.

none

repair

A repair an app makes by itself when a document opens.

none

review

A person accepting machine or contributed work already stored, as it stands. In igt that is a word’s analysis accepted with nothing guessed in it, a sentence field accepted, or a vocabulary link or multi-word expression accepted. In UD it is a word’s or a sentence’s predictions accepted, and in UMR a node or a sentence’s graph accepted. An accept that also takes a guess is a guess-adoption instead.

none

The reference is free text of at most 1024 characters, and a longer one is refused rather than cut. Both are recorded from the operation’s first write, like the label, and never change afterwards: relabelling an operation changes only its label. Nesting flattens here too, so a service joining your operation, or an operation opened inside yours, records your kind and reference, not its own. Both are optional.

2.4.2. Custom messages for single writes

For a single write, you can replace its auto-generated description by passing a message as the audit_message / auditMessage argument of a write method that has one. It is the last argument of most writes. In JavaScript, projects.delete and documents.uploadMedia take an options object after it, and user-data and comment writes take none:

c.spans.set_metadata(span_id, {"approved": True}, audit_message="Approve span {span_id}")
# logged as: Approve span 35424d64-a077-4a29-8006-5a0c3b76aedb
await c.spans.setMetadata(spanId, { approved: true }, "Approve span {spanId}");

The message may template any of the endpoint’s own path, query, or body parameters with {…​} placeholders, filled in by the server. Placeholder names are matched case- and separator-insensitively, so {spanId}, {span-id}, and {span_id} all refer to the same parameter: just write the parameter the way your client spells it (snake_case in Python, camelCase in JavaScript). A placeholder that names no parameter of the request is left as-is. The message is stored verbatim, so do not template a parameter that may carry a secret (e.g. a password) into the log. To label a run of several writes, use an operation (above) rather than repeating a message on each write.

2.4.3. Filtering by operation type

All three audit endpoints accept op_types / opTypes, which narrows the read to the operation types you name. The useful case is a client that caches something derived from the project and wants a cheap way to ask whether that cache is stale, without paging the whole log:

# Has anything about the project's span layers changed since I last looked?
changes = c.projects.audit(
    project_id,
    start_time=last_checked,
    op_types=["span-layer/create", "span-layer/delete", "span-layer/update"],
)
const changes = await c.projects.audit(
  projectId, lastChecked, undefined, undefined,
  ['span-layer/create', 'span-layer/delete', 'span-layer/update'],
);

Write each type exactly as it appears in an entry’s ops[i].type, which is entity/verb: span-layer/create, document/update, token/bulk-delete. An op type that is not spelled that way is rejected with a 400 rather than silently matching nothing.

Warning
The SSE stream spells the same operation differently — span_layer:create, snake_cased and colon-separated. See Event Reference. Use the entity/verb form here.

Filtering applies to individual operations, exactly the way the time window and the endpoint’s own scope do. An entry appears when one of its operations matches, and carries only the operations that matched. So a batch that created a span layer and fifty spans comes back, under a span-layer/create filter, as that batch holding its one layer-create.

2.4.4. Filtering by kind

Every audit endpoint also accepts kinds, which keeps only the entries of operations of the kinds you name (see Kinds of operation), as a list or a comma-separated string:

# Every accept in a document, and every guess taken
reviewed = c.documents.audit(document_id, kinds=["review", "guess-adoption"])
const page = await c.projects.auditPage(projectId, {
  kinds: ["review", "guess-adoption"], order: "desc",
});

On the wire it is ?kinds=review,guess-adoption. A kind outside the list is refused with a 400, for the same reason a misspelled op type is. Unlike the op-type filter, this one keeps or drops an entry whole, since the kind belongs to the operation and not to its individual writes. An entry that is not an operation with a kind (an untagged operation, a batch, a single write) never matches. The two filters combine: with both, an entry must be of one of the kinds and still carries only the writes of the op types named.

2.4.5. When you last touched each document

my_last_edits / myLastEdits answers, in one request, when the calling user last wrote to each document in a project. It returns a {document id: timestamp} map, and a document the caller has never written to is simply absent.

mine = c.projects.my_last_edits(project_id)
const mine = await c.projects.myLastEdits(projectId);

This is read from the log rather than kept as a field, so it accounts for every write to the project, whether through either client, an import, a service, or another application on the same substrate. It is as true of documents written years ago as of the one edited a minute ago. Deriving the same answer by paging the audit log would mean reading a project’s whole history, where this is one indexed read.

2.5. Real-time Messaging

Plaid offers a simple system for real-time communication on a per-project basis. This is intended to support two purposes:

  • Ad hoc client-to-client features which you will implement on top of this communication channel, such as chat between individual annotators.

  • Audit log listening, allowing clients to receive immediate notice whenever a change has been made to any document in the project. (Note that these are sent automatically by Plaid.)

Note
Messaging is a general-purpose medium. For the common case of plugging in an external tool—an NLP model, a tokenizer, an importer—that performs work on request, use Services below, which is purpose-built for that and addresses requests to a specific service rather than broadcasting them.

This functionality is exposed in two simple functions in the client. The send_message/sendMessage function allows a client to broadcast a message to all clients in the project:

client.messages.send_message(project_id, {"purpose": "ping", "message": "ping"})

Note that the second positional argument, the body, can be any JSON value. A message is not saved and does not appear in the audit log, so it never joins an open operation (see Logical operations). Made on a batch it still waits for the batch, so a message queued after the writes goes out after them.

On the other end, a client may listen like so. Note that there are two arguments for the message. event_type is "message" for data sent via send_message/sendMessage by another client, and "audit-log" for audit log notifications. Consider an example of listener setup:

def on_event(event_type, event_data):
    if event_type == "message":
        sender = event_data["user"]
        time = event_data["time"]
        contents = event_data["data"]
        print(f"User {sender} sent data `{contents}` at {time}")
    elif event_type == "audit-log":
        user = event_data["user"]
        time = event_data["time"]
        op = event_data["ops"][0]
        op_type = op["type"]
        document_id = op["document"]
        description = op["description"]
        print(f"User {user} performed operation `{op_type}` on document {document_id} at {time}: '{description}'")


client.messages.listen(project_id, on_event)

After the send_message invocation we just saw, this on_event function would produce the following output:

User user1@example.com sent data `{'purpose': 'ping', 'message': 'ping'}` at 2025-07-09T20:14:36.168Z

And suppose that another client executed the following code:

client.documents.update(
    "35424d64-a077-4a29-8006-5a0c3b76aedb",
    name="New Document Name")

The listener’s code above would print this:

User user1@example.com performed operation `document:update` on document 35424d64-a077-4a29-8006-5a0c3b76aedb at 2025-07-09T20:27:59.616Z: 'Update document 35424d64-a077-4a29-8006-5a0c3b76aedb name to "New Document Name"'

2.5.1. Event Reference

GET /api/v1/projects/{id}/listen is an ordinary text/event-stream, so you can consume it without the official clients. Every frame is a named SSE event whose data: line holds one JSON object. Reader access to the project is required, and the connection carries the same Authorization: Bearer header as any other request.

Four event names appear on the stream:

connected

Sent once, immediately on connect: {"status": "connected", "client-id": "<uuid>"}. The client-id is what heartbeat confirmations quote.

heartbeat

A liveness ping, sent every 30 seconds by default. Its data: line is the JSON string "ping". You must answer each one with POST /api/v1/projects/{id}/heartbeat, whose body is {"client-id": "…​"} quoting the id from the connected event. After two consecutive unanswered pings the server closes the connection.

message

A payload broadcast by another client through send_message/sendMessage.

audit-log

One database write, sent automatically by Plaid.

The official clients answer connected and heartbeat for you and never surface them, so a listener callback registered with listen sees only message and audit-log.

message
{
  "type": "message",
  "id": "61094844-5c70-46e2-be8f-e65dc42f9c7e",
  "project": "b1f2c4d6-0000-4000-8000-000000000001",
  "user": "user1@example.com",
  "time": "2025-07-09T20:14:36.168Z",
  "data": {"purpose": "ping", "message": "ping"}
}

data is whatever value was handed to send_message/sendMessage, reproduced exactly. It is opaque application data, so, like metadata and config elsewhere in the API, its keys are never re-cased by the clients: a key written as case-marker arrives as case-marker in both Python and JavaScript. The envelope around it follows the usual per-language convention.

audit-log
{
  "type": "audit-log",
  "id": "01a04604-ff64-70ae-9362-8036acdca9bf",
  "projects": ["b1f2c4d6-0000-4000-8000-000000000001"],
  "documents": ["35424d64-a077-4a29-8006-5a0c3b76aedb"],
  "user": "user1@example.com",
  "time": "2025-07-09T20:27:59.616Z",
  "ops": [
    {
      "id": "01a04604-ff64-70ae-9362-8036acdca9bf",
      "type": "document:update",
      "project": "b1f2c4d6-0000-4000-8000-000000000001",
      "document": "35424d64-a077-4a29-8006-5a0c3b76aedb",
      "description": "Update document 35424d64-a077-4a29-8006-5a0c3b76aedb name to \"New Document Name\""
    }
  ]
}
  • ops always holds exactly one element. Every write announces itself separately, and its id equals the enclosing event’s id. An atomic batch is not folded into one event: it emits one audit-log event per sub-operation, all of them after the batch commits, so you never observe a state the database rolled back.

  • type on an operation is entity:verb, with the entity name snake_cased: document:create, span:delete, token_layer:update.

  • projects and documents are arrays of ids. documents folds in every document whose version the write bumped, which is how multi-document writes (deleting a vocab item, for instance) reach document-scoped listeners. Either may be empty: a project-level write such as text_layer:create carries no document, and an operation’s own project or document field is null in the same situation.

  • user is a user id, which is the login address.

  • time is stamped when the event is published, not when the row was written. For a single write those are the same instant to within a millisecond, but a long atomic batch publishes only after it commits, so its events can be measurably later than the operations they describe. Correlate by id, never by time.

Relationship to the audit log endpoint

An audit-log event names the same operation that GET /api/v1/projects/{id}/audit reports, but the two payloads are deliberately not interchangeable. The event is a lightweight notification designed to be cheap to emit on every write, while the endpoint is an enriched query result. The differences:

SSE audit-log event GET …​/audit entry

Key names

Bare: id, user, ops, and on an operation type, project, document.

Namespace-prefixed: audit/id, audit/user, audit/ops, op/type, op/project, op/document.

Operation type spelling

document:create (colon, entity snake_cased)

document/create (slash, entity kebab-cased)

References

Bare id strings.

Hydrated objects, e.g. {"user/id": …​, "user/display-name": …​} and {"document/id": …​, "document/name": …​}.

Grouping

One operation per event, always.

Batches and labeled operations are folded into a single entry holding all their member operations, with audit/end-time, audit/batch-id, audit/group-id, and audit/message where they apply.

Extra fields

None.

op/time, op/end-time, op/user, op/batch-id, and audit/api-token.

If you need the enriched form, treat the event as a trigger and read the endpoint, matching on id.

2.6. Services

A service is an external program that registers itself on a project and performs work on request. The motivating case is NLP: you might have a Python program that runs a parser, a tokenizer, or a large language model, and you want annotators to be able to invoke it from the editor with the click of a button. Because a service is just another client speaking to Plaid’s REST API, it can be written in any language and run anywhere that can reach the server—it authenticates with an ordinary token (typically a named API token; see Clients) and needs writer access to the project.

There are two sides to the system: a program that serves (offers to do work) and a client that requests work. The server sits in the middle: it tracks which services are currently connected, routes each request to the one service it names, and relays that service’s replies back to the requester. Nothing is broadcast—a request reaches only its target service, and a reply reaches only the client that made the request.

2.6.1. Offering a service

A service calls serve with a handler that Plaid invokes once per incoming request. The handler receives the request’s data and a response helper with three methods: progress(percent, message) to report intermediate progress, complete(result) to return a final result, and error(message) to signal failure. The helper reports under the service’s own account (POST /projects/{id}/service-requests/{request-id}/events), and the server takes a request’s progress and result only from the account holding the service channel the request was sent down. A service whose channel reopens under the same account still reports what it was sent.

def handle_request(data, response):
    response.progress(10, "Starting…")
    # whatever work the service does
    result = run_my_model(data["document_id"])
    response.complete({"status": "ok", "spans_created": result})

# `service_id` is the stable identifier clients use to address this service;
# `service_name` and `description` are human-readable. `serve` returns
# immediately with a registration handle; the work happens on incoming requests.
registration = client.messages.serve(
    project_id,
    {"service_id": "my-parser", "service_name": "My Parser",
     "description": "Parses a document with my model"},
    handle_request,
)

# The service stays available as long as the program is running and the
# registration is live. Typically you now block until interrupted:
try:
    while registration.is_running():
        time.sleep(1)
except KeyboardInterrupt:
    registration.stop()   # deregister cleanly

The same in JavaScript:

const registration = client.messages.serve(
  projectId,
  { serviceId: "my-parser", serviceName: "My Parser",
    description: "Parses a document with my model" },
  (data, response) => {
    response.progress(10, "Starting…");
    const result = runMyModel(data.documentId);
    response.complete({ status: "ok", spansCreated: result });
  },
);
// later: registration.stop();

2.6.2. Discovering and requesting

A client first asks which services are available, then sends a request to one by its service_id. discover_services/discoverServices returns every service the project has ever seen: the ones connected right now carry online: true, and previously-seen services that are currently down carry online: false plus a last_seen_at stamp. Only an online service can take work—filter on online before offering a "run" action:

services = client.messages.discover_services(project_id)
# [{"service_id": "my-parser", "service_name": "My Parser",
#   "description": "Parses a document with my model", "extras": {},
#   "online": True, "last_seen_at": "2026-06-12T02:43:17Z"}]

A project maintainer can prune the registry with discard_service/discardService, which forgets a previously-seen offline service (it reappears if it ever reconnects); discarding a currently-connected service is rejected with a 409.

request_service/requestService sends work to a named service and waits for its result. The data you pass reaches the service’s handler with its keys recased for the handler’s language, as everywhere else in the API: a Python caller’s document_id arrives in a JavaScript handler as documentId. The value the service passes to complete is what you get back. An optional progress callback fires for each progress update the service reports:

result = client.messages.request_service(
    project_id,
    "my-parser",
    {"document_id": document_id},
    timeout=60.0,
    on_progress=lambda p: print(f'{p["percent"]}%: {p["message"]}'),
)
print("Service returned:", result)
const result = await client.messages.requestService(
  projectId,
  "my-parser",
  { documentId },
  60000,                                  // timeout in ms (Python uses seconds)
  (p) => console.log(`${p.percent}%: ${p.message}`),
);
console.log("Service returned:", result);

If the service reports an error, the request fails with that error rather than returning a result.

The timeout measures silence, not the length of the run: every event the service sends starts it again, so a service that reports its progress can work for as long as it needs to and only a service that has genuinely gone quiet is given up on. Set it to how long you are willing to hear nothing.

Giving up does not end the request. The service goes on working, and its result waits on the server under the request id (see Presence and persistence). Errors of that kind are marked, so a caller can tell the difference without matching on messages: err.pending === true in JavaScript, exc.pending in Python, on a timeout, an abort, or a dropped connection. An error without it is the end of the request. A caller that recorded the request id should keep it when the error is pending and forget it otherwise.

2.6.3. Describing a service: tasks, summary, and parameters

The fields above (service_name, description) are enough for a human to recognize a service, but an application that plugs services into a fixed slot—"tokenize this document", "parse it", "transcribe the audio"—needs more: which services are eligible for that slot, what arguments each accepts, and what each one does. A service advertises all of this in its extras, an open metadata map that rides along with registration and comes back verbatim from discovery. Filling it in with the standard shape below lets a client offer the user a service picker, an arguments form, and a readable summary—without hard-coding anything about a particular service.

from plaid_client import TASKS, Param, build_extras

extras = build_extras(
    tasks=[TASKS.TOKENIZE],                          # which slots this service fills
    summary="## My Tokenizer\nSplits text into words and sentences…",  # markdown
    parameters=[                                       # user-controllable arguments
        Param.enum("language", "Language",
                   [("english", "English"), ("german", "German")],
                   default="english",
                   description="Model language."),
    ],
)
# Pass it as the service's extras (a BaseService subclass assembles this for you):
client.messages.serve(project_id, service_info, handle_request, extras)

The standard extras keys are:

tasks

A list of the slots this service fills, drawn from a small fixed vocabulary: tokenize, parse, transcribe, link-vocab, analyze (propose a morpheme segmentation and glosses for words), draft-graph (draft a meaning representation graph over a document’s sentences, for correction by hand), translate (propose a free translation for each sentence), detect-speech (propose time-aligned speech regions for a recording), assist (a conversational assistant that takes chat turns over a project), compare (score one document’s annotation against another document’s of the same text, writing a report on the scored document’s metadata rather than annotation; the request names the other document as against). A client offering a "tokenize" action lists exactly the services whose tasks include tokenize.

summary

A longer, human-readable description (Markdown), shown on demand—use it for what the one-line description is too short to say.

parameters

An ordered list of the arguments a user may set per request. Each entry is a small descriptor; the client renders a form from it and sends the chosen values as part of the request data.

A parameter descriptor has a key (the field its value is sent under), a label, and a type, plus optional description, default, and required. The types are string (optional placeholder, multiline), number (optional min, max, step, slider), boolean, enum/multiselect (each with options, a list of {value, label}), and field (with a scope), the name of one of the project’s annotation fields at that scope. An application that knows the project’s fields offers them to choose from. To anything else a field is a string. The Param.* builders produce these in Python; in JavaScript you write the equivalent objects.

Note

A parameter’s key is a value—the literal field name the argument is sent under—so it travels over the wire unchanged: a client reads the key exactly as the service declared it (model_size), sends the argument under that key, and it arrives back at the service unchanged. Because a snake_case or camelCase key contains no wire separator, this round-trips in either direction, so the rule is simply: declare each key in your own language’s convention and read the argument back under that same key. (The extras field names—schema_version, tasks, parameters, …—are different: those are recased like the rest of the API, so a Python author writes schema_version and a JavaScript client sees schemaVersion. Only the key is exempt, because it is a value rather than a field name.)

Most tasks are write tasks: the service edits the project directly and the client reloads. detect-speech is the exception, because a time-aligned segment is a stretch of the baseline and therefore cannot exist without text. A detect-speech service writes nothing; it returns its regions in the response, as segments, a list of {time_begin, time_end, speaker?} in seconds. The client holds them as proposals and creates a real segment only when a person types into one.

Helpers in both clients save you from re-implementing this shape. On the service-authoring side (Python), Param.* build the parameter descriptors and build_extras assembles the whole object (a BaseService subclass does this for you). On the selecting/UI side (JavaScript), filterServicesByTask picks the services for a slot and getServiceSummary/getParamSchema read a service’s summary and parameter schema. The value logic—turning a schema into initial form values and validated request values—exists in both: buildDefaultValues/coerceParamValues in JavaScript, default_values/coerce in Python.

2.6.4. Stopping a request

A requester can ask a service to stop work already under way (DELETE /projects/{id}/service-requests/{request-id}; cancelServiceRequest / cancel_service_request). Nothing is interrupted: cancellation is cooperative, and the request ends at the next point the handler looks.

The handler barely has to look, though, because progress() is that point. Reporting progress raises ServiceCancelled (Python) / throws ServiceCancelled (JavaScript) once a stop has been asked for, so a service that already reports progress through its work is cancellable without a line of change. serve catches it and ends the request as stopped rather than failed: the result carries stopped: true, so a client can tell "you stopped it" from "it broke".

Two things a handler may still want:

helper.raise_if_cancelled()

An explicit checkpoint, for a long loop that reports no progress.

helper.critical()

A stretch that must finish once begun, almost always the writes. Checkpoints inside it do not raise, so a document is never left half-written, and the stop takes effect at the first checkpoint after the block. In Python it is a context manager (with helper.critical():), in JavaScript it takes the function to run (await helper.critical(async () ⇒ { … })).

Important

Report the result inside the critical block, with the work it describes. A checkpoint placed after the last write has nothing left to prevent, so all it can do is throw away a result the service has already earned: the document is fully written, and the request still comes back stopped: true with no payload. A caller then understates what happened, and a multi-step caller abandons the steps after a step that actually succeeded.

All five bundled services had helper.progress(100, 'Done') sitting between the writes and complete(). Measured against the Punkt tokenizer, every stop that arrived during the write phase hit it: six for six wrote the document in full and reported themselves stopped. A stop that arrives once everything is done is meant to be ignored.

def handle(self, data, helper):
    for i, sentence in enumerate(sentences):
        # Already a checkpoint: a stop here ends the request cleanly.
        helper.progress(10 + int(70 * i / len(sentences)), f'Glossing {i + 1}/{len(sentences)}...')
        results.append(self.model.gloss(sentence))

    with helper.critical():           # too late to stop: finish, and say so
        with self.client.operation('Glossing'):
            self.write(results)
        helper.complete({'glossed': len(results)})

What a service cannot do is stop in the middle of one long blocking call. The bundled Whisper transcriber is the example: the transcription is a single call into the model with nothing to poll inside it, so a stop asked for during it takes effect when that call returns — before anything is written, which is the part that matters.

Important

In Python, ServiceCancelled inherits BaseException, not Exception — the same choice asyncio.CancelledError made, and for the same reason. Almost every handler wraps its work in except Exception to report a failure; if a stop were an Exception, that handler would swallow it and report an error instead, and a stop caught inside a per-item loop would be shrugged off and the loop would carry on. A finally still runs, so cleanup is unaffected. In JavaScript, where catch catches everything, a handler that catches broadly must re-throw a ServiceCancelled itself.

Important

A handler must not run on the channel’s own reader thread, or it blocks the very channel that carries service_cancel for the request it is running, and the stop is not read until the work it was meant to stop has finished. BaseService handles this: it takes its single-flight lock and then runs the work on a thread of its own, so one request at a time is still the rule and the channel stays readable. A service written directly against serve must do the same.

To test a handler without a server, plaid_client.testing (Python) loads a service file, gives it a fake client with the request, lock and batch surface, and runs one request through BaseService on the thread the server would use, recording every progress beat and terminal report. The bundled services' own tests are written against it.

2.6.5. Presence and persistence

A service can take work only while connected. There is no request queue: a request to a service that is absent (or that disconnects mid-request) fails immediately rather than waiting for the service to come back. This keeps the model simple and is a good fit for the typical setup, where a service is a long-running helper process that is either up or down. If you need a request to survive a service being briefly unavailable, you would build that durability yourself on top of Plaid (for instance, by recording pending work in your own layer and retrying).

The requester’s connection is a different matter: a request outlives it. If the stream that submitted a request closes (a browser tab reloaded during a minutes-long turn, a script that timed out), the service goes on, its progress keeps being recorded, and its result is kept on the server for fifteen minutes after it arrives. The requester comes back for it by request id (GET /projects/{id}/service-requests/{request-id}; attachServiceRequest / attach_service_request in the clients), which replays the latest progress and then delivers the result, or the stored result at once if the request already finished; only the user who submitted the request, or an admin, may rejoin it, and several connections may watch one request. A client that wants to be able to come back mints the request id itself (?request-id=, a UUID; the requestId / request_id option of the request call), so it can record the id somewhere durable before the request is even accepted; submitting an id that names a request that user already made rejoins it instead of starting another, so a retry after a dropped connection is safe. Every request stream opens with an accepted event naming the id (onAccepted / on_accepted).

DELETE /projects/{id}/service-requests/{request-id} (cancelServiceRequest / cancel_service_request) asks the service to stop: the service sees a service_cancel event on its channel, and a handler built on the clients' helpers reads it as responseHelper.cancelled / response_helper.cancelled. Whether to stop is the handler’s decision (a write under way should finish), and the request still ends with whatever the handler then reports. Every request also tells the service who asked (requester_id / requesterId, beside the request data), delegating or not.

What does persist is the discovery record: each registration upserts a per-project "seen services" row (name, description, extras, last-seen time), which is why discovery can list offline services. This lets an application show a stable picker—including a default service a maintainer chose—even when the service happens to be down, instead of the option silently vanishing.

Each service_id admits one live connection per project: a second registration while another instance holds the id is rejected with a 409. A service reconnecting after a network blip is not penalized—if the previously-held connection is dead but not yet reaped, the newcomer simply takes it over.

2.6.6. Surviving a server restart

A registration is self-healing, in both clients. The service holds its request channel open, and a supervisor reopens it whenever it drops, so restarting the Plaid server does not mean restarting its services: each one re-registers itself, typically within seconds of the server answering again, and its work continues without an operator touching it. Retrying has no attempt budget, so an outage of any length is survivable, and the same loop covers a server that is not up yet: services and server can be started in either order.

A reopened channel counts as connected only once the server has actually answered it, so registration.is_connected()/isConnected() is a true reading of whether the server currently sees the service as online, distinct from is_running()/isRunning(), which stays true throughout an outage. Python services built on BaseService also report the transitions on stdout, one line when the connection is lost and one when it returns, so an operator watching a service’s terminal can see it heal rather than restart it on a hunch. The only startup failure that stops a service is a token the server rejects, which no amount of retrying would fix.

Note
Like real-time messaging, service activity is not recorded in the audit log—only the document changes a service makes through ordinary writes are. The writes a service performs are attributed to whatever token it authenticates with, so giving a service its own named API token makes its edits clearly identifiable in the audit history.

2.6.7. Acting on the requester’s behalf

A service normally does its work with its own credentials: whoever launched it chose a token, and every edit it makes is that token’s. That is the right model for a tokenizer or a parser, whose output is the same no matter who asked. It is the wrong model for a service that does arbitrary work at a user’s direction—an assistant that edits whatever a user tells it to—because such a service would let any writer act with the service’s (often broader) permissions, and the audit log would name the service rather than the person.

A service that declares delegation in its extras (build_extras(…​, delegation=True), or BaseService(…​, delegation=True)) instead acts on the requester’s behalf. For each request the server mints a short-lived token for the user who submitted it (lifetime auth.delegated_token_ttl_seconds, one hour by default) and delivers it with the request as delegated_token; BaseService turns that into request_data["requester_client"], a PlaidClient authenticated as that user. Everything the service does through that client is checked against the requester’s own permissions and attributed to them in the audit log, exactly as if they had made the edits themselves. Because such a service can do nothing for a user that the user could not do directly, readers may drive it too (for read-only work such as exploratory queries), whereas a plain service accepts requests from writers only.

The delegated token is a session token with a short lifetime, and it is scoped to projects. It reaches the project the request was submitted on, and the other projects the request names (?project-ids=, a comma-separated list, the projectIds / project_ids option of the request call) that the requester can read. The server refuses it everywhere else: in other projects, on admin and user routes, on listings, and on project creation. A vocabulary is within its reach only when a project in reach links it, and a query made with it must name its projects in :scope. Even there it cannot act on the vocabulary as a whole, whatever the requester’s rights: it cannot rename or delete the vocabulary, add or remove its maintainers, link it to or unlink it from a project, change its config, or restore its entries (including a dry-run preview). It can still rename, merge and delete single entries where the requester may, and those changes reach every project the vocabulary is shared with. It may join an operation that its own writes started or that its request carried, and it may relabel (PATCH /operation-groups/:id) an operation that its own writes started, and no other, so a service can refine the label of the work it did for the request. The service is told which projects its token reaches (delegated_projects / delegatedProjects, beside the request data). Whoever runs a delegating service holds these tokens, and the scope keeps each one to what the requester could do in the projects the request is about. It never outlives the credential that asked for it: a request made with a delegated token hands the service a token that expires no later. A delegated token cannot open a service channel, and a channel opened with any token that expires closes when it does. The token is revoked by the user’s logout like any other session, and it should be used for the one request it arrived with and then dropped.

The assistants (plaid-agent in the source tree, run as plaid-igt-agent or plaid-ud-agent) are the reference delegating services: a chat assistant over a project, backed by whatever model the operator configures, whose edits are proposed as plans the user approves and are then applied under the user’s own account. Unlike the single-file services bundled in the jar, they are a package with its own dependencies, so it is installed separately rather than extracted next to the data directory—from a checkout for now, since it is not yet published to PyPI. It is also the reference for a service that owns its own state through the requester: each conversation lives in the requesting user’s private key/value store (see Users and Permissions), the browser appends the message and submits a request naming the conversation, and the service writes the reply into that record before reporting the request done, so the answer lands whether or not the browser is still watching and a browser that comes back rejoins the running request or reads the finished reply.

3. Layer Types

Each project contains a configuration of layers which define a schema for all documents in the project. Each layer holds a single kind of annotation, and each project may have any number of each kind of layer. For instance, you might have two span layers: one for POS tags, and another for lemmas.

3.1. An Example

Suppose we’re working on a project where all we are doing is POS-tagging. The configuration of the project’s layers (in a simplified JSON representation) would look something like this:

{
  id: "1cce50df",
  name: "Example Project",
  textLayers: [
    {
      id: "6283144f",
      name: "Text",
      tokenLayers: [
        {
          id: "d1cc124f",
          name: "Words",
          spanLayers: [
            {
              id: "ad0f5f2c",
              name: "POS tags"
            }
          ]
        }
      ]
    }
  ]
}

This layer structure prescribes the structure of individual documents. Consider a document where we have POS tagged the sentence "Fido barks":

{
  id: "01d01a27",
  name: "Document 1",
  project: "1cce50df",
  textLayers: [
    {
      id: "6283144f",
      name: "Text",
      text: { id: "9cfafcc6", document: "01d01a27", body: "Fido barks" },
      tokenLayers: [
        {
          id: "d1cc124f",
          name: "Words",
          tokens: [
            { id: "54383a26", begin: 0, end: 4 },
            { id: "a8758db2", begin: 5, end: 10 }
          ],
          spanLayers: [
            {
              id: "ad0f5f2c",
              name: "POS tags",
              spans: [
                { id: "4ed828ea", value: "NOUN", tokens: [ "54383a26" ] },
                { id: "b4ef8082", value: "VERB", tokens: [ "a8758db2" ] }
              ]
            }
          ]
        }
      ]
    }
  ]
}

Notice the following:

  • Each layer has a corresponding kind of data in the document: the text layer has a text, the token layer has tokens, and the span layer has spans.

  • The layers are dependent on each other: the text layer is a dependent of the project, the token layer is a dependent of the text layer, and the span layer is a dependent of the token layer. This is a reflection of conceptual dependencies: tokens are defined as atomized substrings of a text, and spans are defined as groupings of one or more tokens.

  • Each individual entity—whether it is a layer or some data within that layer—has a unique ID

  • Entities refer to others with these IDs—for instance, each span’s tokens value has a list of tokens which constitute that span.

We will continue discussing this example in more detail below.

3.2. Projects and Documents

A project is the root of a layer configuration and has a name.

{
  id: "1cce50df",
  name: "Example Project",
  textLayers: [/* ... */]
}

A project has many documents, and each has a name and a unique ID:

{ id: "01d01a27", name: "Document 1", project: "1cce50df" }

3.2.1. Reading part of a document

Reading a document with include_body / includeBody returns every layer in the project, with all of that document’s rows attached. That is the right default, but a project is often shared: the UD editor and the IGT editor can sit on one substrate, and each cares about a slice of it. Pass layers to read only the slice you need.

doc = client.documents.get(
    document_id,
    include_body=True,
    layers=[text_layer_id, word_layer_id, pos_layer_id],
)
const doc = await client.documents.get(
  documentId, true, undefined,
  [textLayerId, wordLayerId, posLayerId],
);

You may name layers of any kind — text, token, span, or relation — and two rules decide what comes back:

  • A layer appears when you named it, or when it is an ancestor of a layer you named. Ancestors are there so the response keeps its usual nesting: a span layer still arrives inside its token layer, inside its text layer.

  • A layer carries its own contents (its text, tokens, spans, relations, vocabs) only when you named it. An ancestor you did not name arrives empty.

So naming just a POS span layer gives you those spans, wrapped in an empty token layer and an empty text layer. Add the token layer to get the tokens the spans point at, and the text layer to get the text body. Descendants are never implied: naming a token layer gets you its tokens, not the span layers beneath it.

The filter reaches the database, so the rows you excluded are never fetched or joined against metadata. It works on time-travel reads too (as_of with layers).

An id that is not a layer of this document’s project is an error, not a quietly smaller response: a stale or mistyped layer id would otherwise be indistinguishable from that layer being empty.

Note
The response keeps its nested shape. If you want flat rows across a document (or across a project), that is what the query language is for.

3.3. Texts

For each text layer, each document may have at most one text, which consists of a single string. This string holds all the text which is to be analyzed in dependent layers. A text object looks something like this:

{ id: "9cfafcc6", document: "01d01a27", body: "Fido barks", digest: "4d7e1a…" }

digest identifies the body: it is the SHA-256 of the body’s UTF-8 bytes, in lowercase hex. Every read of a text carries it, both GET /texts/{id} and a document read with its body.

3.3.1. Changing a text’s body

PATCH /texts/{id} changes the body in one of two forms, and the tokens over the text follow the change.

  • body: the whole new body. Plaid compares it with the stored body to find what changed, and moves, resizes or deletes tokens to match.

  • edits: the changes an editor made, in order, each at the place it was made. Use this form from an editor that knows where the cursor was.

edits is a list of edit operations. Indices are code points, and each operation’s index is in the body as the operations before it left it:

{ type: "insert",  index: 4, value: "s" }             // type "s" at 4
{ type: "delete",  index: 9, value: 1 }               // delete 1 code point at 9
{ type: "replace", index: 0, length: 3, value: "dog" } // put "dog" in place of 3 code points at 0

Only the net change counts: typing a word one letter at a time and inserting it at once are the same edit, and a letter deleted and typed back is no change. Every change stays exactly where it was made.

The tokens of every token layer take a change the same way, whichever app or script made the layer:

  • A change inside a token, or touching the token’s edge with no whitespace between, grows or shrinks the token. Letters typed at a word’s end become part of the word.

  • A space typed inside a token does not split it. The token now holds the space.

  • Deleting the whitespace between two tokens keeps both, now side by side. Text then typed between them with no whitespace goes to the first.

  • New text apart from every token, with whitespace between, belongs to none of them.

  • A token is deleted only when all its text is deleted. A token typed over whole keeps its place on the new text, and so does a sentence typed over whole with its separator.

  • Several whole words typed over as words of their own are each typed over: 不 大 typed over as x y keeps the word 不 on x and 大 on y, with everything on them. With fewer new words than old, the last new word goes to the old word left that shares the most letters with it, the first on a tie, and the other old words are deleted (cat eel typed over as one keeps eel).

  • A token is never moved onto another word, and nothing that hangs off it is deleted or moved.

A body save is read as the smallest change from the stored body to the new one, and where that change could stand in more than one place for the same new body, Plaid picks the place that disturbs the fewest tokens. It reads each changed stretch word by word too, and takes that reading when it deletes fewer words than the diff as it stands, or only words the diff deletes too: ac d cda saved as ac X keeps d on X and deletes cda, where the diff as it stands deletes both. Where each reading deletes a different word (the a ab saved as the aX), the diff’s is taken (a on aX, ab deleted). Changes inside one word that would delete a token inside it, such as cow analysed co + w saved as abc, are read as the word typed over, so the word keeps its place and its morphemes go, as the same change sent as edits does. A body save never deletes a token with a letter of it left in the new body, except a token inside a word it reads as typed over, as above: a change that deletes the whitespace between two words, with or without letters of them, keeps both words on what is left of them, as the same change sent as edits does. An editor that knows where the cursor was sends edits, which say exactly where.

Where typed text goes is decided by the words: the tokens of a layer that forbids overlap and has a parent or a layer nested under it, and is not nested under another such layer. A text with no such layer has its other tokens, all but the sentences, decide. A layer nested under the words, at any depth, keeps to its words' edges, as morphemes and syntactic words do. Every other token follows the words' decision, so a node over a word on a layer beside the words stays on it. Partition layers follow the words' edges. Text typed between two sentences that no token takes goes by where it was typed: right before the next sentence’s first character it joins that sentence, unless it holds a line break. Otherwise, right after a sentence’s last character, in the middle of a longer run of whitespace, or in a body save, which says nothing of where the text was typed, it joins the sentence before. Text typed before the text’s first sentence that holds a line break with other than whitespace before it becomes a sentence of its own, on the partition layer the words are nested under (or the text’s only partition layer), never on another partition layer such as one token over the whole document, up to the first letter after its last line break, so the first sentence keeps its place and what hangs off it. This goes by the text before the first sentence after the save, whatever the save’s shape: an insert, an insert with other changes, or a body save. The answer’s reshape.tokens lists that sentence with its layer and text. Whitespace alone typed there joins the first sentence, which begins at the start of the text. The first sentence’s words, and a token that follows it, do not begin on that whitespace. A token of a layer that forbids overlap beside the words, whose extent is a sentence’s less whitespace at its edges, such as a time-alignment segment over its sentence, follows that sentence: it covers whatever the sentence covers after the change, less whitespace at its edges. A token of a layer that allows overlap, such as a node, never follows a sentence. It keeps to its words. A zero-width token of a layer that forbids overlap, such as an empty time-alignment segment, that a token of its layer grows over goes to that token’s edge on the side it stood, so the two never overlap. A sentence never begins or ends on whitespace it did not begin or end on, except the first sentence over whitespace typed at the start of the text.

Where two words, two sentences, or two tokens of a layer that forbids overlap beside the words (two time-alignment rows) meet with no whitespace between them, an insert may say which of them its text belongs to with side: "before" for the one that ends there, "after" for the one that begins there, and the sentences and the tokens that follow them go with it. Without it, or where nothing meets, the text goes as the rules above say.

{ type: "insert", index: 7, value: "x", side: "after" }   // typed at the front of the token at 7

A word layer whose config sets splitOnSpace to true in the plaid namespace (SPLIT_ON_SPACE_KEY in both clients) has a space typed inside one of its tokens split it there. Spaces the token already had do not split it. The token goes on the part holding the most of its old letters, the first on a tie, with the tokens as long as it. A token inside it is cut to that part, and one wholly on the other part is deleted. A token over several words that began or ended with it begins or ends where the token now does. The key holds for every word of the text, so on a word layer two apps share, one app’s key decides for both. This holds for body and edits alike.

Send base with edits: the digest of the body the edits were made on. The edits apply only when the stored body is still that body. Otherwise the answer is 409 with text-changed: true and the stored digest, and nothing is written. An edit with base needs no document-version. Without base, the edits apply to whatever body is stored. base may also be sent with body.

PATCH /texts/9cfafcc6
{ edits: [ { type: "insert", index: 10, value: "!" } ], base: "4d7e1a…" }

The answer is the text with its new digest, and reshape: what the change did to the rest of the document.

{ id: "9cfafcc6", body: "Fido barks!", digest: "b31c09…",
  reshape: { tokens: [], spans: [], "vocab-links": [],
             deleted: { tokens: [], spans: [], relations: [], "vocab-links": [] } } }

reshape.tokens lists the tokens whose extent changed, spans and vocab-links those whose token lists changed, and deleted the ids of everything the change deleted. An editor can update its own copy of the document from it instead of reading the document again.

A list of edit operations may also be sent as body. It is then applied exactly as sent, with no reading of the change, the form for a program that computes its own positions.

3.4. Tokens

For each token layer, each document may have many tokens, which are defined as substrings of a text:

{ id: "54383a26", text: "9cfafcc6", begin: 0, end: 4 }
{ id: "a8758db2", text: "9cfafcc6", begin: 5, end: 10 }

Note the following:

  1. begin and end must form valid substring indices for the given text. They are 0-based offsets measured in Unicode code points (begin inclusive, end exclusive) — not UTF-16 code units or bytes, so a supplementary-plane character (an emoji, or a Supplementary-Plane script such as Gothic, cuneiform, or CJK Extension B) counts as a single position. Code-point offsets are language-neutral: Python’s str indexing and SQLite’s substr/length already count code points, and Plaid’s server and clients all agree on this unit.

  2. By default, zero-length tokens where begin == end are valid.

  3. By default, tokens may overlap.

  4. Plaid sorts tokens by begin when determining their linear order in the document. For tokens with identical begin, Plaid uses the optional precedence value wherever available, such that tokens with lower precedence appear earlier in linear order.

Token layers are unique in that their invariants are configurable. In addition to the default configuration, it is possible to constrain them further: for example, a token layer may require that no tokens be overlapping in extent of begin and end. For full details, see Token Layer Constraints below.

Tokens are intended to serve as the basic units for further linguistic analysis using spans and relations.

3.5. Spans

For each span layer, each document may have many spans, which are groupings of one or more tokens which have a single value:

{ id: "4ed828ea", value: "NOUN", tokens: [ "54383a26" ] }
{ id: "b4ef8082", value: "VERB", tokens: [ "a8758db2" ] }

There are no restrictions on spans, other than that they must hold at least one token, and that they all must belong to the span layer’s parent token layer.

3.6. Relations

For each relation layer, each document may have many relations, which are directed edges between two spans with a label. Both spans must belong to the relation layer’s parent span layer. For example, if we wanted to extend the example above with a syntactic dependency relation between "Fido" and "barks" expressing that "Fido" is the subject, we could have a relation like this:

{ id: "2f6080ff", source: "b4ef8082", target: "4ed828ea", value: "nsubj" }

3.7. Vocabs

The four basic layer types (text, token, span, and relation) are all project-specific and cannot be used in more than one project. The fifth layer type, the vocab layer, can be used across projects. As its name suggests, this layer is intended for recording occurrences of lexical entries.

The vocab layer itself has a name:

{ id: "2b75b0f9", name: "English" }

The vocab layer has vocab items, which represent lexical entries, each with a canonical form:

{ id: "da8d4549", form: "Fido" }
{ id: "b5c6e64c", form: "bark" }

Finally, vocab links are used to indicate occurrences of lexical entries. Recall the tokens from before:

// "Fido"
{ id: "54383a26", text: "9cfafcc6", begin: 0, end: 4 }
// "barks"
{ id: "a8758db2", text: "9cfafcc6", begin: 5, end: 10 }

We can create links between them and the above vocab items with vocab links like so:

{ vocabItem: "da8d4549", tokens: [ "54383a26" ] }
{ vocabItem: "b5c6e64c", tokens: [ "a8758db2" ] }

Notice that multiple tokens may be specified, allowing for multi-word and non-contiguous lexical items.

Deleting a vocab item deletes every link to it. A screen that shows how many links an entry has before deleting it can pass that count (expected_link_count / { expectedLinkCount }), and the delete is then refused with a 409 when the entry has any other number of links by the time it runs. The refusal carries the number of links the entry has now as links, beside the message.

To merge entries into one, vocab_items.merge(survivor_id, loser_ids) / vocabItems.merge(survivorId, loserIds) (POST /api/v1/vocab-items/{id}/merge with {"losers": […​]}) moves every link of the losers to the survivor and deletes the losers, in one operation. A moved link keeps its id and metadata. A link on words the survivor is already linked to is deleted instead, since it would say the same thing twice. The links are read when the merge runs, so a link someone made to a loser after the caller looked moves with the rest. Every loser must be in the survivor’s vocabulary, and a loser that no longer exists is skipped, so repeating a merge changes nothing. It needs maintainer rights on the vocabulary, and answers {"moved": n, "duplicates": n, "removed": […​]}. Plaid does not know what an entry’s metadata means, so a reference to a loser inside another entry’s metadata is left for the caller to rewrite, in the same batch as the merge.

3.8. Metadata and Config

It is often desirable to enrich an entity with additional information—for instance, you might want to record some information about the annotator’s confidence in whether a certain span value is correct. Additionally, you might want to do the same with a layer in order to e.g. specify what values are acceptable for spans in a given layer. To accommodate this, Plaid allows arbitrary data to be stored in the config attribute for layer types (project, text layer, token layer, span layer, relation layer, vocab layer) and in the metadata attribute for data types (document, text, token, span, relation, vocab item, vocab link).

You might use a config to store legal tags for a given layer:

{ id: "...", name: "POS Tags", config: { tags: ["NOUN", "VERB", /* ... */] } }

As for metadata, the canonical use is recording whether a human or a machine produced an annotation — Plaid standardizes this as the Provenance convention, including where a model’s confidence and prediction details go:

// Human-made POS tag: NO provenance keys (absence = human)
{
  id: "...",
  tokens: [/*...*/],
  value: "NOUN",
  metadata: {}
}
// Machine-made POS tag, not yet human-verified
{
  id: "...",
  tokens: [/*...*/],
  value: "NOUN",
  metadata: {
    prov: "inferred",
    provSource: "service:my-tagger",
    provProb: 0.8412,                       // flat -> queryable/sortable
    provDetail: {                           // open map for the rest
      model: "my-tagger==2.1",
      uposProbs: { NOUN: 0.8412, PROPN: 0.0966, ADJ: 0.0014 }
    }
  }
}

3.8.1. Editing metadata

set_metadata / setMetadata replaces an entity’s whole metadata, and delete_metadata / deleteMetadata clears it. To change part of it, patch_metadata / patchMetadata takes a list of ops, applied in order as one operation:

{"op": "set", "path": […​], "value": v}

Writes v at the path, creating any missing objects along it. null is an ordinary value here.

{"op": "delete", "path": […​]}

Removes the key at the path. Deleting a key that is not there does nothing.

A path is a list of keys into the nested metadata, the first being a top-level key, so a path of one key sets or deletes a top-level key whole. A path that runs through a value that is not an object (a string, a number, a list) is refused with a 400, and then none of the ops apply. Because the server applies the ops, two people editing different keys of one object do not overwrite each other. For example, with coreference entities kept in a document’s metadata under corefud.entities, adding one entity and removing another touches only those two keys:

client.documents.patch_metadata(doc_id, [
    {"op": "set", "path": ["corefud", "entities", "c10"], "value": "bird"},
    {"op": "delete", "path": ["corefud", "entities", "c8"]},
])

Both clients also ship metadataOps(fragment) / metadata_ops(fragment), which turns an object of top-level keys into ops (a null value becomes a delete), and applyMetadataOps(metadata, ops) / apply_metadata_ops(metadata, ops), which applies ops to a local copy the way the server does, for an app that updates its view before the server answers.

3.8.2. Editing config

set_config / setConfig replaces the value of one key under one namespace (PUT …​/config/<namespace>/<key>), and delete_config / deleteConfig removes it. Other keys, in the same namespace or another, are left alone.

A settings page that wrote back the value it loaded would undo a save another maintainer made after it loaded. To prevent that, a write can name the value it read, and it is then refused with a 409 when the stored value is anything else:

tagsets = project["config"]["igt"]["tagsets"]
client.projects.set_config(project_id, "igt", "tagsets", {**tagsets, "Case": {"mode": "open", "values": []}},
                           expected=tagsets)

In JavaScript the same is setConfig(id, namespace, key, value, auditMessage, { expected }), and deleteConfig takes { expected } in the same place. A key that was absent is expected as None / null (in JavaScript expected: undefined counts the same, as long as the expected key is given). On the wire this is ?if-unchanged=true with the body {"expected": …​, "value": …​} (a DELETE sends {"expected": …​}). The check compares one key’s whole value, so it holds inside an atomic batch too, where a 409 rolls back the batch. Values are compared as JSON, so 1 and 1.0 are the same number and the order of keys does not matter. A cell that already holds the value being written passes too, so a save sent again after its answer was lost succeeds.

Note
Config and metadata values are schema-less, meaning that keys and values are unconstrained. If you’d like to see schema support for either of these, please feel free to open an issue.

4. Data Integrity

In collaborative annotation projects, it is crucial to take steps to ensure that data never reaches an invalid state. Plaid provides a few different means for maintaining data integrity, so that you may have confidence that your data will never become corrupt.

4.1. Core Data Integrity Constraints

In the previous section, we noted the constraints which Plaid enforces on each data type. Plaid guarantees that the database will never violate these, no matter what, by ensuring that invalid entities are never created, and often by deleting structures which are indirectly rendered invalid by another change. Consider these examples:

  • If a relation’s source span is deleted, then Plaid deletes the relation as well, because a relation must have a span on either end in order to remain valid.

  • If a few characters are deleted in a text, then all token indexes are updated to maintain validity: tokens containing those characters will shrink or get deleted (if they turn into zero-length tokens), and not containing those characters which are anchored to subsequent text will have their indices decremented by the number of deleted tokens.

  • If a span’s only token is deleted, then the span will deleted, along with any dependent relations.

These invariants have been incorporated into Plaid because of their broad desirability in linguistic annotation. However, some invariants will vary by annotation framework. For example, it is quite common to want a span layer’s spans to be in one-to-one correspondence with tokens in the parent token layer.

For token layers, you can ask Plaid to enforce some commonly needed additional invariants for you, server-side: these are described in Token Layer Constraints below. An app can also declare rules of its own on the layers it uses, such as one head per word or one sentence per relation, which Plaid enforces on every write: see Layer Constraints. For everything else, Plaid provides three further mechanisms—strict mode, locking, and atomic batches—which help you coordinate writes from the client so that you can enforce your own data integrity constraints without needing to write any code outside of your client.

4.2. Token Layer Constraints

The constraints in the previous section are always on. Token layers additionally support a set of opt-in, server-side constraints which capture some of the most common token-level invariants in linguistic annotation. Because they are enforced by the server on every write, there is no need for extra measures on the client side in order to maintain them. They are configured per token layer at creation time, and are immutable thereafter.

4.2.1. Overlap Modes

Every token layer has an overlap mode, chosen at creation, which governs how its tokens may sit relative to one another:

  • any (the default): no restrictions. Tokens may overlap, leave gaps, and be zero-width. This is the behavior described in the Tokens section.

  • non-overlapping: no two tokens in the same document may overlap (share any character). Gaps between tokens are still allowed—for instance, the whitespace between words.

  • partitioning: the tokens must form a gap-free, non-overlapping, zero-width-free cover of the whole text. Partitioning is allowed only on a root (parentless) token layer; see Token Layer Hierarchy.

Note

A partitioning layer is, at every moment, in exactly one of two valid states for a given document: empty (no tokens at all—the document is simply not yet tokenized at this layer, which is the state every layer starts in), or a complete partition (the gap-free, overlap-free, zero-width-free cover described above). Plaid never lets it reach a partial state in between: every operation either moves it between empty and complete, or is rejected.

In particular, there is no automatic establishment—a newly created partitioning layer is empty until you bulk-create its partition, and bulk-delete (which must remove the whole partition at once) returns it to empty. The editing operations below each preserve the complete-partition state: a split divides one token, a merge joins two adjacent ones, and a boundary shift only moves a shared boundary within the two tokens it separates (a shift that would collapse, invert, or overshoot a token is rejected).

You set the mode when creating the layer:

# A non-overlapping "words" layer
client.token_layers.create(
    text_layer_id, "Words", overlap_mode="non-overlapping")

Because a partitioning layer must always tile its extent, the ordinary per-token create, delete, and extent-update operations are rejected on it—each would necessarily leave a gap or an overlap. Instead, you build and edit the partition with four operations:

  • bulk-create establishes a whole partition at once.

  • split divides one token into two at an offset.

  • merge combines two adjacent tokens into one.

  • shift-boundary moves the boundary between two tokens, growing one and shrinking its neighbor so that the cover is preserved.

These editing operations are available on any and non-overlapping layers too, where they serve as convenient shortcuts. In every case, dependent spans and vocab-links are preserved: a split keeps them on the original (left) token, and a merge reparents the right token’s spans and vocab-links onto the surviving left token.

# Establish the partition "dogs run" -> "dogs", "run"
result = client.tokens.bulk_create([
    {"token_layer_id": words, "text": text_id, "begin": 0, "end": 4},
    {"token_layer_id": words, "text": text_id, "begin": 5, "end": 8},
])
dogs_id, run_id = result["ids"]

# Split "dogs" into "dog" + "s"
new_id = client.tokens.split(dogs_id, 3)["id"]

# Merge the two halves back together
client.tokens.merge(dogs_id, new_id)

Some relations must stay inside one token, such as a dependency tree inside its sentence, while others cross such tokens by design, such as a coreference chain across sentences. A relation layer of the first kind declares a same-ancestor constraint (see Layer Constraints), and a split that leaves one of its relations crossing the new boundary deletes it in the same transaction, whoever made the split.

4.2.2. Token Layer Hierarchy

Although all token layers must depend on a text layer, a token layer may also declare another token layer as its parent token layer. As with the parent text layer, this must be done at time of layer creation and it is immutable. This models nesting between levels of tokenization, which is commonly needed for most approaches to interlinear glossed text, where morphemes are nested within whole words which are nested within sentences.

Nesting is expressed purely through code-point offsets: a child token belongs to the parent token whose extent contains it. There is no separate parent pointer on each token to keep in sync. These rules apply to nested token layers:

  • The parent layer must belong to the same text layer as the child, so they index into the same text.

  • The parent layer must be non-overlapping or partitioning—its tokens must not overlap, so that each child has exactly one containing parent. (An any parent is rejected.)

  • A nested layer may not itself be partitioning.

  • Every token in the nested layer must be contained within some parent token. A token that escapes its parent, or that spans across two parents, is rejected.

  • Tokens on a nested layer may not be zero-width—a zero-width token sitting on a parent boundary would belong to two parents at once. (For morphemes that have no clean surface segmentation, see Non-concatenative morphology below.)

Hierarchical containment and overlap mode provide complementary guarantees. Hierarchical containment is used to restrict the ranges in which tokens for that layer may occur: that is to say, only within the extant tokens in the parent layer. On the other hand, the overlap mode dictates how a layer’s own tokens may occur within that range relative to each other.

Here is the canonical three-level IGT hierarchy for the text "dogs run", assuming tl is a text layer and text_id is its text:

# Three token layers: sentences contain words contain morphemes. Only the root
# (sentence) layer may be partitioning. Words are non-overlapping; morphemes are
# `any` so that fused forms (overlapping morphemes) are representable too -- see
# "Non-concatenative morphology" below.
sentences = client.token_layers.create(
    tl, "Sentences", overlap_mode="partitioning")["id"]
words = client.token_layers.create(
    tl, "Words", overlap_mode="non-overlapping",
    parent_token_layer_id=sentences)["id"]
morphemes = client.token_layers.create(
    tl, "Morphemes", overlap_mode="any",
    parent_token_layer_id=words)["id"]

# One sentence covering the whole text
client.tokens.bulk_create([
    {"token_layer_id": sentences, "text": text_id, "begin": 0, "end": 8}
])

# Two words, with a gap at index 4 for the space
client.tokens.bulk_create([
    {"token_layer_id": words, "text": text_id, "begin": 0, "end": 4},
    {"token_layer_id": words, "text": text_id, "begin": 5, "end": 8},
])

# Morphemes within each word: "dog|s", then "run"
client.tokens.bulk_create([
    {"token_layer_id": morphemes, "text": text_id, "begin": 0, "end": 3},
    {"token_layer_id": morphemes, "text": text_id, "begin": 3, "end": 4},
    {"token_layer_id": morphemes, "text": text_id, "begin": 5, "end": 8},
])
Non-concatenative morphology

The morphemes in the example could be seen as purely concatenative: each is a contiguous slice of its word, and together they cover it without gaps. Plenty of morphology does not decompose so cleanly. The French contraction aux, for instance, is analyzed as the preposition à plus the article les, yet neither morpheme corresponds to a contiguous slice of the surface string "aux".

Because the morpheme layer above is any, it accommodates this directly—this is exactly why a morpheme layer is usually any rather than non-overlapping. Represent a fused form with overlapping morphemes that share the fused span, ordered with precedence:

# "aux" is the word token [0,3]; the morpheme layer's overlap-mode is `any`
client.tokens.bulk_create([
    # à
    {"token_layer_id": morphemes, "text": text_id,
     "begin": 0, "end": 3, "precedence": 0},
    # les
    {"token_layer_id": morphemes, "text": text_id,
     "begin": 0, "end": 3, "precedence": 1},
])

Both morphemes span the whole surface form, so the overlap between them is itself the signal that the correspondence is non-concatenative: an agglutinative word yields disjoint, contiguous morphemes, while a fused word yields morphemes that share a span. precedence records their linear order (à before les).

Zero-width morphemes are not an alternative here: tokens on a nested layer may not be zero-width, for the disambiguation reason given above. Anchor the fused morphemes to the whole word’s span, as shown.

4.2.3. Cascading Edits

Plaid’s general philosophy is to make destructive, cascading changes where necessary to preserve its invariants—recall that deleting a token deletes the spans which depended on it. Editing a parent token follows the same philosophy: structural operations cascade down the hierarchy so that nesting always remains valid.

  • Deleting a parent token deletes every token nested within it (and, transitively, their spans, relations, and vocab-links). Deleting a word deletes its morphemes, while leaving the word’s siblings and their morphemes untouched.

  • Splitting a parent at an offset also splits any descendant that straddles that offset, so a nested token is never bisected. (A split that lands exactly on an existing child boundary needs no child split; the children simply re-home into the two new parents by offset.)

  • Shifting a boundary (or otherwise resizing a parent) cascades according to the layer’s mode. On a partitioning parent, the neighbor grows to absorb the freed region, and any descendant straddling the moved boundary is split so that its outer half re-homes to the neighbor. On a non-overlapping or any parent, descendants that retain no overlap with the new extent are deleted—including every child when the parent is shrunk to zero width—and descendants straddling the new edge are trimmed to fit.

  • Merging two parent tokens yields a token spanning both, which therefore still contains all of their descendants—nothing is orphaned.

  • Editing the text body cascades here too: as tokens shrink, grow, or disappear along with the characters they cover (per the core constraints above), nested tokens are clipped consistently, so a child never ends up outside its parent.

Because all of these are enforced and cascaded server-side, you can rely on the hierarchy remaining internally consistent no matter how the document is edited, including under concurrent edits by multiple users.

4.3. Strict Mode

Multiple users may edit the same document simultaneously, and in some cases, undesirable conflicts may occur as users fail to take into account each other’s work. Suppose, for example, that one user is editing a sentence’s lemmas, and the other is editing a sentence’s POS tags. If the lemma editor doesn’t know that a certain POS tag has changed, they might make the wrong decision about which lemma to assign. Plaid clients' optional strict mode causes edits to fail when someone other than the current user has made an edit. Consider this exact scenario in code:

await client1.spans.update(lemmaSpanOneId, "lemmaOne")
await client2.spans.update(posTagSpanTwoId, "posTagTwo")
// Works fine
await client1.spans.update(lemmaSpanTwoId, "lemmaTwo")

When client 1 executes the second lemma span’s value, unless they happened to have loaded the document anew after client 2’s change, they will not be aware of the new POS tag for the second word.

Strict mode causes requests to fail when someone other than the user in strict mode has edited a document since strict mode began. If client 1 had initiated strict mode at the beginning, then the second request would have failed:

await client1.documents.get(documentId) // strict mode checks against the version last read
client1.enterStrictMode(documentId)
await client1.spans.update(lemmaSpanOneId, "lemmaOne")
await client2.spans.update(posTagSpanTwoId, "posTagTwo")
// Fails with HTTP 409, since client 2 made a change
await client1.spans.update(lemmaSpanTwoId, "lemmaTwo")
// Exit strict mode when desired
client1.exitStrictMode()

This failure gives client 1 the opportunity to reload the document only when it is necessary, allowing them to reconsider the current state of the document before making changes.

The same 409 answers a versioned write whose target is gone: a gloss on a word, or a relation from a span, that another user deleted after client 1 read the document. A versioned write that names nothing at all, such as an empty bulk create, is a 400.

4.4. Locking

Sometimes a more heavyweight solution is needed. A lock gives a user exclusive permission to write to a document, preventing all other users from writing to it. Locks have a 60 second expiration timer by default, and they may be released early or renewed by either explicit renewal or any write to the locked document. Consider:

await client2.documents.checkLock(documentId);
// => HTTP 204
const { lockId } = await client1.documents.acquireLock(documentId);
// -> { lockId: "0b6f…", userId: "client1", expiresAt: 1752260966446 }
await client2.documents.checkLock(documentId);
// -> { userId: "client1", expiresAt: 1752260966446 }
await client1.documents.renewLock(documentId, lockId);
// -> { lockId: "0b6f…", userId: "client1", expiresAt: 1752260996446 }
await client1.documents.releaseLock(documentId, lockId);
// -> HTTP 204
await client2.documents.checkLock(documentId);
// -> HTTP 204

A lock belongs to the acquire that took it, not to the user. While it is held, every other acquire is refused with HTTP 423, one by the same user included, so two runs of one user’s work cannot share a lock and one cannot release it under the other. The acquire answers with a lockId, and only that id renews or releases the lock. A renewal only extends a live lock: once the lock has expired or an admin has dropped it, renewLock is refused with HTTP 423 even when nobody holds the document, because another user’s edit may have landed in between. It is given to the holder alone: checkLock, a refused acquire and the admin list of locks name the user who holds the lock, never its id. Writes carry no lock id. A write to a locked document passes for the user who holds the lock and renews it. Anyone else’s write is refused with HTTP 423. A lock covers the writes made to the document itself. A write made on a project or a vocabulary does not wait for it, even when it changes what a locked document holds: merging or deleting a vocabulary entry moves or removes the document’s links to it, and deleting a layer removes what the document has in that layer. A holder that must not write over such a change writes in strict mode (see Strict Mode): after a merge or delete of an entry its document links to, its next write answers 409.

An acquire may name its holder itself: acquireLock(documentId, auditMessage, newLockId) (new_lock_id= in Python, ?new-lock-id= on the wire) takes the lock under an id the client minted, such as a fresh UUID. Sent again while that holder has the lock, it answers 200 with the same id, so an acquire whose answer was lost can be retried, and released, instead of standing in everyone’s way until it expires. An id is spent once its holder releases it, even before the acquire arrives: an acquire held up in the network that lands after the release takes nothing and answers 423.

An acquire waits for a write already under way on the document to finish. Every write either lands before the acquire answers, and the holder’s first read sees it, or is refused with HTTP 423. A holder therefore never plans its work from a read that a write it did not see is about to change. The cost is that an acquire can wait behind a long save.

Locks are useful for situations where a concurrent edit by another user could yield an invalid state with respect to data integrity constraints beyond what is enforced in Plaid’s core. They should be used only where necessary in order to minimize the risk of invariant violations stemming from concurrent modifications.

Both clients wrap these calls in a block that holds the lock for the length of the work and releases it on the way out, including on error. The block is one holder, and its handle carries the id as lock.lockId (lock.lock_id in Python). The block mints that id and sends it with the acquire, so an acquire that got no answer is sent again, up to three times, and released if none is answered:

await client.documents.locked(documentId, async () => {
  // delete + recreate this document's tokens
});
with client.documents.locked(document_id):
    rebuild_tokens(client, document_id)  # delete + recreate this document's tokens

The block also renews the lock while it runs, which matters for a service. The expiration timer is refreshed by the holder’s writes, so a parser that loads a model, reads the document, spends minutes in inference and only then writes holds the lock for its first minute and nothing after that: the lock lapses in silence and an edit can land between the read the work was planned from and the write about to go out. The block renews on a timer instead, reading the window from the expiresAt the acquire returned. If a renewal fails the lock is gone, and the block says so rather than carrying on: every later write from that client raises DocumentLockLost (documentLockLost is set on the client until the block exits), and a block that reached its end anyway ends with that error instead of reporting success. Work that has not written yet can give up sooner by reading lock.lost on the handle the block is given.

4.5. Atomic Batches

Finally, you may also submit multiple requests in batches. Batches are atomic meaning that we guarantee that either they will all succeed or all fail. This is a very useful guarantee whenever you have sophisticated data integrity requirements that must be orchestrated using more than one request. The guarantee holds up to 1,000 operations, the server’s cap on one batch request (limits.batch-operations in GET /api/v1/info, MAX_BATCH_OPS in both clients). Both clients send a larger batch as consecutive requests, each atomic on its own, so a failure in a later one leaves the earlier ones committed. The error then says how much was saved: committed is the number of operations saved (0 when none were) and committedResults (committed_results in Python) holds their results in queue order.

A batch is an object rather than a mode of the client. batched hands your block a batch carrying the same resources as the client (b.documents, b.tokens, and the rest), and a write made on that batch is queued instead of sent. The queue is submitted when the block ends (as one request, up to the 1,000-operation cap), and dropped if the block raises. A write made on the client while a batch is open is never part of it and goes over the wire at once, so code that knows nothing of your batch cannot land inside it.

Here is an example of how to submit a batch using the JavaScript client:

try {
    const results = await client.batched(async (b) => {
        b.documents.update(documentIdOne, "New Name for Doc 1");
        b.documents.update(documentIdTwo, "New Name for Doc 2");
    });
    console.log("Batch success!")
    for (const response of results) {
        console.log(response);
    }
} catch (e) {
    console.error(`Batch failed: ${e}`)
}

Or in Python, where the results land on the batch because a context manager cannot return a value:

try:
    with client.batched() as b:
        b.documents.update(document_id_one, "New Name for Doc 1")
        b.documents.update(document_id_two, "New Name for Doc 2")
    print("Batch success!")
    for response in b.results:
        print(response)
except Exception as e:
    print(f"Batch failed: {e}")

Where the queue is not built inside a single block, open the batch yourself with batch() and send it with submit(), or drop it with abort():

const b = client.batch();
b.documents.update(documentIdOne, "New Name for Doc 1");
b.documents.update(documentIdTwo, "New Name for Doc 2");
const results = await b.submit();
b = client.batch()
b.documents.update(document_id_one, "New Name for Doc 1")
b.documents.update(document_id_two, "New Name for Doc 2")
results = b.submit()

Either way you get one result per queued write, in the order the writes were queued.

A write in a batch can use the id an earlier write in the same batch creates, so a create and the write that depends on it (an entry and the link to it, say) succeed or fail together. b.ref() stands for the id the write queued last will get, b.ref(n) for that of the nth write queued (counting from 0, or from the end when negative), and b.ref(n, k) for the kth of the ids a bulk create answers with:

with client.batched() as b:
    b.vocab_items.create(vocab_id, "dog")
    b.vocab_links.create(b.ref(), [token_id])

A reference goes in the body of a later write on the same batch, at any depth. The client refuses one anywhere else: in a path, on another batch, or in a call made on the client.

On the wire an operation lists its references beside its body, as "refs": [{"at": ["vocab-item"], "op": n}], or {"at": […​], "op": n, "index": k} for the kth of a bulk create’s ids. at is a path into the body, of object keys and list indexes, and the body holds null there. The server puts the id at each path when it runs that operation. It never searches a body, so a value in your data that looks like a reference is stored as you sent it. A reference in a path, rather than a body, is not supported. A reference to an operation that is not before it, to one that answered no such id, or to a path that does not lead to a null, refuses the whole batch with a 400. When a batch is sent as several requests, a write cannot refer to one in an earlier request, and the client refuses such a batch before sending any of it.

A vocab item belongs to no document, so the document-version a strict-mode client stamps on its create has nothing to be checked against on its own. Created alone, the entry is refused with a 400 unless document-id names the document the version is for. Naming a document takes read access to it, a 403 otherwise. A document that no longer exists answers an admin 409 and anyone else 403, as an unknown id does. In a batch the stamp is left to the batch’s other writes, such as the link the entry is made for. A read made on a batch—any GET, and query—is answered from the server there and then rather than queued, so a read may be made on the batch or on the client as you please. The server runs a batch as one transaction holding its single write lock for the whole of it, and no other write can proceed meanwhile, so keep a batch to the work that has to be atomic. A document lock is taken and released on its own, never in a batch: a rollback could not give back a lock, so the server refuses a batch that takes, renews or releases one with a 400 before running any of it.

4.5.1. Bulk updates

A batch of many single updates is expensive: every request in it is processed on its own inside the one transaction, and each writes its own operation. When the changes are all of one kind, use the bulk endpoints instead, which apply a whole list in one operation and one transaction. bulk_create and bulk_delete exist for tokens, spans, relations, vocab links and vocab items. bulk_update exists for spans and relations (a value, metadata ops, or both, per entry), for tokens (metadata ops per entry) and for vocab items (a form, metadata ops, or both, per entry):

client.spans.bulk_update([
    {"id": span_one, "value": "NOUN", "metadata": [{"op": "set", "path": ["prov"], "value": "inferred"}]},
    {"id": span_two, "metadata": [{"op": "set", "path": ["provConfirmed"], "value": True}]},
])

A value is set only when the key is present (null sets a null value), and an entry’s metadata is a list of ops, as for patch_metadata (see Editing metadata). The entries may lie in several documents of one project; every document touched has its version bumped. An id that does not exist refuses the whole update, so an update never silently skips an entry.

4.6. Retrying a write

A write’s answer can be lost after the server stored it: the connection drops, a proxy answers 502 or 504, the laptop sleeps. Sending the write again must then not store it twice. Plaid gives you two tools for this, and both clients use them on every write.

4.6.1. Ids you mint

Every create that answers an id takes an optional id in its body: projects, documents (and a document’s copy), texts, the five kinds of layer, tokens (and the token a split makes), spans, relations, vocab items, vocab links, guidelines and comments. In a bulk create each item may carry its own id, and items with and without one may be mixed. The rows inside a copied document always get ids from the server.

The id must be a UUIDv7 (RFC 9562) whose time lies after 2020 and at most an hour ahead of the server’s clock, otherwise the create is refused with 400. Rows are read in id order, so an id of another version, or one stamped far in the past or future, would sort before or after everyone else’s rows for good. One id named twice in one bulk create is a 400.

An id that was used before is refused with 409, and the body says so:

{"error": "id-taken", "id-taken": true, "id": "0199...", "deleted": false, "message": "A span with id 0199... already exists."}

That holds for an id whose row was since deleted too ("deleted": true, and "existed" in the message), so a create sent again late never brings back a row someone deleted meanwhile. A comment has no history, so only a comment that still exists holds its id. Tell id-taken apart from a version conflict by the body, never by the status alone.

With the id known before the create is sent, a later write in the same batch can name it directly, and b.ref() is only needed for ids the server mints.

Both clients mint these ids: uuidv7() in JavaScript and uuid7() in Python. Each create method takes the id as an option, { id } as the last argument in JavaScript and id= in Python:

const id = uuidv7();
await client.spans.create(layerId, [tokenId], "NOUN", undefined, undefined, { id });
span_id = uuid7()
client.spans.create(layer_id, [token_id], "NOUN", id=span_id)

4.6.2. The Idempotency-Key header

Every other write, and every create too, may carry an Idempotency-Key header of 8 to 128 letters, digits or . _ : -. The server keeps the answer to a write sent with a key for 24 hours ([idempotency] retention_hours in config.toml), scoped to the user who sent it. The same key sent again by the same user for the same request is answered with that stored answer: the same status and body, the same X-Document-Versions, and Idempotent-Replayed: true. Nothing is written again, nothing is announced on the project’s stream, and no history entry is added. The replay comes before every other check, including the document-version one, so a strict resend of a write that landed is not refused as stale.

Two sends are the same request when they have the same method, path, body and query parameters. group-id, group-message, group-kind, group-ref and audit-message only label a write and are left out, while document-version counts. The same key for a different request is refused with 422, whose error is idempotency-key-reused (and "idempotency-key-reused": true, with the sentence under message). Only a success is kept: a write refused for any reason (a version conflict, a lock, a bad request, a busy database) wrote nothing, and sending it again runs it again.

A /batch takes one key for the whole batch. The key is refused with 400 on the routes a batch cannot carry, or whose answer is a secret: the lock routes, uploads (media and profile pictures), private user data, login and logout, minting an API token or an invite, /admin, and the service routes. A GET ignores it.

4.6.3. What the clients do

Both clients send a key on every write that is not a signal (outOfBand), an upload (noBatch), or a call that mints a secret. A queued write has none of its own: its batch request has one, and a batch the client splits past 1,000 operations sends one key per part. When a keyed write gets no answer (no response at all, 502 or 504), the client sends it again under the same key, up to three times after about 1, 3 and 9 seconds, unless the browser says it is offline. The error that finally escapes carries the key (idempotencyKey, idempotency_key), so a caller that sends the write again under it later is answered from the first send if it landed.

An answer the server replayed (Idempotent-Replayed: true) is marked, whether the client sent it again on its own or the caller did. wasReplayed(answer) in JavaScript and was_replayed(answer) in Python say whether it was, and so whether the write stored nothing new. In JavaScript the mark is replayed: true, not enumerable, on the answer object or array. In Python the answer is a dict or list subclass whose replayed is True. Either way the answer reads, compares and serializes as an unmarked one would. Only an answer with a JSON object or list body carries the mark. An empty or text answer does not. A batch’s results are marked when its request was replayed. A batch past the server’s cap goes as several requests, and its results are marked when any of them was replayed, since then some of the batch stored nothing new. The same holds for committedResults (committed_results) when a later request fails.

A caller that runs an operation again from the top can make it send the same keys. client.keySeed() (key_seed() in Python) gives a seed, and an operation opened with it numbers its writes, so the nth write sent inside it takes the key <seed>.<n> on every run, with the document-version the first run stamped:

const keys = client.keySeed();
const send = () => client.withOperation("Gloss word", async () => { ... }, { keys });
await send();   // and if its answer was lost, later:
await send();   // the writes that landed are replayed, the rest run
keys = client.key_seed()
with client.operation("Gloss word", keys=keys):
    ...

4.7. Trust

For all constraints which you might attempt to enforce using the three mechanisms described above, there is, of course, nothing stopping a malicious user from circumventing them and submitting changes which invalidate your formalism-specific data integrity constraints. For example, if you have a document where you want every token to carry exactly one span, a malicious user could simply circumvent your UI code entirely and craft a malicious request to e.g. delete the span on a token, leaving it with none. Since there is no server-side validation for this constraint, the server will happily execute it and advance into a database state that does not violate any core data integrity constraints but does violate your formalism-specific constraints.

This is a fundamental limitation of Plaid which was deliberately adopted in order to support frontend-only development. You therefore must only grant write privileges only to users who you trust not to circumvent client-side validation guardrails. Fortunately, we think this is not an onerous imposition in most real-world circumstances.

Note
The token layer constraints described in Token Layer Constraints and the rules described in Layer Constraints are the exception to this caveat. Because they are enforced on the server, they cannot be circumvented by a malicious client, and they hold no matter how a request is crafted.

5. Layer Interoperability

Plaid’s goal is to provide infrastructure that will allow you to point multiple apps at the same project, with data being shared across apps as much as possible. For example, someone might gloss their texts in an interlinear (IGT) editor and, on the very same documents, build a dependency treebank in a Universal Dependencies (UD) editor. Done well, this is close to magical: no import/export is needed to work on the same data across two different apps. Done poorly, the two apps will quietly corrupt each other’s data. In this chapter, we consider this very scenario as an example, and describe a discipline to be followed by app developers to ensure that apps play as well as they can with each other.

5.1. Structural Sharing

For most linguistic annotation applications built on top of Plaid, the layers can be divided into two categories. First are substrate layers: these are the primary text layer and some token layers, including the token layer for sentences, words, and maybe morphemes. There are many different apps that would be interested in working with these.

Then there are annotation layers: these hold information that probably only exactly one app cares about, such as a Universal Dependencies part-of-speech tag. These annotations typically do not need to be shared between apps.

Now, recall from Metadata and Config that each app writes its configuration under its own namespace. This configuration can help two different apps, such as one used for UD and another used for IGT, to track only the annotations each cares about: the UD app would use configuration cues to know where to look for its tag and dependency layers, and similarly, the IGT app can use configuration to find the gloss layers it uses, which can happily sit side by side on the same tokens. For annotations, each app is oblivious to the other, because each app only ever looks at the layers it created.

The substrate is the part that the apps do share, but even here they will only agree up to a point. A sentence is a sentence in both apps, and the same is usually true of a word. Below the word, though, the apps may have different needs, and one and the same word may be divided in two different ways. In the UD app, the layer beneath the word holds CoNLL-U syntactic words, which sometimes disagree with the CoNLL-U tokens above them: the English token teacher’s, for example, is a multiword token holding the two syntactic words teacher and 's. In the IGT app, the layer that is closest in purpose to UD’s syntactic word layer holds morphemes, which split on all morphology, so that this same word, teacher’s, is instead divided into the three morphemes teach, -er, and 's. It is therefore not always possible to share all token layers across apps.

A reasonable rule of thumb, then, is to share the substrate as far down as the apps agree, and to let them branch below that point. In our example, the two apps would share the text, the sentence layer, and the word layer, and then, beneath the word layer, the UD app would add its syntactic-word layer while the IGT app adds its morpheme layer. (Recall from Token Layer Hierarchy that a single token layer may have more than one child layer, so both of these can sit side by side under the shared word layer.) The apps agree all the way down to the word, and only then go their separate ways. If two apps genuinely do mean the same thing by a layer, so that their morpheme segmentation really is identical, then they are free to share it as well; but this should always be a deliberate decision rather than a silent assumption.

5.2. Roles

Suppose that a third app comes along that has no knowledge of the UD or IGT apps, and that it wants to find out what the extant layers mean. While there is no perfect solution to this, Plaid provides a small shared vocabulary of roles recorded in each layer’s configuration under the plaid namespace. A layer carries at most one role, and the inventory is deliberately small:

Role Layer What the layer holds

baseline

text layer

The primary text being annotated, over which all the other layers are built.

sentence

token layer

Sentence segmentation, normally a partitioning layer at the root.

word

token layer

The orthographic word: a whitespace-and-punctuation tokenization (CoNLL-U’s token).

syntactic-word

token layer

Grammatical words beneath the orthographic word (CoNLL-U’s word, e.g. the pieces of a multiword token like teacher’s).

morpheme

token layer

Morpheme segmentation beneath the word.

time-alignment

token layer

Tokens aligned to a media timeline, for audio or video.

Plaid itself attaches no meaning to these values. These names are rather blessed conventional names for communicating the meaning of a layer that apps are expected to honor when present. The app that first sets the project up might record the roles of its sentence and word layers like this:

await client.tokenLayers.setConfig(sentenceLayerId, "plaid", "role", "sentence");
await client.tokenLayers.setConfig(wordLayerId, "plaid", "role", "word");

The plaid namespace is reserved for conventions that every app agrees on, which makes it the right home for these shared role tags. An app’s own private configuration, such as its vocabularies, its colors, and the meanings of its fields, belongs instead under that app’s own namespace, where no other app will mistake it for shared information. Keeping these two kinds of configuration apart is what allows several apps to coexist on one project.

We also advise app developers to consider the absence of a role tag to be a meaningful signal: if a layer is not tagged with a role, then as a matter of convention, no other app should read or modify it.

5.2.1. Text direction

One further shared convention lives on a document, in the same reserved namespace but under its metadata rather than a layer’s config:

await client.documents.patchMetadata(docId, [
  { op: "set", path: ["plaid", "textDirection"], value: "rtl" },
]);

The value is "ltr" or "rtl", and the key’s absence means "read the text", which is what almost every document says. Plaid’s substrate has no notion of what language anything is in, and for a single field it does not need one: a string is a sequence of code points, and the Unicode bidirectional algorithm lays it out. What that cannot settle is the order of COLUMNS in an interlinear block or a dependency grid, because the container holding them also holds row labels written in the metalanguage. So a client resolves one direction per document and lays its grid out by it, falling back to counting the strong characters in the document’s own text. This is an override for the corpus that text cannot speak for, such as a right-to-left language written in a Latin transliteration.

Note that document metadata is otherwise a flat table of fields a person entered, one per name, and this key is an object. A client that walks metadata generically, such as an exporter writing one column per key, should skip the plaid namespace rather than serialize it.

5.3. Provenance

Annotation projects routinely mix human labor with machine output—a parser fills in a first pass, an auto-linker proposes vocabulary links, an importer carries another tool’s analysis—and it matters enormously which is which: machine output is cheap and replaceable, while human judgment is expensive and must never be silently destroyed. Some projects also take in work that is a person’s but not yet checked—a community member’s transcription, a student’s glosses—which must be kept apart from both. Plaid standardizes these distinctions as the provenance convention: flat metadata keys on annotation entities (spans, relations, vocab links, and optionally tokens) along two axes. The prov key records the origin (absent: a trusted person, a verifier; inferred: a machine; contributed: a person whose work is reviewed, a contributor), and provConfirmed records trust (a verifier vouched for the value). Together they put every entity in one of four states.

State Metadata Meaning

human

no provenance keys

A verifier made it. The absence of the prov key is the discriminator.

machine

prov: "inferred", provSource: "<producer>"

An algorithm or service made it, and nobody has vouched for it yet.

contributed

prov: "contributed", provSource: "user:<userId>"

A contributor made it, and no verifier has vouched for it yet.

verified

either of the above, plus provConfirmed: true

A verifier confirmed or edited it. prov and provSource stay, so the origin remains traceable.

provSource names the producer: service:<serviceId> for services, rule:<name> for built-in rule algorithms, user:<userId> for a contributor, or an app-specific id such as gloss:doc-frequency or flex-import. The presence of the prov key is what marks an entity as somebody’s proposal; any value other than contributed reads as machine-made. The keys are deliberately flat scalars, which the query system’s metadata filters match well, so questions like "show me every unverified machine annotation in this document" are ordinary queries.

Who counts as a contributor is the project’s decision, recorded once for every app under the reserved plaid config namespace:

"config": { "plaid": { "review": { "users": ["ann@example.org"], "roles": ["writer"] } } }

users names people whose work is reviewed whatever their access level, and roles names whole project roles (reader, writer, maintainer; an admin without an explicit role counts as a maintainer). Either list may be absent, and an absent review means nobody is reviewed. This is deliberately separate from the permission model: the maintainer, writer and reader lists say what a person may do, and the review lists say whose work needs a verifier’s look, so a trusted collaborator can be a writer without a mark and a student can be a maintainer with one. Every Plaid app that writes on a person’s behalf consults it (Plaid IGT and Plaid UD show it as a per-member checkbox on their access screens), and an app for a setting where all writers are contributors need only set roles to ["writer"] when it creates a project. Both clients ship readReview, isReviewed, projectRole and withReviewedUser (Python: read_review, is_reviewed, project_role, with_reviewed_user) for reading and editing it, and writerPolicy(contributorId) / WriterPolicy(contributor_id), which turns "is this person a contributor" into the stamps their creates, edits and confirmations carry and the material their review gestures act on, so rule 3 below has one implementation across apps.

A producer may also record prediction extras—how confident it was, and what else it considered—in two further reserved slots, split along the queryability line:

provProb

One flat number in [0, 1]: the producer’s probability for the value it chose. Because it is a flat scalar, it filters and orders in the query language, so "review the least-confident machine output first" is an ordinary query. Provide it only when you can honestly produce a probability—a raw log-probability or unnormalized score is not one, and belongs in provDetail instead.

provDetail

One open map for everything else: top-k alternatives or distributions, the model name and version, raw scores. It is deliberately nested (and therefore not query-matchable—anything worth filtering on goes in provProb), and deliberately unconstrained inside, with one piece of advice: record the top handful of alternatives, not whole-vocabulary distributions.

Prediction extras describe the machine’s original prediction. They are kept even after a human edits the entity—the history is valuable for audit and retraining—which means a consumer must not present provProb as confidence in the current value once the entity is verified: any human edit verifies (rule 3 below), so provConfirmed is exactly the flag to check before displaying it.

Of the bundled services, the Whisper transcriber records its per-segment scores this way (provDetail.avgLogprob / provDetail.noSpeechProb / provDetail.model—and no provProb, since a log-probability is not a calibrated probability), and the Stanza parser records provDetail.model and provDetail.language (its pipeline exposes no per-prediction probabilities; a parser that has them would add provProb plus a top-k distribution in the detail map). On the read side, the UD editor consumes distributions a parser records under provDetail.uposProbs / provDetail.xposProbs / provDetail.deprelProbs (each a flat {label: probability} map): the top-k floats above the rest of the vocabulary in the corresponding cell’s dropdown as a "Parser suggestions" group, and machine-made cells describe their origin (producer, model, provProb) in their hover tooltip.

Because provDetail is open, consumers that want to work across producers need agreed key names inside it. The IGT app’s producers (the PolyGloss analyzer, the built-in analysis-copy rule, and a guess a person adopts in the editor) record the predicted value itself under provDetail.value on a span and provDetail.form on a morpheme token, mirroring the entity’s own field. The entity’s value may later be edited, so this copy is what makes "accepted as-is" and "corrected" distinguishable once an entity is verified, which is the basis of any accuracy report. A producer that has a distribution records it as a top-k {label: probability} map under provDetail.valueProbs (spans) or provDetail.formProbs (morpheme tokens), the chosen label included, with provProb duplicating that label’s entry as the flat, queryable copy.

Every machine writer also says what made the prediction and which version of the writer ran, under provDetail.model and provDetail.version. model is the model’s name for a language model (gpt-oss-120b), and the tool with its release for anything else (stanza==1.11.0, nltk==3.9.1, whisper-small). A writer made only of rules in its own code, such as the UMR skeleton-from-glosses service, has no model. version is <release>+<hash>: the Plaid release the writer ran on, then the first 8 hex digits of a SHA-256. For a bundled service the hash is of the service’s own file, which holds its prompt, its rules and its defaults, so git show <commit>:<path> | sha256sum tells whether that commit’s code wrote a value (line endings count as the repository stores them). The Python client computes it as BaseService.version, and machine_detail(version, model=…​) in plaid_client.service builds the map. For an assistant the hash is of its system prompt template, every tool schema and the code of its harness and its app, which writes the rest of what the model reads (see the plaid-agent README, "Model and prompt version"). An approved assistant plan’s writes carry the model and version of the turn that proposed the plan, and their provSource names the service that turn answered as, so a plan approved after the assistant was restarted on another model still names one producer. When a contributor approves, the writes are the contributor’s, and the model and version stay beside them with the assistant’s source as provDetail.guess, as for any guess a contributor adopts. The AnCast scorer writes a report rather than an annotation, and names the same two things as tool and serviceVersion. A rule an app runs in the browser is a writer too. Its model is builtin:<name> (IGT’s builtin:analysis-copy and builtin:precedent), and its version is the app’s package version, then the first 8 hex digits of a SHA-256 over every file whose change changes what the rule writes, hashed in the browser from the files as the app bundles them. Those files are the rule’s own (analysisMemory.js, autoLink.js), autoPass.js, which runs both, the mutation that writes the output, every file of IGT and plaid-ui they import (precedent.js, which holds the linking policy, among them) and the client’s provenance.js. plaid-igt/src/domain/builtinSources.json lists them for each rule, and a unit test keeps the list equal to the imports. What is hashed is one sha256sum line per file, in the list’s order, so at a given commit this gives the same 8 hex digits:

C=<commit> NAME=precedent  # or analysis-copy
git show "$C:plaid-igt/src/domain/builtinSources.json" | jq -r --arg n "$NAME" '.[$n][]' |
  while read -r p; do echo "$(git show "$C:$p" | sha256sum | cut -c1-64)  $p"; done |
  sha256sum | cut -c1-8

A release does not stamp the apps' package versions, which read 0.0.0 in every build, so for these the hash is what names the code. Keep the rest of provDetail small: these two strings, what was predicted, and the handful of scores a reviewer would use.

The convention is made operational by a write contract that every machine writer (service, built-in algorithm, importer) is expected to follow:

  1. A machine writer may freely create new material and freely replace machine-made, unverified material—its own or another machine’s. Unverified machine output is by definition replaceable.

  2. A machine writer must never modify or delete human-made, contributed or verified material unless explicitly told to, via a per-run, user-facing opt-in. For services, the idiom is a declared boolean overwrite parameter, so the option renders in the standard argument form and is off by default. A contributor’s work is a person’s work, however unreviewed.

  3. A verifier’s edit of a machine-made or contributed annotation verifies it: alongside the new value, the editing app also merges provConfirmed: true. Explicit confirm gestures (clicking to accept a proposed link, for example) do the same. A contributor’s edit of any annotation marks it contributed: the app merges the contributed stamp and drops any earlier confirmation, so a verifier sees that a confirmed value was changed. A contributor accepting a machine proposal records it as a contribution, not a verification.

Rule 3 is what lets editors render proposals distinctly (Plaid’s apps use violet italics for machine output and amber for contributions) and have the marking dissolve naturally as a verifier works through the material—each touched annotation graduates to verified, while everything still violet remains fair game for the next machine pass.

For service authors, both clients ship helpers: stampInferred(source) / stamp_inferred(source) builds the fragment to merge into everything you create (every bulk create accepts per-item metadata) and takes the prediction extras as options (stampInferred(source, {prob, detail}) / stamp_inferred(source, prob=, detail=)), serviceSource(serviceId) / service_source(service_id) builds the canonical producer id, and isProtected(metadata) / is_protected(metadata) implements the rule-2 check against material you are about to destroy. Born-verified material—an import carrying upstream human approval, or a guess written only upon explicit user confirmation—uses confirmedInferred / confirmed_inferred instead. On the reading side, provState / prov_state classifies an entity into the four states, needsReview / needs_review is what a review UI marks and sweeps, and provOrigin / prov_origin tells a verified entity’s two origins apart. A service running on behalf of a contributor (the IGT assistant applying a plan a contributor approved, for example) stamps with stampContributed(userId) / stamp_contributed(user_id) and rewrites with contributeOnEdit / contribute_on_edit, which also drops an earlier confirmation; these helpers return fragments of top-level keys, which metadataOps / metadata_ops turns into the ops a PATCH takes (a null value becomes a delete), and mergeMetadata / merge_metadata applies a fragment to a local copy, as applyMetadataOps / apply_metadata_ops over those ops does, refusing what the server would refuse.

Note

Word and sentence tokens are substrate and are not stamped: IGT’s built-in tokenizer, the Punkt tokenizer service and the bundled UD parser all write them with no provenance keys, and the run’s operation in the audit log (service-run, with builtin:<name> or service:<id> as its ref) names what made them. Other tokens MAY carry provenance where a service writes them as part of its analysis (the bundled UD parser stamps the syntactic words it creates, as it does their annotations and relations). The write contract protects annotation content; substrate segmentation is guarded by the structural rules in Token Layer Constraints.

One migration wrinkle: machine output written before a producer adopted this convention carries no provenance keys and therefore reads as human-made. The first guarded re-run over such a document will refuse; one run with overwrite enabled replaces and re-stamps it.

5.4. Reconciliation Across Apps

In Data Integrity, we discussed various ways of maintaining app-specific data model invariants within a certain app. Here, we consider the more difficult matter of one app updating a shared substrate layer in a way that invalidates a different app’s data model invariants.

Suppose that a sentence has been annotated for UD, and that later on, someone opens the same sentence in the IGT app and splits the sentence. From the IGT app’s perspective, this is a totally unproblematic change to make. However, on the UD side, it is a strict requirement that all dependency relations must never cross sentence boundaries, and this change in the IGT app at the shared sentence tokenization layer has caused an invariant violation in the UD app.

Plaid cannot know about this invariant by itself, because it is application-specific. The UD app can tell it, though, by declaring the rule on its relation layer (see Layer Constraints), and then the split deletes the crossing relations in its own transaction. An invariant that no declared rule expresses is left to the UD app to deal with the next time that document is opened.

The discipline that resolves this is easy to state, and in the interest of smooth interoperability, we encourage all app developers to adhere to it:

Every time an app opens a document, it should validate all its invariants and automatically repair any violations that may have been introduced by other apps.

In our running example, the next time the document is opened in the UD app, the UD app would validate the data and realize that a relation is straddling a sentence boundary. The simplest thing to do at this point would be to delete the offending relation. Although information is lost this way, we are able to restore data integrity without requiring any human intervention. Ideally, the app would also loudly tell the user what happened, and perhaps even encourage or require review. And indeed, in general, some invariant violations in other situations might require human intervention in order to avoid worse consequences.

Note

It is worth running this same reconciliation not only when a document is first opened, but also whenever a document that is already on screen changes underneath you, for instance in response to a real-time update delivered through Real-time Messaging. Opening the document is the baseline; reconciling on a live update is the very same idea, applied at the moment the substrate comes back into view.

5.5. Layer Constraints

An app can declare rules on the token, span and relation layers it uses, and Plaid enforces them inside every write, whoever makes it. A rule declared by the UD app on its dependency layer holds for a split made in the IGT app, for a service, for a script and for a restore alike. Stored data always keeps the rules its layers declare.

Each rule is a constraint object with a type and the type’s parameters:

Type Layer Rule

max-in-degree

relation

A span is the target of at most max relations of the layer. A relation from a span to itself counts.

acyclic

relation

The layer’s relations in a document form no cycle. With self-loops: true a relation from a span to itself is allowed. Relations whose value is in except-values are not counted.

same-ancestor

relation

Both ends of a relation lie in one token of token-layer, such as one sentence. An end’s place is the first token of its span. The token layer must be on the same text layer and allow no overlap.

single-span

span

A token is in at most one span of the layer.

value-set

span or relation

A value is in the list values. With delimiters the value is split on each of those characters and every part must be listed, or with parts: "first" only the first. An empty value always passes.

coextensive

token

A token has exactly the extent of a token of its parent layer. Several may share one parent’s extent.

single-link

token

A token is the only token of at most one vocabulary link.

5.5.1. Declaring

Rules are declared per app, under the app’s own namespace, so two apps sharing a layer never overwrite each other’s lists. Plaid enforces every namespace’s list. Declaring needs maintainer privileges.

client.relation_layers.set_constraints(deps_layer_id, "ud", [
    {"type": "max-in-degree", "max": 1},
    {"type": "same-ancestor", "token_layer": sentence_layer_id},
])

In JavaScript the same call is client.relationLayers.setConstraints(depsLayerId, "ud", […​]), and the parameters are camelCase (tokenLayer, selfLoops). On the wire it is PUT /api/v1/relation-layers/<id>/constraints/ud with the body {"constraints": […​]}, and the same routes exist on token and span layers. The answer is the layer’s whole map, from namespace to list, which every read of a layer also carries as constraints.

A list the layer’s stored data already breaks is refused with 422 and the violations, and nothing is stored. Passing expected, the list you read for that namespace (None or null when there was none), makes the write refuse with 409 and constraints-changed when someone declared since. delete_constraints (deleteConstraints) removes a namespace’s list.

Two more routes help before declaring:

  • check_constraints (POST …​/constraints/check) lists the violations a list would meet, and writes nothing.

  • repair_constraints (POST …​/constraints/repair) applies the remedies described below to every violation in the layer’s stored data, as one operation per document (layer/repair-constraints), and lists what is left. A repair deletes no value: doubled spans are joined as a merge joins them, and a joined value outside the list’s value-set is listed among what is left. A join a value-set already stored on the layer would refuse leaves that token as it is.

A repair leaves a document another user holds the lock on as it is, and lists it under locked, as {document, locked-by}. Its violations stay in the list of what is left, so declaring the rules waits for a repair after the lock is released.

Repairing a whole layer needs maintainer privileges. Passing document (repair_constraints(layer_id, constraints, document=doc_id), or repairConstraints(layerId, constraints, undefined, { document }) in JavaScript) repairs that document alone, and a writer may ask. A document of another project, or an id no document has, is refused as any id the caller cannot see is (see Users and Permissions). An app uses this when a writer opens a document on a layer that holds none of its rules yet.

5.5.2. When a write breaks a rule

Rules are checked once, at the end of the write’s transaction: after a single write, or after the last operation of a batch. A batch may pass through a state that breaks a rule, as long as its end does not.

When a write breaks a rule on a row it wrote itself, it is refused with 422 and nothing is stored. Creating a second head for a word, a relation across two sentences, or a value outside a closed list is refused this way. The body names each violation:

{
  "error": "A span is the target of 2 relations in \"Deps\" (at most 1 is allowed).",
  "violations": [
    {"constraint": "max-in-degree", "namespace": "ud", "layer": "<relation layer id>",
     "layer-name": "Deps", "document": "<document id>", "at": "<span id>",
     "ids": ["<relation id>", "<relation id>"]}
  ],
  "violation-count": 1
}

A value-set violation also names the value and the parts not in the list. At most 100 violations are listed, and violation-count is the total. violationsOf(err) in JavaScript and violations_of(err) in Python read them off a refused write.

When a write breaks a rule on rows it did not write, because it changed what they rest on, Plaid applies the rule’s remedy in the same transaction instead of refusing the write:

  • same-ancestor: the relations left crossing are deleted. This is what happens to a dependency tree when another app splits its sentence.

  • coextensive: the tokens left without a parent of their extent are deleted, with everything on them. This is what happens to a word’s analyses when another app merges two words.

  • single-span: of the spans a token merge leaves on one token, the survivor’s own span is kept (else the one with the smallest id) and takes the distinct values of the others, joined with join-with (" | " by default) in text order, and the others are deleted with their relations. A joined value the layer’s value-set would refuse is not joined.

  • single-link: of the single-token links a merge leaves on one token, the survivor’s own is kept (else the one with the smallest id) and the others are deleted.

The remedies run as their own operation, layer/apply-constraints, in the same batch and group as the write that set them off, so History shows them under it. max-in-degree, acyclic and value-set have no remedy: a write elsewhere that would break one, such as a restore that brings back a second head, is refused.

A batch larger than 1,000 operations is sent as several transactions, each checked on its own. A batch that moves several relations should therefore send every delete before any create.

5.5.3. Values a list does not govern

Three kinds of write keep a value outside a value-set list:

  • An unverified machine value: a span or relation whose metadata has prov other than contributed and no provConfirmed: true (see Provenance). Confirming such a value is refused when the value is not listed.

  • A write in an import: an operation of kind import (see Kinds of operation). Importers keep what the source says and report what is off the list.

  • A document copy or a restore from history. Both write back values that were already stored.

A value kept this way does not stop the list from being declared again later, and any later edit of it is checked.

A part of a value is compared with the list after trimming exactly what JavaScript’s String.prototype.trim trims: Unicode white space, including the no-break spaces, and the byte order mark. valueSetAllows(constraint, value) in JavaScript and value_set_allows(constraint, value) in Python read a value the same way.

5.6. References in Metadata

An app sometimes needs to point from one of its rows to another row of the same document, in a way no relation can express. Plaid UMR, for example, records on each node without a word anchor the id of the sentence token it belongs to, so that it can tell such a node apart from one another app’s edit has moved. Metadata is the place for this, and Plaid keeps such a reference pointing at the same row wherever the rows themselves are recreated:

  • Restoring a document to an earlier time re-inserts rows under their old ids, so a reference to a restored row finds it again.

  • Copying a document gives every row a fresh id, and any metadata value that is exactly the id of a row being copied (a text, token, span, relation or vocabulary link, or the document itself) becomes the copy’s id for that row. Nested maps and arrays are followed. Map keys, strings that merely contain an id, and ids of things outside the document (layers, vocabulary entries) are left as they are.

  • Plaid IGT’s archive does the same when it imports a project, for every app’s rows, including references to other documents of the archive.

The rule is exact equality with a whole string value, so a reference should be stored as the bare id. A reference from one document into another document’s tokens or spans is not rewritten by any of these, and an app should not rely on one.

6. Querying

Plaid offers a rich query system for powering searches across one or even multiple projects. Here, we consider a few examples to give you a feel for it. For full details, see the query language reference. The examples below use the JavaScript client and the small "Fido barks" project from An Example.

6.1. A First Query

A simple query specifies what to find, and lists the conditions a match must satisfy in where:

const posLayerId = /* ... some UUID ...*/;
// every span on the pos layer whose value is NOUN
const { results } = await client.query({
  find: ["?s"],
  where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
});
// results: one row per match, each carrying the ids you asked to find
//   here, the single span over "Fido"

Read that where line as a sentence: "a span, call it ?s, on the pos layer, whose value is NOUN." The ?s is a variable — any name you invent, written with a ? — and every match comes back as a row holding one id per name in find.

6.2. Relationship Clauses

Here is a more interesting query, where we search for a noun immediately followed by a verb:

await client.query({
  find: ["?noun", "?verb"],
  where: [
    ["span", "?noun", { layer: posLayerId, value: "NOUN" }],
    ["span", "?verb", { layer: posLayerId, value: "VERB" }],
    ["covers", "?noun", "?t1"],   // the noun span covers a token ?t1
    ["covers", "?verb", "?t2"],   // the verb span covers a token ?t2
    ["precedes", "?t1", "?t2"],   // and ?t1 falls immediately before ?t2
  ],
});
// in "Fido barks": one match — the NOUN over "Fido" and the VERB over "barks"

When multiple clauses are provided to where, all must be satisfied in order to yield a match. In the latter three clauses, we use relationship clauses, which are used to enforce structural relations between entities. covers requires that a span have a token as one of its constituent tokens, and precedes requires that one token come immediately before another in linear order. These are two of several relationship clauses; the reference catalogs the rest.

6.3. Return Types

By default a match is a tuple of ids. Different return types are available, identified by return:

// a single number — the total match count
await client.query({ find: ["?s"], where: [["span", "?s", { layer: posLayerId }]],
                     return: "count" });
// -> { return: "count", count: 2, truncated: false }   // two pos spans

// or full entities, exactly as a GET would return them
await client.query({ find: ["?s"], where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
                     return: "entities" });
// -> results[0][0] is { id, layer, document, value: "NOUN", tokens: [...] }

6.4. Visibility

Normal permissions restrictions apply to queries: a query submitted by a user is only executed over data that user has permission to read. In order to further restrict visibility to e.g. a single project, the scope parameter may be provided in a query:

await client.query({
  find: ["?s"],
  where: [["span", "?s", { layer: posLayerId, value: "NOUN" }]],
  scope: { projectIds: ["<a project's id>"] },
});

Queries always read the current state of your data: there is no time-travel parameter (one passed as asOf is refused). For a document’s history, see the Audit Log.

6.4.1. Document Time Travel

A document GET accepts an as-of query parameter carrying an ISO-8601 instant, e.g. GET /api/v1/documents/{id}?as-of=2026-06-01T12:00:00Z&include-body=true, and returns the document exactly as it stood at that moment — reconstructed from the audit log, so it is always available, requires no configuration, and is never stale. A few details worth knowing:

  • Time travel is read-only and defined on the top-level document GET and on the vocabulary reads below. Document sub-routes (media, locks, metadata) and all other endpoints refuse as-of with a 400.

  • A timestamp landing in the middle of an atomic batch request is treated as falling just before that request began, so you never observe a half-applied request. A batch the client split past 1,000 operations is several requests, and a moment between two of them is observed as it was.

  • A timestamp at or after the present simply returns the current state.

  • Documents that have since been deleted remain readable at timestamps when they existed, for users with current access to the project.

  • Access control always reflects current project membership, not membership as of the requested time.

Restoring a Document

POST /api/v1/documents/{id}/restore?as-of=2026-06-01T12:00:00Z brings the document back to its state at that moment, as one operation. Every layer is covered: what was deleted since then comes back under its original id, what was added since is removed, and what changed is set back, token lists and metadata included. Because ids come back, anything that still refers to an entity by id, such as a comment, finds it again. A layer deleted since that time, and a vocabulary link whose entry no longer exists or whose vocabulary has left the project, cannot come back; they are skipped and listed under skipped in the response, along with whatever depended on them. The response is a summary of the changes, and with dry-run=true nothing is written and the summary says what would change. A restore is an ordinary entry in the audit log, so the state just before it can be restored in turn. Restoring requires maintainer privileges, and document-version applies as on any other write.

6.4.2. Vocabulary Time Travel

A vocabulary is shared across projects, so its history is its own rather than part of any document’s.

  • GET /api/v1/vocab-layers/{id}?as-of=2026-06-01T12:00:00Z&include-items=true returns the vocabulary as it was at that moment, in the same shape as the live read, its entries included with include-items. A vocabulary that did not exist then is a 404. In the clients: vocabLayers.get(id, includeItems, asOf) and vocab_layers.get(id, include_items=, as_of=).

  • GET /api/v1/vocab-layers/{id}/items/{item-id}?as-of=…​ returns one entry as it was then, also when it has been deleted since, and 404 when it was not in the vocabulary at that time. as-of is required. In the clients: vocabLayers.getItemAt(id, itemId, asOf) and vocab_layers.get_item_at(id, item_id, as_of).

  • GET /api/v1/vocab-layers/{id}/audit is the vocabulary’s audit log: every change to it or to its entries, with the same parameters, entry shape and paging as a document’s log. Vocabulary links are not listed there, since they belong to the document they annotate. In the clients: vocabLayers.audit and vocabLayers.auditPage, vocab_layers.audit and vocab_layers.audit_page, taking the same arguments as the document log’s.

Restoring a Vocabulary Entry

POST /api/v1/vocab-layers/{id}/items/{item-id}/restore?as-of=…​ puts one entry back as it was at that moment, as one operation. A deleted entry comes back under its original id with its form and fields, and a living entry has its form and fields set back. Links are not part of an entry, so a deleted entry’s links do not come back with it: restoring each linking document to a time when the link existed brings them back, now that the entry exists again. The response is {inserted, form, metadata, total}, with total zero when nothing changes, and with dry-run=true nothing is written. When the form is set back, every document linking the entry gets a new version, returned in X-Document-Versions (past fifty documents, only their number, in X-Document-Versions-Omitted). A time when the entry did not exist is a 400. Restoring requires maintainer privileges on the vocabulary. In the clients: vocabLayers.restoreItem(id, itemId, asOf, { dryRun }, auditMessage) and vocab_layers.restore_item(id, item_id, as_of, dry_run=, audit_message=).

6.5. Further Reading

See the query language reference for comprehensive information about how queries work.

7. Configuration

Plaid is configured through a single TOML file. You do not have to create it: on first launch Plaid writes config.toml into the data directory (data/config.toml) with every setting at its default value. To change something, edit that file and restart the server.

To load a config file from a non-default location, either pass --config on the command line or set the PLAID_CONFIG environment variable:

$ java -jar plaid.jar --config /etc/plaid/config.toml
# or
$ PLAID_CONFIG=/etc/plaid/config.toml java -jar plaid.jar

8. Deployment

This section is for whoever runs Plaid for other people: a course, a research group, a documentation project. It assumes nothing beyond the Configuration section above. Plaid is one process with one SQLite database, so a small machine that stays on is enough: a departmental virtual machine or the smallest cloud instance will serve a class.

8.1. What you need

  • A machine that stays on, reachable by your users, running Linux (the notes below are for Linux; the jar itself runs anywhere Java does).

  • JDK 21 or later.

  • plaid.jar from the releases page.

  • A hostname, if you want HTTPS (and you do, for anything that leaves your desk).

Everything Plaid stores lives in one directory, data/, next to the jar (or wherever [database] path and the other paths in config.toml point): the database plaid.db, uploaded recordings under media/, nightly backups under backups/, the configuration file, the signing secret jwt-secret.txt, and logs if you send them to a file. Keep that directory private to the account that runs Plaid.

8.2. First launch

Create a user for the service and a home for the jar, then run it once by hand:

$ sudo useradd --system --home /srv/plaid --create-home plaid
$ sudo -u plaid mkdir -p /srv/plaid && sudo cp plaid.jar /srv/plaid/
$ cd /srv/plaid && sudo -u plaid java -jar plaid.jar

On first launch Plaid asks for the administrator’s email address and password. That address is the account’s permanent id and what you sign in with. It then writes data/config.toml with every setting at its default and starts listening on port 8080. Stop it with Ctrl+C once you have seen it start; from here on a service manager runs it.

8.3. Running it as a service

A systemd unit keeps Plaid running across reboots and collects its logs:

# /etc/systemd/system/plaid.service
[Unit]
Description=Plaid annotation server
After=network.target

[Service]
User=plaid
WorkingDirectory=/srv/plaid
ExecStart=/usr/bin/java --add-opens=java.base/java.nio=ALL-UNNAMED \
    --enable-native-access=ALL-UNNAMED \
    -jar /srv/plaid/plaid.jar
Restart=on-failure

[Install]
WantedBy=multi-user.target
$ sudo systemctl enable --now plaid
$ sudo journalctl -u plaid -f      # follow the log

Pass those two JDK flags here rather than leaving them off. Plaid’s database driver needs them, and started without them the server relaunches itself with them added — which works, but leaves the first JVM sitting idle for the life of the service, holding a few hundred megabytes it will never use.

Leave [logging] file unset so the log goes to stdout, where journald keeps and rotates it.

8.4. HTTPS

Plaid speaks plain HTTP on its port. Put a reverse proxy in front of it to terminate HTTPS, and serve the apps and the API through the same hostname so browsers see one origin and no CORS configuration is needed. Caddy does this in three lines and fetches its certificates itself:

plaid.example.edu {
    reverse_proxy localhost:8080
    request_body {
        max_size 220MB
    }
}

Two things any proxy must allow for: request bodies as large as [media] max_file_size_mb (recordings are uploaded whole), and the server’s event streams, which are long-lived responses the proxy must pass through without buffering. Caddy does both with the configuration above; nginx needs client_max_body_size raised and proxy_buffering off on the /api/ location.

If the proxy must run on another hostname than the API, list the app’s origin under [cors] allowed_origins instead.

8.5. Backups

Every night at [backup] time (03:00 by default) Plaid writes a consistent, compacted snapshot of the database to data/backups/plaid-backup-<timestamp>.zip and keeps the most recent retention of them. That protects against a bad edit, not against a lost machine: copy the newest zip somewhere else on a schedule, and copy data/media/ with it, since recordings are files on disk and not in the database.

For a backup right now while the server runs, use SQLite’s own copy rather than cp (the database is in WAL mode, and a plain copy misses recent writes):

$ sqlite3 /srv/plaid/data/plaid.db ".backup /srv/plaid/data/manual-$(date +%F).db"

To restore: stop the service, put the database file from the backup in place of data/plaid.db (removing any plaid.db-wal and plaid.db-shm beside it), restore data/media/, and start the service.

8.6. Upgrading

Take a backup, replace plaid.jar, restart the service. Database migrations run on startup. The bundled apps are part of the jar, so they upgrade with it.

8.7. Bringing in a class

The administrator signs in first, at plaid.example.edu/igt/, and either runs the course project themselves or creates the instructor’s account from a project’s Settings > Access, where an administrator can also add and reset users. The instructor creates the project and, under the same Access section, mints invitation links: each grants a role on the project, is good for a set number of uses and days, and lets whoever opens it choose their own password. One link with fifteen uses is a class. A student who forgets a password gets a password-reset link from the administrator, minted in the same place. The IGT guide’s Sharing and Permissions section covers the roles.

8.8. Reading the log

Plaid writes one line per request, plus a line for anything that went wrong:

POST /api/v1/documents 201 34ms user=ada@example.edu ip=10.0.0.14
GET /api/v1/projects/019ecd83-7617-7501/audit 403 2ms user=sam@example.edu ip=10.0.0.31
POST /api/v1/login 401 220ms user=- ip=203.0.113.9

user= names the account the request was authenticated as, user=- one that was not (a refused token, a failed login, or anything Plaid rejected before checking credentials). A request signed with a named API token carries token=<id> as well, which is how to tell a person from their script.

Everything an administrator needs from it is also on the Logs tab of the IGT app’s admin area, which does not require a log file and works the same way under journald. It reads a buffer the server keeps in memory: requests in one half, with a column per field and a filter for an account, a status class or a search, and everything else in the other half, so a bulk import cannot push an error out of sight. It holds what has been logged since the last restart, at info and above. Where [logging] file is set, the tail of the file itself is on the same tab, and that is the only place to read messages from the libraries Plaid runs on and anything from before the restart.

8.9. Settings worth a look

  • [auth] jwt_ttl_seconds: how long a login lasts. The default is thirty days; a shared lab machine may want a day.

  • [media] max_file_size_mb: the largest recording a document may carry. Raise the proxy’s limit with it.

  • [api] expose_openapi: turn off if the API description should not be public.

  • [backup] directory: point it at a mounted volume or a synced folder to get backups off the machine for free.

9. Development

For information on how to work on Plaid itself (not an app which uses Plaid), see the development guide.