A quick way to hold the whole service in your head: there is exactly one thing that is authoritative, the stream of collaborative edits, and everything a caller can ask for is a projection of it. Start there, because every other design decision falls out of it.
The one-paragraph version
content-service is a generic content primitive. It does not know
what a "matter document", a "note" or a "template" means: upstream services own that.
What it owns is the mechanics: how a document is initialised, edited, snapshotted, reconstructed,
versioned and exported, and how files are uploaded and security-scanned in one place instead of
being reimplemented per domain. The editable body of every document is a Yjs CRDT
holding a ProseMirror tree, persisted as an append-only log of update bytes plus periodic
materialised snapshots. Everything else (Markdown, plain text, a DOCX, a PDF) is derived from
that on read.
The old document-service tried to own a slice of this (firm precedent templates)
but was never fully wired up. It was deleted in June 2026; content-service,
built fresh, subsumed the idea with a far more general model.
Demo first
One document, many faces
Here is a single matter document, a short client letter. Internally it lives as one Yjs document (a ProseMirror tree). Click through the formats a caller can request: each one is the same content, transformed on the fly by a different chain of libraries. Watch the transform chain and the libraries change underneath. This is the heart of the service, so it goes first.
representation
Every read first reconstructs the Yjs state (snapshot + replayed tail updates), turns it into ProseMirror JSON, then projects to the requested format. getContentData.ts is the hub. The default format Lawrence reads is Markdown.
The two services
Two services, one survivor
You asked about document-service and content-service together, so
start with the honest headline: there is only one service now. document-service was
deleted in PR #11249 ("Remove the unused document-service", June
2026). What you see on disk under services/document-service/ is the wreckage:
a dist/ build folder, a .turbo log, and two generated Prisma artifacts
(schema.dbml, enums/index.ts). None of it is tracked by git anymore.
There is no src/, no package.json, no schema.prisma.
Its history is short. It was carved out of identity-service in January 2026 to
own firm precedent templates: upload a DOCX, store it in S3, convert it, preview it,
post-process it with an LLM. It never stabilised. The removal PR notes it "was already pointing
at the identity service URL as a placeholder and was clearly non-functional". Meanwhile
content-service was created fresh in February 2026 (#8153) on a
completely separate track, matured quickly, and made the template-owning service redundant. This
was a supersession by deletion, not a code merge: no document-service code or
migrations moved across. content-service rebuilt the idea from scratch, more generally.
The contrast is the clearest way to see why content-service is shaped the way it is. The old model baked the domain (a firm precedent, in a jurisdiction, in a legal area) straight into the table. The new model pushes all of that meaning up into the calling service and keeps only a generic, versioned, collaborative body.
document-service · removed
one table, domain baked in
DocumentTemplate:type(only everFIRM_PRECEDENT),firmId,name,originalFilename,s3Key(the DOCX),htmlContent(filled in after a one-shot conversion),status,previewS3Key,previewStatus,legalAreaId(a Sanity category).DocumentTemplateJurisdiction: a join table tagging each template with jurisdictions.- Body was a flat HTML string. No versioning, no collaboration, no reconstruction. Convert once, store the result.
- Cross-service references were bare strings (
firmId,createdById) pointing at identity-service.
content-service · live
generic primitive, domain lives upstream
Templateis generic:type(DOCUMENT), a richerstatus(PROCESSING / ACTIVE / FAILED / DEPRECATED), and ascopestring instead of a barefirmId. It gainscurrentVersionId/publishedVersionId: a draft-vs-published lifecycle the old model had no concept of.- The body is a Yjs CRDT (
Content+ContentUpdate+ContentSnapshot): versioned, collaborative, reconstructable. - Files are a first-class
Filemodel with a full lifecycle, shared by every domain, not two columns on a template. - Domain meaning (what is a precedent? a matter document?) lives in
matter-serviceand the library above.scope+createdByIdentityIdreplace the hard-coded references.
Jurisdiction tagging did not survive the move. The nearest new concept, firm-scoped TemplateCategory, is categories, not jurisdictions. That capability was dropped, not ported.
⚠ One live leftover
The decommission was thorough (the tRPC client, the router, the shared Inngest event were all
removed), with a single miss: scripts/local-e2e-setup.sh still lists
services/document-service:5560 as a Prisma Studio target. It points at a directory
that no longer exists in git. Harmless in production, but it can trip the local Studio startup
loop. A one-line delete fixes it.
The data model
The data model, entity by entity
Ten tables in one Postgres database (content_svc), in three loosely-coupled
clusters: a file cluster, a content cluster (the interesting
one), and a template cluster layered on top of content. Click any entity to see
its fields and how it links to the rest. The composite relations are the subtle part: a snapshot
is identified by (id, contentId), so a version can only point at a snapshot that
belongs to the same content. That is enforced in the schema, not in application code.
Solid arrows are required/listed relations; dashed are lineage pointers (latestSnapshotId, sourceFileId, sourceContentSnapshotId). Event is a framework event-store table that is currently unused.
click an entity
Content
The three relationships worth memorising
Content → ContentUpdate is append-only and the source of truth.
Content → ContentSnapshot are materialised checkpoints of the Yjs state, one
of which is the latestSnapshot reconstruction base. ContentVersion →
ContentSnapshot pins a user-visible label to one immutable snapshot. A
Template is just a Content plus a pointer to its published
ContentVersion.
The storage core
How editable content is really stored
This is the idea everything else hangs off. A document's body is a Yjs CRDT:
a conflict-free replicated data type that lets many editors (a lawyer, the editor's autosave,
Lawrence) mutate the same document and converge. content-service never stores "the current
document" as a single blob it overwrites. It stores an append-only log of update
bytes (ContentUpdate), plus periodic materialised
snapshots (ContentSnapshot) so it does not have to replay the entire log
on every read.
Reading "the latest" means: take the most recent snapshot, then replay every update that landed after it. Reading a version means: use that version's snapshot directly, no replay.
Two integers keep concurrent writers honest. currentEpoch is an
optimistic-concurrency guard: a writer sends the expectedEpoch it read, and if it no
longer matches, the write comes back stale and the client must re-read. The
batchId is an idempotency key: replaying the same batch returns exists
rather than duplicating it. Play with it below.
live Mirrors applyContentYjsUpdate, getContentState (snapshot + tail replay), createLatestContentSnapshot and the epoch/batchId guards. Square = snapshot, dot = update, amber = a labelled version.
Versions, snapshots and the auto-milestone
A ContentVersion is the user-visible layer on top of all this: a label
("Automatic milestone", or whatever a lawyer types) pinned to one immutable snapshot. Versions
come from three places. A caller asks for one explicitly. A new version is created from a source
(a fresh DOCX upload, some Markdown, another document). Or the service makes one on its own.
That last path is the auto-milestone. Every persisted update fires a
content-service/content.updatePersisted event. An Inngest function debounces those
per document (120s, capped at one hour), and once a document has had edits and its latest version
is at least 15 minutes old, it checkpoints a new "Automatic milestone" version with a null
author. It is the safety net that means history exists even when nobody clicks save.
The transforms
The transformation pipeline
The explorer above showed the outputs. Here is the machinery. The canonical
in-memory shape is a ProseMirror tree living inside a Yjs document, in the Yjs
XmlFragment named "content" (the constant CONTENT_ROOT). The binding
between ProseMirror and Yjs is @tiptap/y-tiptap (TipTap's successor to
y-prosemirror): prosemirrorJSONToYDoc on the way in,
yXmlFragmentToProseMirrorRootNode on the way out. The ProseMirror schema itself is
built once from getOoxmlExtensions(), a TipTap extension set shared by every
conversion, so Markdown, HTML, DOCX and the editor all agree on what a valid document is.
Everything inbound funnels through one hub, createContentFromSource: plain text,
Markdown, HTML, ProseMirror JSON, a raw Yjs snapshot, another document, or a template all become
ProseMirror JSON, which becomes a Yjs snapshot. Everything outbound funnels through
getContentData, which reconstructs the Yjs state and projects it. The libraries
doing the work:
| Library | Role in the pipeline | Direction |
|---|---|---|
yjs | The CRDT itself: snapshot encode/decode, incremental sync via diffUpdate | storage core |
@tiptap/y-tiptap | ProseMirror ↔ Yjs binding (the heart of every read and write) | both |
@tiptap/core | Builds the server-side ProseMirror schema; nodeFromJSON for slices and the map | both |
@lawhive/ooxml-extensions | The TipTap node/mark set: the OOXML-aware schema definition | schema |
@lawhive/ooxml-schema | validateDoc / parseDoc / serializeDoc (validate + normalise) | both |
@tiptap/markdown | Markdown serialise (read) and parse (import) | both |
@tiptap/html | HTML → ProseMirror JSON import only (no server-side PM→HTML) | inbound |
@lawhive/ooxml-parser | parseDocx: DOCX bytes → ProseMirror + round-trip metadata | inbound |
@lawhive/ooxml-generator | generateDocx: ProseMirror + metadata + shell → DOCX | outbound |
| AWS S3 + PDF Lambda | DOCX → PDF, bytes-in / bytes-out via S3 staging | outbound |
node:crypto | sha256 textHash per map block (drift detection) | map |
Why a DOCX round-trips faithfully: the shell strategy
A Word document is far more than its text: styles, numbering, a theme, headers and footers,
fonts. If content-service parsed a DOCX into ProseMirror and later regenerated it from the
ProseMirror alone, all of that would be lost. So on import it keeps the original. The bytes are
parsed into ProseMirror, and the document's OOXML metadata is serialised into a
ContentShell linked to the source file. On export, that metadata is
re-applied and the content is patched back into the original .docx as a shell buffer. The
text is yours; the chrome is the file's.
No source file? A blank shell (getDefaultBlankShellBuffer()) and BLANK_OOXML_METADATA stand in. This is the same fidelity path the on-platform DOCX editor relies on.
The PDF path, precisely
There is no PDF renderer in the service. getContentPdf generates the DOCX, then:
(1) PutObject the bytes to _staging/content/<id>/<run>.docx;
(2) POST to PDF_CONVERTER_LAMBDA_URL (a SigV4-signed Lambda URL) with
{ s3Key, s3BucketName }, getting back { previewS3Key };
(3) GetObject the PDF; (4) in a finally, delete both staging objects.
The Lambda only ever sees S3 keys, never inline bytes. The PDF is ephemeral. And, again, the
README's "NOT_IMPLEMENTED" note for this is stale: only the unrelated S3-mode
preview helpers are still stubs.
The file domain
The file lifecycle: S3 and GuardDuty
The file domain is the only part of content-service that talks to S3, and it
exists so that "upload a file, scan it, mark it ready" lives in one place instead of being
rebuilt in every domain that needs an attachment. A file starts as a PENDING row
with a presigned upload, and the bytes go straight to S3 from the client, never
through the service. The S3 key encodes the scope, so storage layout follows
ownership:
files/<scope>/_external/<fileId>.data— a user uploadfiles/<scope>/_internal/<fileId>.data— platform-generatedfiles/<scope>/_derived/<fileId>/<artifact>— a derived asset
where <scope> is a typed path like matter/mat_123 or
firm/fir_1/matter/mat_2. The presigned POST caps uploads at 200 MiB
and expires in 120 seconds. Only original .data objects take part
in the lifecycle below.
The lifecycle hinges on one distinction. INTERNAL files are trusted (the
platform made them) and jump straight to READY. EXTERNAL files (a
human uploaded them) go to UPLOADED, trigger a GuardDuty malware scan, and only reach
READY after the scan comes back clean and the object carries the verifying
tag. The scan result arrives the long way round: GuardDuty publishes to SNS, which hits
webhook-api, which emits an Inngest event back into content-service.
updateFileStatus Inngest function reacting to four S3 webhook events. The verifying tag is GuardDutyMalwareScanStatus = NO_THREATS_FOUND, polled up to three times with backoff before a clean result is trusted. Terminal states emit a content-service/file.* event the rest of the service listens to.The async layer
The async machinery: Inngest and events
Everything that cannot happen inside a request happens through Inngest. There
is a subtlety worth flagging up front: content-service has a @lawhive/framework
event-store wired in (the Event table), but its events router is empty, so
nothing writes to it today. Every content-service/* event you will see is an
Inngest event, sent with inngest.send() and consumed by an Inngest
function. The framework store is scaffolding for a future that has not arrived.
A second thing to notice: every content-service/* event is produced and consumed
entirely within content-service. No other service listens to them. The only cross-service
coupling is at the webhook/s3/* layer, where content-service and matter-service both
listen to the same S3 webhook events but filter on different buckets. content-service is more
self-contained than it first looks.
| Inngest function | Triggered by | Config | What it does |
|---|---|---|---|
| file.updateStatus | webhook/s3/object.created + malwareScan.completed/failed/skipped | concurrency 20 retries 1 | The file state machine: set size, transition status, trigger GuardDuty for EXTERNAL, poll the clean tag, emit file.* |
| content.createVersionFromFile | content-service/file.ready .failed / .quarantined | concurrency 10 retries 2 | Only for purpose=content-service/content-version. On ready: parse DOCX → ContentShell → new ContentVersion; emit versionFromFile.ready. Idempotent (skips if a shell exists) |
| template.updateStatusForTemplateWithVersion | content.versionFromFile.ready / .failed | concurrency 10 retries 1 | Find the PROCESSING template for that content; set ACTIVE + currentVersionId, or FAILED |
| content.createAutoMilestoneVersion | content-service/content.updatePersisted | debounce 120s (cap 3600s) per-content 1 | Auto-checkpoint a "milestone" version once edits exist and the latest version is ≥15 min old |
| template.reconcileStuckProcessing | cron */5 * * * * | concurrency 1 | Watchdog: mark templates stuck in PROCESSING >15 min as FAILED |
Read the catalog top-to-bottom and one worked flow appears, the template DOCX
import. createTemplate makes a backing Content, an uploadable
File aimed at it (targetContentId), and a Template in
PROCESSING. The lawyer uploads a DOCX. The file becomes READY, which
fires file.ready, which runs createVersionFromFile (parse, build the
ContentShell, create a version), which fires versionFromFile.ready,
which flips the template to ACTIVE. If anything stalls, the 5-minute cron sweeps it
to FAILED. Four functions, one event chain, no orchestrator.
| Event | Payload | Producer → consumer |
|---|---|---|
| webhook/s3/object.created | { s3Bucket, objectKey, objectSize } | webhook-api → file.updateStatus |
| webhook/s3/malwareScan.completed | { s3Bucket, objectKey, scanResult } | webhook-api → file.updateStatus |
| webhook/s3/malwareScan.failed / .skipped | { s3Bucket, objectKey } | webhook-api → file.updateStatus |
| content-service/file.ready / .failed / .quarantined | { fileId, source, scope, purpose } | file.updateStatus → createVersionFromFile |
| content-service/content.versionFromFile.ready | { fileId, contentId, versionId } | createVersionFromFile → updateStatusForTemplate… |
| content-service/content.versionFromFile.failed | { fileId, contentId } | createVersionFromFile → updateStatusForTemplate… |
| content-service/content.updatePersisted | { contentId, contentUpdateId, createdByIdentityId, createdAt } | applyContentYjsUpdate → createAutoMilestoneVersion |
Access
Scope and authorization
Because content-service holds no domain meaning, it cannot authorize on domain rules. Instead
every File, Content and Template carries a
scope: a typed, ordered path serialised to a string, such as
matter/mat_123, firm/fir_1/matter/mat_2, or
identity/pers_9. Scope does three jobs at once: it is the authorization key, the
prefix you list by, and the S3 key layout. One concept, three uses.
Four middleware procedures enforce it. protectedScopeProcedure guards create and
list operations against the input scope. protectedContentProcedure,
protectedTemplateProcedure and protectedFileProcedure load the existing
entity, check scope access, and return a masked NOT_FOUND when the caller
is not allowed (so absence and forbidden look identical from outside). The service itself trusts
an internal JWT; identity propagation across service boundaries is what makes the matter-scope
checks meaningful.
And who actually calls it? content-service sits two hops below the UI. The primary consumer is matter-service, which owns what a "matter document" means and delegates the content mechanics down. The chain for an ordinary editor action:
Lawrence is a fifth participant that joins at matter-service from the side, through lawrence-api's VFS. That is the next section.
The agent
How Lawrence reads and edits it
Lawrence never imports content-service. It reaches documents through a Virtual File
System that lawrence-api exposes, addressing everything as paths like
documents://<matter>/<doc>. On the agent side, the Python
platform-vfs package turns the tools read, stat,
list, create and edit into KMS-signed HTTP calls. On the
platform side, an adapter registry maps each protocol to a backend. The path for
a document is: agent → platform-vfs → lawrence-api VFS → matter-service →
content-service.
documents:// resolves through MatterDocumentAdapter to matter-service, which is content-backed. artifacts:// is a different thing: it maps to matter-service matterArtifact (HTML letters), not content-service, and is read-only through the VFS.Read is Markdown, edit is Yjs
The two directions are deliberately asymmetric. A plain read on a document returns
Markdown by default (the agent can also ask for ProseMirror JSON, a single
section, or a block map). But an edit does not write Markdown back. It is
compiled into Yjs updates. The agent sends high-level commands ("in block
b_a2, replace 'Client' with 'Party'"); lawrence-api fetches the current ProseMirror
and Yjs state, checks the content_version_id the agent saw still matches, applies each
command as a diffSuggestion mark (a tracked change the lawyer
reviews, not a silent overwrite), encodes the mutated Y.Doc, and submits it through
matter-service to content.applyContentYjsUpdate. The same epoch CAS from the storage
section guards it: a stale result triggers one re-read and retry.
The trace below is that round-trip, with the real request and response shapes. The
write verb, by contrast, is disabled for documents: editing always goes through
edit so it lands as reviewable suggestions.
Field names are the real ones (content_version_id, diff_suggestion_id, the applied/stale/exists statuses). The edit's internal hop to applyContentYjsUpdate is shown so the Yjs path is visible.
Auth and mode, in one breath
Write-side VFS routes (/edit, /create, /write) are
KMS-signed (RSASSA_PSS_SHA_512) and verified by lawrence-api; the data call runs
as a per-user JWT for the acting lawyer (so matter access and audit columns are
real), and the firm travels in x-lawhive-firm-id. Mode is resolved
surface > request.mode > the vfs-agent flag > chat; when
surface="sidebar", the agent is forced read-only (a read-only prompt, write tools
unwired). One caveat worth knowing: that read-only enforcement is agent-side, there is no separate
server block that rejects an edit purely because the surface is the sidebar.
→ Companion deep-dive
The edit DSL itself, the command grammar Lawrence emits and the planner that compiles it into a
single ProseMirror transaction of diffSuggestion marks, has its own field guide:
Turning intent into transactions: the edit & create DSL.
It covers all eight commands interactively, targeting, tracked-vs-direct edits, the full pipeline
to a Yjs update, and the result-status vocabulary.
Retrieval
The retrieval side: a tale of two Files
Here is the most common misconception about this corner of the platform, worth stating plainly
because the schema invites it. content-service's File has an
ingestionStatus column (NOT_REQUESTED / PENDING / SUCCESS / FAILED /
UNSUPPORTED). It is tempting to assume that is how an uploaded document gets chunked,
embedded and indexed for Lawrence's search tools. It is not. That field is a
dormant scaffold: nothing writes to it (the updater has zero non-test callers), and it never
leaves NOT_REQUESTED.
There are two different File models on two parallel tracks. content-service's
File is the editable track (the content bucket, Yjs documents). The
ingestion-and-retrieval track runs entirely through matter-service's own
File (the matter-documents bucket), and its status lives in matter-service columns
named aiIngestionV2Status / aiIngestionV3Status. They do not meet
today.
content-service File
the editable track
- Lives in the content bucket; backs DOCX import/export and the Yjs document body.
ingestionStatusexists but is scaffold: no producer, no consumer.- Its job ends at "the document is editable", not "the document is searchable".
matter-service File
the ingestion + retrieval track
- Lives in the matter-documents bucket; this is what actually gets ingested.
- On upload, emits
matter-service/file.uploaded, which lawrence-api turns into an/ingestcall. - Status returns as
aiIngestionV2Status / V3Statusvia an SNS webhook.
The real loop, end to end, is a different set of services entirely. It is worth tracing once so you never confuse it with content-service again:
matter-service/file.uploaded.safeIngestAsset / POST /ingest.search.matter-file-index-1.vector_search / ask_question_about_documents.Ingestion never calls platform-v3 directly: it publishes status to SNS, which hits lawrence-api's /file-ingestion/webhook, which updates matter-service. content-service is not on this path at all.
Verdict
content-service stores and serves the editable document. The searchable
document is a separate pipeline keyed on matter-service files. content-service's
ingestionStatus (and previewStatus, and thumbnailStatus)
are forward-looking columns for a convergence that has not happened. The README's "broader
derived-asset pipelines are still evolving" is pointing exactly here.
Reality check
What's live, scaffold, and stale
A field guide is only useful if it tells you which parts of the map are real. content-service has a handful of columns and helpers that look load-bearing but are not yet wired, plus a couple of docs that have drifted from the code. Here is the honest state, so you do not waste an afternoon chasing a pipeline that does not exist.
Fully wired via the DOCX export + PDF Lambda. The README still calls it NOT_IMPLEMENTED: that note is stale.
parseDocx + generateDocx with the ContentShell round-trip. This is the on-platform editor's fidelity path.
Debounced Inngest checkpointing, 120s / 15-min-age. History exists without anyone clicking save.
documents:// edits land as Yjs diffSuggestion marks through applyContentYjsUpdate.
Column + updater exist, zero callers. Real ingestion runs on matter-service files, not here.
Enums and updaters exist but nothing transitions them. No persisted preview/thumbnail on File yet.
The Event table is wired but the events router is empty. Domain events go via Inngest instead.
Two helpers throw NOT_IMPLEMENTED (deferred to a "previews-for-display" ticket). The on-demand PDF path is separate and works.
parseDocx runs with skipMedia:false and embeds media; a comment flags splitting it out to keep content small as future work.
Only DOCX is parsed on the file-to-version path; another format reaching file.ready trips versionFromFile.failed.
Deleted in #11249. On-disk files are untracked build leftovers.
Still lists document-service:5560 for Prisma Studio: a dangling reference to a deleted dir.
Enforced agent-side (prompt + tool wiring). No separate server block rejects an edit when surface=sidebar.
The agents repo edits filled forms at artifacts://…/fields; platform-v3 moved them to filledforms://…/fields. Reconcile when wiring end-to-end.
Reference
Field guide & cheat-sheet
The bits you will want again: id prefixes (every id is self-describing), the procedure surface by domain, and the runtime facts. At the end there is a button that copies a distilled summary of this whole page as Markdown, so you can paste the mental model straight into Claude Code or a ticket.
ID prefixes
| Domain | The procedures you'll reach for | Auth |
|---|---|---|
| content | createContentEmpty, createContentFrom{PlainText,Markdown,Html,ProsemirrorJson,Template,ExistingContent,YjsSnapshot}, applyContentYjsUpdate, getContent{Yjs,ProsemirrorJson,ProsemirrorJsonSection,Map,PlainText,Markdown,Docx,Pdf}, createContentVersionFrom*, listContentVersions, updateContentVersionInfo, deleteContent | scope / content procedures |
| template | createTemplate, createTemplateVersionFromCurrentState, publishTemplate, restoreTemplateVersion, setTemplateActiveVersion, setTemplateEditors, list/getTemplate*, updateTemplate{Info,Status}, category procedures | scope / template procedures |
| file | createUploadableFile, getFile, deleteFile (the rest of the lifecycle is Inngest, not tRPC) | scope / file procedures |