Skip to content

Classifying Documents by Sensitivity ​

Access rules answer whose a document is. Classification answers how sensitive it is. They are separate dimensions and a document must pass both: an actor sees it when they hold one of its labels and their clearance reaches its level.

That second question needs its own mechanism because labels cannot express it. Labels are a set — holding any one is enough — so there is no way to say "everything up to Confidential, nothing above". Levels are ordered, and a clearance admits its own level and every level below it.

Nothing here is built in. You define the levels, their order, their names and what places a document at each one. A scheme might be Public / Internal / Confidential / Restricted, or 公开 / 内部 / 秘密 / 机密, or three levels with names nobody outside your company would recognise.

Turning a scheme on hides everything already indexed

A document carries a level only if it was stamped with one. Documents indexed before you enabled a scheme have none, and a caller with a clearance cannot see them — there is no level to compare against, so the answer is no.

Reload, then re-stamp after enabling, the same way you would after changing an access rule. Until you do, a classified tenant retrieves nothing.

The re-stamp also re-reads your text markers from each document's stored text, so a Confidential document in a public folder is caught by what it says about itself. Documents that declare their level in frontmatter need ingesting again, since frontmatter is not kept in stored text.

Where a document's level comes from ​

Three sources, and the highest wins:

  1. The default. Everything starts here. You must name one — the engine will not guess how an unmarked document should be treated.
  2. A path rule. Legal/** is at least Confidential. First match wins, and it uses the same matcher as your access rules, so the two cannot disagree about what a pattern covers.
  3. A marker in the document's own text. A file containing Confidential or 机密 is Confidential wherever it sits.

A marker can only raise a level, never lower it, so a confidential document saved into a public folder is still caught by what it says about itself. Matching folds case, width and whitespace, so 机密 matches whether it was typed full-width or half-width.

A document can also declare its level in frontmatter — a classification: key whose value names a level by id or by any of its display names.

When a marker is also an ordinary word ​

A marker is found anywhere in the text and has no notion of a word. A marker internal therefore raises every document that says "internal network", and every source file declaring an internal function. Two settings fix that:

  • Context — the marker counts only when one of its context words is within a window of it (100 characters by default, not counting spaces). internal with context classification, 密级 catches "Classification: Internal" and "密级:内部" but not "internal network".
  • Scope — a codebase and a path, with the same patterns as a path rule, so a marker meant for documents is never read in a code repository.

The console warns when a marker is a level name or a code keyword and has no context. Whole-word matching would not help here: in "internal network" the word is whole, and Chinese has no spaces to find words by.

Markers are read at ingest, and remembered

Only ingest sees a document's text, so a marker is recorded when the document is indexed and consulted again on every re-stamp. Without that record a re-stamp would recompute from the path alone and quietly declassify every document a marker had raised.

A re-stamp re-reads text markers

Every re-stamp first re-reads each document's stored text for your text markers, and raises any document a marker now matches — no re-ingest, no re-embedding. Two things it cannot do:

  • Lower a level. Stored text is the document in pieces, and a marker can be missed across two of them, so a re-stamp only ever raises. If removing or narrowing a marker should lower documents, ingest them again.
  • Read frontmatter. It is not kept in stored text. A frontmatter marker reaches documents indexed before it only when they are ingested again.

Naming conventions and case ​

A path rule is a glob as soon as it contains *, ?, [...] or {a,b}, matched against the whole path from the workspace root. * stays within one folder and ** crosses folders, so a naming convention is one rule:

Rule pathMatches
**/*-CONF.mdany file ending -CONF.md, in any folder
**/*机密*any file with 机密 in its name
Legal/**everything under Legal
Legal/the same: a trailing / means the folder
Legala prefix — also Legalese/ and Legal.bak

Matching is case-sensitive unless the rule sets case_insensitive = true, which covers -CONF, -Conf and -conf at once, in any script that has case. It applies to that classification rule only: folder access rules are always case-sensitive, because folding case there could widen who sees a document.

A rule can be limited to some codebases with codebase_id, a literal id or a glob such as hr-*. Leave it out and the rule applies everywhere.

Office files and PDFs are never indexed as themselves — they arrive converted — so a rule ending .docx or .pdf matches nothing. Match the converted name instead.

Database tables ​

A table's rows take the level of their path, db://{source}/{schema.table}. Give a table a level with a path rule:

Rule pathLevel of
db://crm/public.salariesone table
db://crm/**every table of the crm source

The level applies everywhere a table can be reached: search, and live SQL through database_query. A caller cannot query a table above their ceiling, the model writing SQL is never shown it, and the saved queries they can list are only those over tables they may query. Row values are never scanned for markers; a table's level comes from its path, so write that path in the case the database reports — a rule matching no table leaves it unclassified.

A default above the lowest level refuses unclassified callers

Every table with no rule sits at your default level. A caller with no clearance is cleared for the lowest level only, so with a default above it they can query nothing, and database_query answers no tables admitted rather than returning no rows. Give those callers a clearance, or set the default to your lowest level.

Giving people a clearance ​

A clearance is one level id, set on an actor or on a group in Directory. A person's clearance is the highest of their own and their groups'.

Someone with no clearance gets the lowest level. That is deliberate: an actor nobody has cleared is cleared for the least, never the most. The same applies to a request that asserts no actor at all.

Administrators are bound by their own clearance too. Administering the platform and being cleared to read a Restricted document are different authorities, and joining them is opt-in (admins_bypass).

Where the text is allowed to go ​

Reading is not the only way a document leaves. The engine sends text to four models of its own, quite apart from whichever model answers a question:

EndpointWhat it receivesAbove its ceiling
Embeddingevery chunk of every fileIndexed for keyword search, no vectors
Semantic extractionwhole filesNo LLM extraction; ordinary graph nodes unaffected
Rerankercandidate resultsKeeps its place, is not re-scored
Synthesisthe snippets in an answerLeft out; the answer says how many were withheld

Give each one the highest level it is approved to receive. An endpoint you leave unset is unrestricted — enabling a scheme must not silently stop your corpus being indexed — but a level id that does not exist is refused rather than read as "no limit".

Withholding is chosen to lose as little as possible. A document above the embedding ceiling is still indexed and still findable by keyword; it simply has no vectors.

Raising a level does not recall what was already sent

If a document was embedded and you then raise it above the embedding ceiling, re-indexing it removes those vectors. Re-stamping alone does not — a re-stamp rewrites labels and never touches vectors. Either way the text has already reached the provider. You can stop the next send; you cannot unsend.

The model that answers the question ​

The four endpoints above are the engine's own and it enforces them itself. The model that answers a turn is different: the client says which model it is about to use, and the engine leaves anything above that out of what it returns.

In UGENT that is max_classification on an LLM instance. It can only narrow — the engine intersects it with the asking actor's clearance — so a client cannot use it to see more than the person may.

An absent declaration reads as no limit, while a level that does not exist reads as the lowest. A typo therefore fails safe and silence does not, which is the wrong way round. Set Refuse retrieval that declares no model ceiling to close that: a request that declares none is refused outright.

Turn it on once every client declares a ceiling. Two things make that checkable rather than a guess:

  • Access > Setup check reports model_ceiling_undeclared with how many retrieval requests since the last reload declared none — the requests the switch would refuse. With the switch on, the same count is reported as model_ceiling_refusals.
  • This console declares its own ceiling on search, workspace architecture and graph export, so turning the switch on does not refuse its pages. In UGENT, set max_classification on each LLM instance; anything else must send x-ugent-model-ceiling.

Your operator can require a ceiling for the whole deployment. It then applies to every tenant that defines a scheme, and the switch on this page shows as on and cannot be turned off.

What a level cannot do ​

Everything above governs what the engine hands out and where the engine sends text. Neither governs what happens to text after an agent has retrieved it.

An agent that can open a file reads it with its own tools — a file reader, a shell, another connected service. None of those is the engine, and none of them consults a level. So a person cleared for a Confidential document can put that document in front of any model their client is configured with, and the engine neither sees it nor can prevent it.

This matters because the two situations look identical from the console: a level shown as enforced, on a document an agent then pastes into an unapproved model. The level was enforced. It was enforced on retrieval, which is the only thing it claims.

When that gap matters, it is a deployment question

If a confidential answer must never transit an unapproved model or land in a general-purpose conversation store, configuration cannot get you there — the bound has to come from how the system is deployed. The usual shapes are a separate account with its own agent whose every configured endpoint is approved, or a separate host with no outbound internet at all, so there is no endpoint for text to reach whatever an agent decides to do.

Ask your operator which shape you are on. It is the difference between "no unapproved model is sent this text" and "no unapproved model can receive it".

What the console shows and edits ​

The Data classification card on Directory shows the scheme your tenant is actually running: the levels in rank order with their names, which is the default, and whether the scheme is your own or inherited from the deployment.

No scheme yet? The card offers two starters — four levels (Public / Internal / Confidential / Restricted) and two (General / Confidential) — with the names shown in English or Chinese first. The other language is kept either way, so a document may declare either, and clearances name level ids, which the choice does not change. A starter is created switched off, so nothing is hidden until you turn it on. It is offered only when your tenant has an access section of its own; otherwise the card sends you to Folder access rules first, for the reason under Inheriting a scheme below.

For a scheme your tenant defines, you can edit:

  • whether classification is enforced, and the default level,
  • the four egress ceilings, and whether a missing model ceiling is refused,
  • the path rules — add, remove, reorder, limit to a codebase, ignore case,
  • the content markers — text to find, or a frontmatter key.

Each row is checked before you can save: a rule with no path, a level the scheme does not define, or an unclosed [ or { is marked on the row. Rules that save but probably do not do what you meant — a prefix with no trailing /, an extension that is never indexed, a marker with punctuation — carry a warning instead.

After a save the card lists what the change still needs, in order: reload; re-stamp, for a change to rules, markers, the default or whether the scheme is on; and a re-ingest only for what a re-stamp cannot do — apply a frontmatter declaration to documents indexed before it, or lower a level after a marker is removed or narrowed. The list accumulates across saves until you dismiss it, so a later small edit does not erase an earlier one.

The levels themselves are not editable there. Their order is the scheme — a clearance admits its own level and every one below — and a renamed level id strands every document and clearance still holding the old one. Changing levels stays a deliberate config change.

Inheriting a scheme, and how to lose it ​

Your operator can define a scheme for the whole deployment. Your tenant runs it only if you define no access section of your own.

Your access section replaces the deployment's entirely

It is not merged. The first time you save folder access rules, your tenant gets its own access section — and if that section has no scheme, your tenant enforces none from then on, even though the deployment still defines one.

The console warns you above the Save button when this is about to happen. To keep a scheme, define one in your own tenant's config first.

The card tells you which case you are in: this tenant defines its own scheme, or this tenant runs the deployment's.

On a single-tenant engine ​

A context engine in single-tenant mode takes its scheme from its own server.toml, under [access.classification], and applies it whether or not [access] enabled is true. With access control off the engine keeps no directory of people, so it cannot tell who is asking. Every caller is cleared for the lowest level only, on every route that returns documents. Switching access control off removes per-person access, not the scheme. To let anyone read above the lowest level, turn access control on and give people their clearances.

Checking it ​

Access (/app/access) reports classification_not_inherited when the deployment defines a scheme your tenant does not use. As with every other finding there, it reads configuration only and no indexed content.

Released under the Private Beta License.