rlmumford / document
A document: one file people treat as a unit, what it is, what state it is in, and what has been worked out about it.
Requires
- drupal/core: ^10.2 || ^11
- drupal/entity: ^1.4
- drupal/typed_data_plus: 2.0.x-dev
- justinrainbow/json-schema: ^6.8.2
Requires (Dev)
None
Suggests
- drupal/pdf_tools: Required by the document_generation submodule.
- rlmumford/checklist: Required by the document_checklist submodule.
- rlmumford/task: Required by the document_task submodule.
Provides
None
Conflicts
None
Replaces
None
This package is auto-updated.
Last update: 2026-11-02 16:29:22 UTC
README
A document: one file people treat as a unit, what it is, what state it is in, and what has been worked out about it.
file and files
Files arrive in files, as they were given to us. Where several make up one
document — six photographs of a bank statement, a covering letter and a CV —
they are composed into file, which is the single readable thing anyone
actually opens.
The originals stay. A composition can be wrong, a page can be missing, and the only way to find that out afterwards is to still have what came in.
Status
needed, not_needed, received, refused, approved, rejected,
expired, superseded.
needed is the one that justifies the entity: a document somebody has been
asked for and has not sent is still a thing the system must hold an opinion
about, and a file cannot represent the absence of itself. superseded is the
other — replacing a document must not mean destroying the one it replaced.
A plain list rather than a workflow, because the transitions differ by document type and by whose document it is, and baking one set of them in decides that for everybody.
analysis_data
Whatever has been worked out about the document — extracted text, a parse, a model's answer — with what produced it. It belongs with the document rather than in a cache that can be cleared.
A map, so it is an array in PHP and serialized in storage, which means it is
not queryable. CounselKit's D7 original used a real MySQL JSON column and can
query it. When something here needs that, the column type is the change rather
than the shape of the data.
Types
A config bundle, because a CV and a bank statement share nothing but a file
field. A general type ships so the module is usable on install; add your own
before it accumulates everything.
Choosing a document instead of uploading one again
document_selector is a widget for an entity_reference field pointing at
documents. It offers the documents this person has already given us, of the
right type, alongside an upload for a new one — and asks what to call a new one,
so next time the list is a choice rather than three files called document.pdf.
Configure it with the document type new uploads become, and the extensions and size you will accept.
It serves both entity_reference and entity_reference_revisions. On a revision
field it records which version of the document was chosen, so replacing a file
later does not rewrite what was already sent somewhere.
owner and person
The owner is whoever provided the document and is answerable for it. person is
whose life it describes. Usually the same; occasionally the whole point — a case
worker scanning a client's bank statement, a recruiter uploading somebody
else's CV.
Named for what it holds rather than for the relationship. A document can be
about a bank account, a property, a company; "about" would have to mean all of
them and so would mean nothing, and one field cannot hold them anyway without
dynamic_entity_reference. Those get fields of their own when something needs
them.
getPersonId() reads person, falling back to the owner where nobody said
otherwise, and document_selector offers documents on the same basis. That
fallback is why person is left empty rather than defaulted: "nobody said" has
to stay distinguishable from "it is theirs", or the recruiter case silently
becomes the recruiter's own document.
What this module deliberately does not know
Whether a document contains special-category data under Article 9. That question only means something alongside a consent model — what you are allowed to do having been told yes — and a module that stores files should not carry half of one. Add the field where the consent lives.
Revisions
Replacing the file in a document creates a new revision rather than overwriting it, so anything that referenced the old one — an application that was already sent, say — still resolves to what it actually sent.
Revisions carry a log: who made them, when, and why. Without that, "there are four versions of this" answers nothing, and the question asked of a document years later is always who changed it and what they were doing.
The log is deliberately not inherited. revision_log is a revisionable
field like any other, so loading a document and asking for a new revision would
carry the previous message forward untouched - and every revision would then
claim the reason given for the first one that had a reason. That is worse than
an empty log: an empty log says nobody recorded why, an inherited one says
something false.
So setRevisionLogMessage() stages a message rather than writing it, and
preSave() puts whatever was staged onto the revision being written - nothing,
if nothing was staged. The field itself goes on saying what was actually stored,
so loading a document and reading its log gives the reason the current revision
was made, which is the only thing anybody wants from it.
getPendingRevisionLogMessage() reads what is staged, if you need it.
Who and when are stamped on every new revision, not only the ones made through a form - otherwise a revision created by an update hook or a migration has no author and no date.
Named review requirements
Document types own a reviews map keyed by requirement name, such as staff,
debtor and creditor. Each definition contains its label, human instructions,
allowed machine decisions, whether it is required, reviewer eligibility, an
optional AI analysis prompt and a JSON Schema Draft 7 analysis contract.
The document-type form lists these definitions in a table. Each has its own edit route; Add review requirement creates another. Editing a job's review item only selects the named requirement and maps its document context. The job does not copy or override the type's policy.
Eligibility has two modes, both in addition to review documents permission and
document view access:
permission: require a specific permission. The default staff review requiresreview documents as staff(grant this to the appropriate staff roles).context: use the standard Typed Data Plus context mapping widget and handler to resolve one requiredentity:userfrom the document or an available global context. For example,document.person.entity, or a path through a consumer's case reference to its debtor. Missing relationships fail closed. The current actor must match the resolved user, even if that actor has administrative permissions. This neither logs in as that person nor grants them access.
These are workflow capacities, not Drupal role assignments. Consumers provide case/debtor/creditor fields; the reusable document module defines none of them. This slice maps one person per named requirement. Distinct people whose approvals are all required should have distinct named requirements.
Individual evidence and requirement status
document_review records one person's review. It stores the named requirement
in role, its historical label in role_label, the document and reviewed
revision, executing user, machine decision and historical decision label, reason,
time and revision/file-reference fingerprint. It accepts opaque source/attempt
IDs. Storage rejects updates; a new attempt creates new evidence rather than
rewriting old evidence. A unique receipt prevents source/attempt replay.
document.reviewer::record() reloads the document, checks access and eligibility,
verifies the fingerprint and validates the decision. Reviewer identity always
comes from the executing account. Supplying optional structured analysis validates
it against this named definition's schema and preserves both JSON data and that
schema with the evidence. This keeps old analysis interpretable if the type's
schema changes later. Human review may omit analysis; no model run is implied by
this human submission API.
requirements($document) returns each required definition's label, met flag
and the matching review UUID, if any. It uses the latest evidence for that
requirement and current document fingerprint, restricted to the current mapped
person where applicable. Only approved satisfies full-scope approval. It does
not mutate document.status, create tasks or authorize viewing other documents.
partial accepts a narrower scope, which a subsequent workflow must describe and
request the remainder of. incomplete rejects an insufficient submission while
allowing a continuation to retain uploaded material. Neither approves the
original full scope. This slice records the distinction; it does not choose
between new revisions and replacement requests or perform those follow-up effects.
The fingerprint identifies the revision and ordered file field values, ignoring empty field items; it is not a file-byte hash. Replace managed files rather than changing bytes in place, and retain document revisions for historical evidence. Empty-file fingerprints are now normalized, so old empty-file approvals may need reviewing again; evidence is never silently retargeted to a different version.
AI preparation boundary
prepareAnalysis($document, $name) authorizes the named review and returns:
- A prompt resolved with the shared Typed Data Plus placeholder resolver, using
the current document typed data (for example
{{document.label.value}}). - The analysis schema, allowed decisions, review name and document fingerprint.
Unresolved placeholders fail rather than silently removing review checks. Schemas must be inline Draft 7 objects; references and base URIs are rejected. This keeps the schema valid both alone and nested inside checklist operation parameters. The complete schema is validated against the JSON-schema library's bundled meta-schema. Required properties and enums are enforced when analysis is supplied.
There is no AI runner or model invocation in this slice. A later adapter must record its run, model and execution authorization explicitly; it must not use the human-record API to pretend a staff member reviewed something they did not. Human confirmation of AI recommendations should link to that analysis/run. Prompt configuration alone never grants authority to approve a debtor's document.
Current-review condition
The native Drupal condition Document required reviews approved
(document_reviews_approved) checks whether every required named review is
approved for the current document version. Its single Document input uses
standard Typed Data Plus context mapping. It is available anywhere native
conditions are configured: checklist applicability/actionability/required rules,
templates and job rules. It requires no task integration module.
For a later checklist item, map the document outcome of the review item:
conditions: actionability: id: document_reviews_approved negate: false context_mapping: document: 'item:review:document'
The condition re-reads current evidence instead of relying on the earlier item's completion or a remembered event. Replacing the document, changing its required reviewer or recording a later rejection can make it false again. Partial and incomplete do not satisfy approval. No required definitions also returns false.
Normal Drupal negation is supported. Missing, unsaved, deleted or inaccessible documents raise a missing-context result, not false: negation cannot turn an unavailable document into a satisfied gate. The checklist evaluator treats that result as unresolved. Conditions do not grant review permission or reschedule existing tasks by themselves; they are checked when their owning workflow runs.
The result is not cached because evidence and mapped reviewer relationships may change independently of the document context. This condition does not reopen completed items or retract previously performed actions.
Updates and integration
document_update_10001() installs review storage. document_update_10002() adds
analysis and historical role-label fields and converts the original single review
policy into a named staff definition, preserving instructions and decisions.
Existing records remain untouched. Existing non-staff role strings need matching
consumer-owned definitions before they can fulfil requirements.
Enable document_checklist for the checklist forms, resources and operation.
Review completion and workflows
document.reviewer::summary($document) exposes status (pending, complete,
not_required), met, total and the per-requirement evidence. It evaluates the
current document version and current type configuration. The Required reviews
extra display component shows this summary in document views, including checklist
resources; hide or reorder it through Manage display. It does not expose analysis,
review reasons or reviewer identities. The summary is not render-cached because
reviewer mappings may use global contexts or related entities.
Recording a review through document.reviewer::record() emits
ReviewRequirementsCompleted only when the required set changes from incomplete
to fully approved. No required definitions means not_required, not completion.
The event carries the reviewed document and matching requirement evidence. Review
recording and event consumers run in the same transaction; an exception rolls back
the review and database effects. Event subscribers must not perform irreversible
external operations there: persist work to dispatch after commit instead.
Approval followed by rejection and a later approval can produce a new completion. A new document revision requires fresh evidence. Editing type configuration or importing review entities directly does not manufacture historical completion occurrences. Integrations submitting decisions must use the review service.
Enable Document Task (document_task) to expose
document.reviews_completed in the existing dependency widget and Entity Template
dependency component. Its required context is Reviewed document. With
task_dependency_job enabled, the same event appears among job triggers as
dependency_event:document.reviews_completed; its document context can populate
the configured task template. Existing trigger actions and conditions still apply.
Dependencies retain the established remembered occurrence semantics: register before completion to wait for that event. Registering after completion waits for a later event. Replacing a document or losing approval changes the live summary but does not erase an existing dependency receipt, reopen resolved work, or undo an action. This is not a continuous “document remains approved” gate. A consumer requiring that guarantee must check current requirements before acting.
Neither the event nor the summary changes document.status. Partial/incomplete
follow-up requests, status policy and AI execution remain separate integrations.
Review history
Document views include a small Review history link alongside the Required
reviews component. /document/{document}/reviews displays up to 25 records per
page, most recently recorded first, with stable review-ID pagination. Each entry
shows the recorded decision and role labels, actual reviewer, review time, reason,
and whether its fingerprint matches the current document version. Labels are
historical; reviewer names are current account labels, or User N when the account
is deleted or cannot be viewed. Raw analysis and analysis schemas are not shown.
History is evidence, not a second approval summary: multiple decisions may concern the current version, and changes to required reviewers or type policy can change which evidence satisfies a requirement. Required reviews remains the source for current approval progress. Reading history never records a review, changes a document's status, or completes checklist work.
The document.review_history builder requires document-view access and filters
individual reviews through their access handler. A viewer does not need permission
to perform reviews. The page is not cached, so permissions and version comparisons
are reevaluated. document_checklist adds a resource-pane adapter; the document
module itself has no checklist dependency.
Individual review follow-up
document.reviewer::record() also emits ReviewRecorded after each successfully
recorded decision, carrying the document and the new immutable review entity.
This occurs after evaluating aggregate completion, inside the same transaction.
It is available without checklist or task modules. Direct entity imports do not
emit this event. Replaying a nonempty source/attempt receipt fails before emitting
another occurrence; intentionally recording another review is a new occurrence.
With document_task and task_job enabled, Document review recorded
(document.review_recorded) is available on the job's Triggers tab. It supplies
Reviewed document (document) and Recorded review (review) contexts to
the existing condition and Entity Template configuration. For example, two
condition-string conditions can select an incomplete staff review:
conditions: - id: condition_string condition_string: 'review.role.value == "staff"' - id: condition_string condition_string: 'review.decision.value == "incomplete"'
Define document and review on the follow-up job's Contexts tab, with types
entity:document and entity:document_review. With task_context enabled, use
Task Context (Select) template components to retain the event evidence:
components: document: id: task_context.data_select uuid: document task_context: document selector: document review: id: task_context.data_select uuid: review task_context: review selector: review
The follow-up checklist can then use task_context:document and
task_context:review, including selectors for review.reason.value,
review.reviewer.entity, and the recorded machine decision. Template placeholders
can use {{document.label.value}} and {{review.decision_label.value}}.
The event does not create a replacement request, narrow a partially approved scope, change document status, or choose a follow-up assignee by itself. Those choices belong to the configured job/template and assignment rules. Review roles and decisions are configurable machine names; they are not hardcoded trigger variants. A later approval does not erase previously created follow-up work.
Follow-up database writes and evidence commit together. A failing subscriber
rolls both back. Subscribers must enqueue external work for after commit, rather
than sending communication or invoking external systems inside this transaction.
The existing document.reviews_completed dependency occurrence remains the
separate signal that every required review is approved. An individual review
trigger is not an aggregate-approval gate.