Authority: who may change what
Every change to a document passes one gate, a DocumentAuthority: commands, typing, paste, undo and redo, agents' turns and previews. The gate judges the change by what it does (a DocumentDiff of the document before and after), for the actor making it. Chrome asks the same gate before anything changes, so a control is disabled exactly when its change would be refused.
dart
final authority = DocumentAuthority(
actor: () => DocumentActor(id: user.id, roles: user.roles),
rules: [approvedIsFrozen],
);
final controller = DocumentEditorController(
authority: authority,
onRefused: (refusal) => showSnackBar(refusal.message),
);The typed DocumentController takes the same rules (DocumentController(configuration, rules: [...])), and a typed DocumentEditor judges by its controller's authority.
The rules, in order
The built-in rules run first, then the host's rules:
- mode: the actor's interaction mode admits what the change does.
- protection: protected regions and the document-wide restriction.
- grants: the actor holds the grants the change needs (deciding another participant's suggestion, editing or resolving another's comment).
- write scope: nothing outside the actor's write scope changes.
The first refusal wins. A refusal is a DocumentRefusal: a stable reason to branch on, a message for people and agents, and the region, block and grant involved when known.
DocumentRefusalReason | Meaning |
|---|---|
mode | The actor's mode does not admit the change (detail names the mode) |
lockedContent, lockedPlacement, lockedStyle | A locked region's text, placement or styles would change |
comments | A comment would change in a region that forbids comments |
documentRestriction | Content outside every editable area would change under a document-wide restriction |
protectionDefinition | Protection itself would change, which only its owner, its editors or template design may do |
missingGrant | The actor lacks refusal.grant |
outsideWriteScope | Content outside the actor's write scope would change |
notAdmitted | The editor admits no change right now: it is preparing, suspended, or showing the page overview (detail names why) |
notApplicable | The command has nothing to act on (nothing selected, or the selection already is what it would make it) |
DocumentRefusalReason('your-id') | A host rule's own reason |
One mode table
DocumentInteractionMode holds the one table of what each mode admits. Input admission, the mode rule, the key path and every command surface read it, so they cannot disagree:
| Mode | Content | Comments | Decide suggestions | Define protection |
|---|---|---|---|---|
editing | yes | yes | yes | yes |
suggesting | as recorded suggestions | yes | yes | no |
reviewing | no | yes | yes | no |
commenting | no | yes | no | no |
viewing | no | no | no | no |
mode.admits(kind) answers it for a DocumentCanonicalTransactionKind (content, annotation, reviewDecision, protection, or none for navigation and copy). An EditorAction declares its transactionKind, and it is offered in a menu, the palette or the context menu exactly in the modes that admit that kind; there is no separate list of modes per command. Protection commands use the protection kind, so they are offered only while editing.
Host rules
A DocumentRule judges a change, and optionally a block or a span before any change exists:
dart
final approvedIsFrozen = DocumentRule(
id: 'approved-frozen',
// The change itself: before and after.
judge: (diff, actor) => textOf(diff.before) == textOf(diff.after)
? null
: const DocumentRefusal(reason: frozen, message: 'Approved text is frozen.'),
// "Could I edit this block?", asked up front.
judgeBlock: (document, blockId, actor) =>
isApproved(document, blockId) ? frozenRefusal(blockId) : null,
// Optional: the same question for part of a block's text.
judgeSpan: (document, blockId, span, actor) =>
span != null && span.start < labelLength(document, blockId)
? labelRefusal(blockId)
: null,
// It reads only the block it judges, so an edit re-judges only the blocks
// the edit touched.
blockScope: DocumentRuleBlockScope.ownBlock,
);judgeSpan receives the characters (start, end) a selection covers inside one block, or null for the whole block. A rule without one is asked judgeBlock for every block a selection touches.
Rules that read outside state
When a rule's answer depends on something the document does not hold (a workflow state, a server flag, the time), give the authority a ruleRevision that changes with it. Cached answers (protection marking, the review timeline's controls, document-level chrome) are kept only while it stays equal:
dart
final authority = DocumentAuthority(
actor: () => currentActor,
rules: [frozenWhileInReview],
ruleRevision: () => workflow.revision,
);
workflow.addListener(() => controller.configureAuthority(authority));Calling configureAuthority with the same authority republishes only when its ruleRevision moved. The workflow example freezes a draft while a workflow has it in review (website/examples/gallery/workflow.dart).
Asking "could I?"
| Question | Ask |
|---|---|
| Could I edit this block? | authority.judgeBlock(document, blockId) |
| Could I edit this part of a block? | authority.judgeSpan(document, blockId, (start: 4, end: 9)) |
| Could I edit through this selection? | authority.judgeSelection(document, selection, selectedTextBlockIds); on the editor, controller.selectionEditRefusal |
| Could I make a document-level change (rename, page setup, find and replace, the source pane)? | controller.documentEditRefusal |
| Could I make this exact change? | controller.refusalOf(() => controller.acceptRevision(id)), a dry run that applies nothing |
| Could I protect, make editable or unprotect the selection? | controller.protectSelectionRefusal, permitSelectionRefusal, unprotectSelectionRefusal |
| Could I author protection at all? | controller.protectionAuthoringRefusal |
| Could I edit this protected region? | controller.protectedRegionEditRefusal(region) |
| (typed) Could I edit this node or area? | controller.canEdit(ref), controller.protection.areas[i].canEdit |
Each answers null when the change would be admitted, and otherwise the refusal the change itself would get. Inside a single block, a caret beside a locked phrase stays editable and a caret at the edge of an editable range may type into it; under a document-wide restriction a caret outside every editable range is refused.
documentEditRefusal judges a rename of the document as the stand-in for every document-level change. In a mode that records suggestions it is judged as the suggestion the change records, so suggesting keeps find and replace and page setup available while a read-only restriction, the commenting mode or a host rule disables them. It is also refused while the page overview holds editing, until the reader zooms in.
One refusal channel
A refused change changes nothing and is published once, on controller.refusals (a ValueListenable<DocumentRefusal?>) and to the onRefused callback. The typed DocumentController.run returns a NotAllowed error carrying the same reason in found.