Series: Building Quorentra CRM from Zero — A Modular, ChatGPT-Native AI CRM
Building secure document uploads, storage abstraction, metadata, CRM entity relationships, permissions, authorized retrieval, deletion, auditing, and ChatGPT document workflows.

1. Introduction
By Part 25, Quorentra has become a surprisingly capable CRM platform.
Our core structured domains now include:
CompanyContactOpportunityActivityTaskMeeting
We also introduced a CRM Context Layer capable of assembling these domains into bounded, permission-aware evidence packages for ChatGPT.
The user can ask:
What’s happening with Adventure Works?
Quorentra can retrieve:
Adventure Works│├── Contacts├── Open Opportunities├── Recent Activities├── Open Tasks└── Upcoming Meetings
ChatGPT can reason over that evidence.
But an important part of customer knowledge is still missing.
Real customer relationships produce files.
For example:
ProposalsContractsStatements of WorkRequirements DocumentsPricing SheetsArchitecture DocumentsPresentationsMeeting NotesProject PlansSecurity QuestionnairesRFPsPurchase OrdersTechnical Specifications
A CRM that cannot associate these documents with customer records is incomplete.
Therefore Part 26 introduces a dedicated:
Document and Attachment domain
But we are deliberately not building document AI yet.
We first need to answer a more fundamental question:
How does Quorentra securely own, store, relate, retrieve, and govern customer documents?
Only after that foundation exists should we introduce text extraction, chunking, embeddings, semantic retrieval, and RAG.
2. Our Target Interaction
Imagine the user has uploaded:
AdventureWorks-Azure-Migration-Proposal-v3.pdf
The user tells ChatGPT:
Attach this revised proposal to the Adventure Works Azure Migration opportunity.
ChatGPT identifies:
Intent:Attach DocumentDocument:Uploaded FileCompany Reference:Adventure WorksOpportunity Reference:Azure Migration
Quorentra resolves:
Adventure Works ↓CompanyAzure Migration ↓Opportunity
The user sees:
┌──────────────────────────────────────────────┐│ QUORENTRA ││ Attach Document ││ ││ AdventureWorks-Azure-Migration-Proposal-v3 ││ PDF · 2.4 MB ││ ││ Company ││ Adventure Works ││ ││ Opportunity ││ Azure Migration ││ ││ Attach this document? ││ ││ [Cancel] [Attach Document] │└──────────────────────────────────────────────┘
After confirmation, Quorentra stores the file and its authoritative metadata.
The Opportunity can then show:
Azure Migration│├── Activities├── Tasks├── Meetings└── Documents └── AdventureWorks-Azure-Migration-Proposal-v3.pdf
That is our first goal.
3. Documents Are CRM Entities
A common mistake is to treat uploaded files as little more than filesystem objects.
For example:
/uploads/customer/proposal.pdf
That is not sufficient.
A CRM needs to know:
Who owns the document?Which tenant does it belong to?Who uploaded it?What CRM entity is it associated with?What type of file is it?How large is it?Where is it stored?Has it been deleted?Can this user access it?Has it been processed?
Therefore:
A Document is a first-class CRM domain entity.
The binary file is only one component of that entity.
4. Separate Metadata from Binary Storage
Our architecture should distinguish:
Document Metadata
from:
Document Binary
Conceptually:
PostgreSQL│└── Document ├── id ├── organization_id ├── filename ├── MIME type ├── size ├── checksum ├── storage key ├── relationships └── audit metadataObject/File Storage│└── Binary File
Do not store large uploaded files directly inside ordinary relational rows unless there is a compelling reason.
5. Why This Separation Matters
PostgreSQL is excellent for:
MetadataRelationshipsTransactionsPermissionsSearchAuditability
Object storage is better suited to:
PDFDOCXXLSXPPTXImagesLarge binaries
Separating them also lets us change storage technology later without changing the Document domain.
6. Storage Abstraction
For local development, Quorentra may initially store files on disk.
For production, we may later use:
Amazon S3Azure Blob StorageGoogle Cloud StorageS3-compatible storage
The application should not care which one is underneath.
Introduce:
DocumentStorage
as an abstraction.
7. Storage Interface
Conceptually:
class DocumentStorage(Protocol): async def save( self, *, storage_key: str, content: BinaryIO, ) -> None: ... async def open( self, *, storage_key: str, ) -> BinaryIO: ... async def delete( self, *, storage_key: str, ) -> None: ... async def exists( self, *, storage_key: str, ) -> bool: ...
Domain services depend on this abstraction.
8. Local Development Storage
For the MVP:
LocalDocumentStorage
might store files under:
backend/storage/documents/
But do not use the original filename as the storage path.
Bad:
storage/documents/proposal.pdf
Better:
storage/documents/└── org_x/ └── 2026/ └── 08/ └── <generated-storage-key>
The exact physical layout is an implementation detail.
9. Never Trust Uploaded Filenames
A filename such as:
proposal.pdf
is user-controlled input.
So is:
../../important-file
The original filename should be stored as metadata, not used directly to determine filesystem location.
Generate an opaque storage key.
10. Storage Key
For example:
organizations/<organization_uuid>/documents/<document_uuid>/original
or an equivalent opaque structure.
The important properties are:
Tenant-awareCollision-resistantNot user-controlledStorage-provider independent
11. Starting Checkpoint
Before implementing Part 26, verify Part 25.
Run:
cd backendpython -m pytest
Then:
cd ..\chatgpt-uinpm run build
Test:
What’s happening with Adventure Works?
Verify:
Search CRM ↓Resolve Company ↓CRM Context Builder ↓Permission Filtering ↓Bounded Context ↓Grounded ChatGPT Answer
Part 26 adds Documents without breaking that existing retrieval architecture.
12. Document Permissions
Introduce:
documents.readdocuments.createdocuments.delete
Later we may add:
documents.updatedocuments.downloaddocuments.processdocuments.manage
For the MVP, keep the permission model small.
13. Read Versus Download Permission
Initially:
documents.read
can authorize both:
metadata retrievalauthorized binary retrieval
Later, if necessary, we can separate:
documents.readdocuments.download
This is a policy decision rather than a structural limitation.
14. Example Role Matrix
| Role | Read | Create | Delete |
|---|---|---|---|
| Viewer | ✓ | — | — |
| Member | ✓ | ✓ | — |
| Manager | ✓ | ✓ | ✓ |
| Admin | ✓ | ✓ | ✓ |
| Owner | ✓ | ✓ | ✓ |
The exact matrix can evolve.
The important requirement is that permissions are checked server-side.
15. Define the Document Model
Conceptually:
Document├── id├── organization_id│├── original_filename├── display_name├── mime_type├── file_extension├── file_size_bytes├── checksum_sha256│├── storage_provider├── storage_key│├── status│├── uploaded_by_user_id│├── created_at├── updated_at├── deleted_at├── deleted_by_user_id│└── version
Relationships will be handled separately.
16. Document Status
Start with:
class DocumentStatus(str, Enum): AVAILABLE = "available" DELETED = "deleted"
Later we can introduce processing states such as:
UPLOADINGPROCESSINGREADYFAILEDQUARANTINED
when document processing is added.
For Part 26, keep the lifecycle simple.
17. Why Not Add AI Processing States Yet?
Because Part 26 does not yet perform:
Text ExtractionOCRChunkingEmbeddingSemantic Indexing
Adding states for nonexistent workflows would create unnecessary complexity.
Build them when the processing pipeline exists.
18. Original Filename
Store:
original_filename
exactly enough to identify the uploaded file to the user.
Example:
AdventureWorks-Azure-Migration-Proposal-v3.pdf
But sanitize it for display and never use it directly as a storage path.
19. Display Name
Optionally separate:
original_filename
from:
display_name
For example:
original_filename:AdventureWorks-Azure-Migration-Proposal-v3.pdfdisplay_name:Azure Migration Proposal v3
For the MVP, display name can default to the filename without its extension.
20. MIME Type
Store:
mime_type
Examples:
application/pdfapplication/vnd.openxmlformats-officedocument.wordprocessingml.documentapplication/vnd.openxmlformats-officedocument.spreadsheetml.sheetapplication/vnd.openxmlformats-officedocument.presentationml.presentationtext/plainimage/pngimage/jpeg
But do not trust the browser-supplied MIME type blindly.
21. File Extension
Also record:
file_extension
such as:
pdfdocxxlsxpptxtxtpngjpg
This helps validation and user display.
22. MIME and Extension Must Agree
A file named:
proposal.pdf
should not automatically be accepted as a PDF.
The upload pipeline should compare:
Declared MIME TypeFile ExtensionDetected File Type
where practical.
If they conflict, reject or quarantine the upload.
23. File Size
Store:
file_size_bytes
Do not rely on the client-reported value.
Calculate or verify it server-side.
24. Upload Size Limit
For the MVP, define a configurable limit.
For example:
MAX_DOCUMENT_SIZE_MB = 25
The exact number is less important than having an explicit limit.
Do not accept unlimited uploads.
25. Why File Limits Matter
Without limits:
Huge Upload ↓Memory Pressure ↓Storage Growth ↓Request Timeouts ↓Denial-of-Service Risk
The server must control resource consumption.
26. Stream Uploads
Avoid loading large files entirely into memory.
Prefer:
Incoming File ↓Stream ↓Validation ↓Checksum ↓Storage
rather than:
Incoming File ↓Read Entire File into RAM ↓Process
This becomes increasingly important in production.
27. SHA-256 Checksum
Calculate:
checksum_sha256
for every uploaded file.
Example:
7f83b1657ff1fc53...
This gives us:
Integrity checkingDuplicate detection foundationStorage verificationAudit support
28. Do Not Deduplicate Automatically Yet
Two users may intentionally upload identical files to different CRM records.
Therefore Part 26 should calculate checksums but not necessarily collapse duplicate Documents.
Later we can implement intelligent duplicate handling.
29. Storage Provider
Store a logical value such as:
local
during development.
Later:
s3azure_blobgcs
This allows migration and multiple storage backends.
30. Document Relationships
A Document becomes useful when connected to CRM context.
It may relate to:
CompanyContactOpportunityActivityTaskMeeting
We need a flexible relationship model.
31. Avoid Six Nullable Foreign Keys
One possible design is:
document.company_iddocument.contact_iddocument.opportunity_iddocument.activity_iddocument.task_iddocument.meeting_id
This works initially, but becomes awkward.
A Document may also need multiple relationships.
For example:
Proposal→ Adventure Works→ Azure Migration→ Proposal Review Meeting
Therefore introduce a dedicated relationship table.
32. DocumentLink
Conceptually:
DocumentLink├── id├── organization_id├── document_id├── entity_type├── entity_id├── created_by_user_id└── created_at
This creates a flexible attachment model.
33. Supported Link Types
Define:
class DocumentLinkEntityType(str, Enum): COMPANY = "company" CONTACT = "contact" OPPORTUNITY = "opportunity" ACTIVITY = "activity" TASK = "task" MEETING = "meeting"
This aligns with our existing CRM entity types.
34. Why a Generic Link Table?
Because:
Document │ ├── Company ├── Opportunity └── Meeting
can now be represented naturally.
We also avoid modifying the Document table every time a new attachable CRM domain is added.
35. Generic Relationships Require Validation
The flexibility of:
entity_typeentity_id
comes with responsibility.
The database cannot always enforce all cross-table foreign keys directly.
Therefore the application must strictly validate:
Entity existsEntity belongs to active tenantUser can access entityEntity type is supported
before creating a DocumentLink.
36. Tenant Isolation
Both:
Document
and:
DocumentLink
contain:
organization_id
This allows explicit tenant filtering.
Every document query must include the active tenant.
37. Cross-Tenant Linking Must Be Impossible
Suppose:
Document→ Tenant AOpportunity→ Tenant B
Creating:
DocumentLink
between them must fail.
This is a hard security invariant.
38. Upload Workflow
The basic upload flow becomes:
Receive File ↓Authenticate User ↓Resolve Tenant ↓Check documents.create ↓Validate Filename ↓Validate Size ↓Validate Type ↓Generate Document ID ↓Generate Storage Key ↓Stream File ↓Calculate Checksum ↓Store Binary ↓Create Document Metadata ↓Create AuditEvent ↓Commit
But storage and database transactions require careful coordination.
39. The Storage Transaction Problem
PostgreSQL can roll back database changes.
Object storage generally cannot participate in the same ACID transaction.
Suppose:
Store File✓Insert Document✗
Now we have an orphaned binary.
Or:
Insert Document✓Store File✗
Now the database points to a missing file.
We need a deliberate strategy.
40. MVP Upload Strategy
For the first implementation:
Validate ↓Store Binary ↓Create Document Row ↓Create AuditEvent ↓Commit
If database creation fails after storage succeeds:
Best-effort delete stored binary
If cleanup fails:
Log orphan storage object
Later we can introduce a more sophisticated staging workflow.
41. Future Production Strategy
A more advanced design might use:
Upload Session ↓Temporary Storage ↓Database Metadata ↓Finalize Storage ↓AVAILABLE
or asynchronous reconciliation.
But that is beyond the Part 26 MVP.
42. Direct Upload Endpoint
Introduce:
POST /api/v1/documents
using:
multipart/form-data
This is different from our normal JSON APIs because the request contains binary content.
43. Example Upload Request
Conceptually:
file:AdventureWorks-Azure-Migration-Proposal-v3.pdfdisplay_name:Azure Migration Proposal v3
The server derives:
organization_iduploaded_by_user_idstatusstorage_keychecksumfile_size
44. Do Not Accept Storage Keys from Clients
Never allow:
storage_key ="user-provided/path"
The server generates it.
Likewise, clients cannot provide:
organization_iduploaded_by_user_idstatuschecksum
as authoritative values.
45. Upload and Link as Separate Operations
A useful modular design is:
Upload Document
followed by:
Link Document
This separates:
File ownership
from:
CRM relationship
That makes the Document module reusable.
46. Why Separate Them?
Suppose the user uploads a proposal and later wants to attach it to:
CompanyOpportunityMeeting
The file should not need to be uploaded three times.
Instead:
Document ↓DocumentLink ├── Company ├── Opportunity └── Meeting
47. Direct REST Linking
Introduce:
POST /api/v1/documents/{document_id}/links
Conceptual body:
{ "entity_type": "opportunity", "entity_id": "..."}
The backend validates the target.
48. ChatGPT Should Not Supply Arbitrary IDs
Conversationally, the user says:
Attach this proposal to the Adventure Works Azure Migration opportunity.
ChatGPT should provide:
Document referenceCompany referenceOpportunity reference
Quorentra resolves authoritative IDs.
This follows the same architecture used throughout the CRM.
49. Document Draft
For ChatGPT-native attachment workflows, introduce:
DocumentLinkDraft
rather than allowing immediate arbitrary attachment.
Conceptually:
DocumentLinkDraft├── id├── organization_id├── user_id├── document_id├── entity_reference├── entity_type├── resolved_entity_id├── status├── workflow_id├── expires_at├── created_at└── updated_at
50. Why Use a Link Draft?
User:
Attach this to the Azure project.
There may be multiple matches:
Azure MigrationAzure Security ReviewAzure Landing Zone
The draft can preserve the intent while Quorentra resolves ambiguity.
51. Create Document Link Draft Tool
Introduce:
create_document_link_draft
Conceptually:
class CreateDocumentLinkDraftInput(BaseModel): document_id: UUID entity_type: DocumentLinkEntityType | None = None entity_reference: str
The document ID is acceptable here only when it originates from an authoritative upload result already available to the application workflow.
52. Resolution Flow
Document ↓Entity Reference ↓Unified CRM Search ↓Entity Resolution ↓Tenant Validation ↓Permission Validation ↓Relationship Validation ↓DocumentLinkDraft
Part 25’s unified search infrastructure is reused.
53. Example
User:
Attach this proposal to the Adventure Works Azure Migration opportunity.
Quorentra resolves:
Adventure Works ↓CompanyAzure Migration ↓Opportunity
The Link Draft becomes:
document_id:<uploaded document>entity_type:opportunityresolved_entity_id:Azure Migration UUID
54. Prepare Document Link
Introduce:
prepare_document_link
Input:
{ "draft_id": "..."}
Flow:
Load Draft ↓Verify Tenant ↓Verify User ↓Verify documents.create ↓Verify Document AVAILABLE ↓Verify Target Entity ↓Verify Entity Permission ↓Verify Relationship ↓Check Link Does Not Already Exist ↓Create MutationRequest ↓Return Confirmation
No DocumentLink exists yet.
55. Mutation Type
Use:
document.link.create
The MutationRequest contains authoritative proposed values.
56. Confirmation Widget
Create:
chatgpt-ui/src/documents/└── DocumentLinkConfirmation.tsx
Render:
┌──────────────────────────────────────────────┐│ QUORENTRA ││ Attach Document ││ ││ Azure Migration Proposal v3 ││ PDF · 2.4 MB ││ ││ Company ││ Adventure Works ││ ││ Opportunity ││ Azure Migration ││ ││ [Cancel] [Attach Document] │└──────────────────────────────────────────────┘
57. Execute Document Link
Introduce:
create_document_link
Input:
{ "mutation_request_id": "..."}
Execution:
Load MutationRequest ↓Verify Tenant ↓Verify User ↓Verify Permission ↓Verify Pending ↓Verify Not Expired ↓Reload Document ↓Reload Target Entity ↓Verify Both Same Tenant ↓Verify Target Permission ↓Check Duplicate ↓Create DocumentLink ↓Create AuditEvent ↓Execute MutationRequest ↓Convert Draft ↓Commit
58. Link Audit Event
Use:
action =document.link.created
Useful metadata:
document_identity_typeentity_idactor_user_idmutation_request_idworkflow_id
59. Document Upload Audit Event
When the file itself is uploaded:
action =document.created
Record metadata such as:
document_idmime_typefile_size_byteschecksumuploaded_by_user_id
Avoid logging document contents.
60. Reading Documents
Introduce:
GET /api/v1/documents/{document_id}
and ChatGPT tool:
get_document
This returns metadata, not necessarily the binary file itself.
61. Document Metadata Response
Conceptually:
{ "id": "...", "display_name": "Azure Migration Proposal v3", "original_filename": "AdventureWorks-Azure-Migration-Proposal-v3.pdf", "mime_type": "application/pdf", "file_size_bytes": 2516582, "status": "available", "uploaded_at": "...", "uploaded_by": { "id": "...", "name": "..." }, "links": [ { "entity_type": "opportunity", "entity_id": "...", "display_name": "Azure Migration" } ]}
Do not expose:
raw filesystem pathcloud storage secretbucket credentialsinternal signed-storage credentials
62. Document Listing
Introduce:
search_documents
Useful filters:
queryentity_typeentity_idmime_typeuploaded_fromuploaded_tolimit
63. Opportunity Documents
User:
Show me the documents for the Azure Migration opportunity.
Flow:
Resolve Opportunity ↓Find DocumentLinks ↓Load AVAILABLE Documents ↓Permission Filter ↓Return Document List
64. Company Documents
User:
What documents do we have for Adventure Works?
Return Documents linked directly to the Company.
Later we may optionally include Documents linked to child Opportunities.
But do not silently change relationship semantics.
65. Direct Versus Related Documents
We should distinguish:
Direct Documents
from:
Related Documents
Example:
Adventure Works│├── Direct Document│ └── Master Services Agreement│└── Opportunity └── Azure Migration └── Proposal.pdf
A Company-level search may eventually support both.
For the MVP, make the behavior explicit.
66. Document List Widget
Create:
chatgpt-ui/src/documents/├── DocumentList.tsx├── DocumentCard.tsx├── DocumentDetail.tsx└── DocumentLinkConfirmation.tsx
Example card:
┌────────────────────────────────────────────┐│ Azure Migration Proposal v3 ││ ││ PDF · 2.4 MB ││ ││ Azure Migration ││ Adventure Works ││ ││ Uploaded 2 Aug 2026 ││ ││ [View] │└────────────────────────────────────────────┘
67. Authorized Download
The binary file must never be exposed through a predictable public URL.
Bad:
/public/uploads/proposal.pdf
Anyone who guesses the URL might retrieve it.
Instead:
User ↓Quorentra ↓Authentication ↓Tenant Check ↓documents.read ↓Document Authorization ↓Binary Retrieval
68. Download Endpoint
Introduce something like:
GET /api/v1/documents/{document_id}/content
The backend authorizes the request before returning or redirecting to the content.
69. Local Storage Retrieval
For local development:
Authorized Request ↓Document Metadata ↓storage_key ↓LocalDocumentStorage.open() ↓Stream Response
Never accept a filesystem path from the client.
70. Future Object Storage Retrieval
With cloud storage:
Authorized Request ↓Quorentra ↓Generate Short-Lived Signed URL ↓Return / Redirect
The signed URL should be:
Short-livedDocument-specificGenerated only after authorization
71. Do Not Store Signed URLs
Signed URLs are temporary access mechanisms.
They should not become authoritative Document fields.
Store:
storage_key
not:
signed_download_url
72. Content-Disposition
When serving a file, return an appropriate:
Content-Disposition
using a sanitized filename.
This allows the browser to present a meaningful filename without exposing the storage key.
73. File Type Allowlist
For the first CRM MVP, support a conservative set.
For example:
PDFDOCXXLSXPPTXTXTCSVPNGJPEG
Reject unsupported types.
74. Why Use an Allowlist?
An allowlist is safer than:
accept everything except known bad files
The latter is difficult to maintain.
Instead:
Known supported type→ acceptUnknown type→ reject
75. Executable Files
Files such as:
.exe.bat.cmd.ps1.js.vbs.scr
should not be accepted as normal CRM Documents in the MVP.
Quorentra is not a general-purpose file-sharing system.
76. Archive Files
Be cautious with:
ZIPRAR7z
They introduce:
Nested contentCompression bombsHidden executablesMalware scanning complexity
Exclude them from the first implementation unless required.
77. Malware Scanning
A production document platform should eventually scan uploaded files.
Possible future architecture:
Upload ↓Temporary Storage ↓Malware Scan ↓Clean? ┌────┴────┐ │ │Yes No │ │ ▼ ▼Available Quarantined
But Part 26 does not need to implement a full malware scanning service.
78. Prepare for Future Scanning
Even if scanning is not implemented yet, keep storage architecture compatible with:
TemporaryQuarantineAvailable
states later.
Do not tightly couple Document availability to one physical folder.
79. Document Deletion
Users eventually need:
Delete the old proposal.
Deletion changes authoritative state.
Therefore it should use our governed mutation path.
80. Do Not Immediately Hard Delete
For the MVP, use logical deletion.
Set:
status =deleteddeleted_at =server timestampdeleted_by_user_id =authenticated user
Do not immediately erase the database row.
81. Why Soft Delete?
It preserves:
Audit historyDocument identityRelationship historyWho deleted itWhen it was deleted
It also gives us room for future retention policies.
82. What About the Binary?
For the first implementation, there are two options:
A. Keep binary during soft-delete retentionB. Delete binary immediately but preserve metadata
For a CRM MVP, option A is usually operationally simpler.
Then a later retention process can permanently purge it.
83. Prepare Document Deletion
Introduce:
prepare_document_deletion
Input:
{ "document_id": "...", "expected_version": 1}
The server verifies:
TenantPermissionDocument statusVersion
84. Deletion Confirmation
┌──────────────────────────────────────────────┐│ QUORENTRA ││ Delete Document ││ ││ Azure Migration Proposal v2 ││ PDF · 2.2 MB ││ ││ This document will no longer be available ││ in normal CRM views. ││ ││ [Keep Document] [Delete Document] │└──────────────────────────────────────────────┘
85. Execute Deletion
Introduce:
delete_document
Input:
{ "mutation_request_id": "..."}
Execution:
Load MutationRequest ↓Verify Tenant ↓Verify User ↓Verify documents.delete ↓Verify Version ↓Verify AVAILABLE ↓Set DELETED ↓Set deleted_at ↓Set deleted_by_user_id ↓Increment Version ↓Create AuditEvent ↓Execute MutationRequest ↓Commit
86. Document Deletion Audit Event
Use:
action =document.deleted
The document metadata remains available for audit purposes.
87. Deleted Documents in Search
Normal:
search_documents
must exclude:
status = deleted
unless the caller has an explicit administrative reason to include them.
88. Deleted Document Download
A deleted Document should not be downloadable through the normal content endpoint.
Return an appropriate error.
89. Document Links After Deletion
Do not necessarily delete DocumentLink rows.
They remain useful historical evidence:
This deleted document used to be attached to:Azure Migration
Normal CRM views simply filter out deleted Documents.
90. Unlinking Documents
Eventually the user may say:
Remove this proposal from the meeting but keep it on the opportunity.
That requires:
document.link.delete
This is distinct from deleting the Document itself.
91. Link Removal
We can include basic unlinking in Part 26 or defer it slightly.
Conceptually:
prepare_document_unlinkdelete_document_link
The important semantic distinction is:
Unlink→ remove CRM relationshipDelete Document→ remove Document from active use
Never confuse the two.
92. Document Versioning
Suppose we have:
Proposal v1Proposal v2Proposal v3
Should these be versions of one Document?
Eventually, perhaps.
But for Part 26:
Each uploaded file =independent Document
This keeps the domain simple.
93. Why Defer Versioning?
Real document versioning requires decisions about:
Version chainsCurrent versionReplacementRetentionDiffsRollbackPermissionsAI indexing
Do not build that complexity until the need is real.
The filename can still contain:
v1v2v3
without formal version semantics.
94. Document Type
We may optionally introduce a simple classification:
class DocumentType(str, Enum): GENERAL = "general" PROPOSAL = "proposal" CONTRACT = "contract" REQUIREMENTS = "requirements" PRESENTATION = "presentation" OTHER = "other"
But this should not be required for upload.
95. Avoid AI Classification Yet
Do not automatically infer:
This is definitely a contract.
based solely on filename or model interpretation.
For the MVP:
GENERAL
can be the default.
Later document processing can classify content with explicit provenance.
96. Document Metadata Editing
Users may eventually want to change:
display_namedocument_typedescription
But the core Part 26 requirement is upload, link, retrieve, and delete.
Metadata editing can be added later without redesigning storage.
97. Add Documents to CRM Context
Part 25 created:
CRM Context├── Contacts├── Opportunities├── Activities├── Tasks└── Meetings
Part 26 allows us to extend it carefully:
CRM Context├── Contacts├── Opportunities├── Activities├── Tasks├── Meetings└── Documents
But only document metadata is included.
98. Do Not Put Binary Files in CRM Context
Context should contain:
Document IDDisplay NameTypeMIME TypeRelationshipUpload Date
not:
Binary contentBase64 fileEntire PDFEntire DOCX
That would be inefficient and unnecessary.
99. Company Context Documents
For Adventure Works:
Documents├── Master Services Agreement├── Security Questionnaire└── Account Plan
These may be directly linked to the Company.
100. Opportunity Context Documents
For Azure Migration:
Documents├── Requirements.pdf├── Architecture.pptx├── Proposal-v3.pdf└── Pricing.xlsx
This is extremely useful contextual metadata even before document AI exists.
101. Context Budget for Documents
Add a bounded default:
documents:10
or another conservative value.
Again:
Context must remain bounded.
102. Document Ordering
Default:
created_at DESC
or perhaps:
updated_at DESC
For the MVP, use deterministic upload recency.
103. Document Provenance
A Context item should preserve:
entity_type =documententity_id =Document UUIDsource =crm_document_metadata
Later extracted text can reference the same Document.
104. This Prepares Us for RAG
Future architecture:
Document ↓Text Extraction ↓Document Text ↓Chunks ↓Embeddings ↓Vector Index ↓Semantic Retrieval ↓ChatGPT
But every chunk will still trace back to:
Document ID
That is why building the Document domain correctly first matters.
105. Future Chunk Provenance
Later:
Chunk├── document_id├── page_number├── section├── text└── embedding
can provide evidence such as:
Proposal.pdfPage 14Pricing Section
This becomes far stronger than untraceable AI context.
106. ChatGPT Document Tools
Part 26 can expose:
get_documentsearch_documentscreate_document_link_draftprepare_document_linkcreate_document_linkprepare_document_deletiondelete_document
Binary upload itself may happen through the App UI rather than a model tool.
107. Why Upload May Be UI-Driven
ChatGPT cannot safely invent a local file.
The user must provide the actual file.
Therefore:
User ↓Upload UI ↓Quorentra Document API ↓Document
Then ChatGPT can work with the resulting Document reference.
108. ChatGPT-Native Does Not Mean Model-Only
This is an important principle.
A ChatGPT-native CRM can use:
ConversationWidgetsFile Upload ControlsConfirmation UIStructured ToolsBackend Services
The language model is the orchestration and reasoning interface.
It does not replace every application component.
109. Document Upload Widget
A Quorentra widget might show:
┌──────────────────────────────────────────────┐│ QUORENTRA ││ Upload Document ││ ││ Drop a file here ││ or ││ [Choose File] ││ ││ Supported: PDF, DOCX, XLSX, PPTX, TXT, CSV ││ Maximum size: 25 MB │└──────────────────────────────────────────────┘
After upload:
Upload completeAzure Migration Proposal v3PDF · 2.4 MB
110. Conversational Continuation
ChatGPT can then ask:
Where should I attach this document?
The user:
To the Azure Migration opportunity.
Quorentra resolves and prepares the link.
This is a natural combination of:
UI+Conversation+Structured CRM Tools
111. One-Shot Workflow
If the UI already knows the active Opportunity context:
Adventure Works→ Azure Migration
the user may upload a file directly there.
The application can prepopulate the relationship.
Still, the backend must validate it.
112. Active Context Is Not Authority
Suppose the widget says:
Current Opportunity:Azure Migration
The server must still verify:
Opportunity existsbelongs to active tenantuser can access it
Never trust UI state as security authority.
113. Document Search from ChatGPT
User:
Find the latest proposal for Adventure Works.
Possible flow:
Resolve Adventure Works ↓Search Documents ↓Filter linked Company/Opportunity Documents ↓Filter document type/name ↓Order by created_at DESC ↓Return candidates
No document content understanding is required yet.
114. Search by Filename
Part 26 search can support:
original_filenamedisplay_namedocument_type
This is enough for basic retrieval.
115. Do Not Pretend to Search Document Contents
Before text extraction exists, a query like:
Find the document that mentions a 15% discount.
cannot reliably be answered.
Quorentra should not imply that it searched inside files.
That capability belongs to later parts.
116. Grounding Boundary
At Part 26, ChatGPT knows:
A document exists.Its name.Its type.Its CRM relationships.Its upload metadata.
It does not yet know:
What the document says.
That boundary must remain explicit.
117. Example Correct Answer
User:
Do we have a proposal for Adventure Works?
Quorentra finds:
Azure Migration Proposal v3
Good:
Yes. Quorentra has a document named “Azure Migration Proposal v3” attached to the Azure Migration opportunity.
118. Example Incorrect Answer
Bad:
Yes. The proposal offers Azure migration for €90,000 and includes a six-month delivery schedule.
unless those facts come from structured CRM fields or previously extracted authoritative content.
The existence of a PDF does not mean ChatGPT knows its contents.
119. Upload Tests
Test:
valid PDFvalid DOCXvalid XLSXvalid PPTXvalid TXTvalid CSVvalid PNGvalid JPEGunsupported executableoversized fileempty fileinvalid extensionMIME mismatch
120. Filename Security Tests
Test filenames such as:
../../secret.pdf..\..\secret.pdfproposal<script>.pdfvery-long-filename...proposal?.pdf
Verify they cannot influence physical storage paths.
121. Storage Key Tests
Verify:
server-generatedtenant-awareuniquenot derived directly from filenamenot client-controlled
122. Checksum Test
Upload known content.
Verify:
SHA-256
matches the expected digest.
123. File Size Test
Verify server-calculated:
file_size_bytes
matches actual stored content.
124. Tenant Isolation Upload Test
User in Tenant A uploads a Document.
Expected:
organization_id =Tenant A
The client cannot override this to Tenant B.
125. Document Read Tenant Test
User in Tenant B requests Tenant A Document ID.
Expected:
not found / forbidden
according to security policy.
No metadata leakage.
126. Document Download Tenant Test
The same rule applies to binary retrieval.
Knowing a Document UUID must never be enough to download it.
127. Permission Tests
Viewer:
get_document→ allowedupload_document→ denieddelete_document→ denied
Member:
upload_document→ allowed
according to configured roles.
128. Document Link Tests
Test linking to:
CompanyContactOpportunityActivityTaskMeeting
Each target must:
existbelong to tenantbe accessible
129. Cross-Tenant Link Test
Document:
Tenant A
Opportunity:
Tenant B
Expected:
DOCUMENT_LINK_TENANT_CONFLICT
No link created.
130. Duplicate Link Test
Link:
Document X→ Opportunity Y
twice.
Expected:
1 logical DocumentLink
Use a uniqueness constraint where practical.
131. Link Draft Test
Verify:
create draftambiguous targetmissing targetresolved targetexpirycancellationconversion
132. Link Confirmation Test
Before confirmation:
0 DocumentLink rows created
After execution:
1 DocumentLink
133. Link Permission Change Test
Prepare link.
Remove relevant permission.
Execute.
Expected:
denied
because permissions are rechecked.
134. Link Target Change Test
Prepare link.
Delete or invalidate target entity before execution.
Expected:
execution fails safely
No dangling link.
135. Search Document Tests
Verify:
search by display namesearch by filenamefilter by entityfilter by MIME typelimit resultsexclude deletedtenant isolationpermission filtering
136. Company Document Query Test
Documents directly linked to Adventure Works should appear.
Documents from another Company must not.
137. Opportunity Document Query Test
Documents linked to Azure Migration should appear in the Opportunity document list.
138. Context Integration Test
Company context now includes bounded Document metadata when permitted.
Opportunity context includes linked Documents.
Verify no binary content is inserted into context.
139. Context Permission Test
User has:
crm.context.read
but lacks:
documents.read
Expected:
Documents omitted
The Context Layer cannot bypass Document permissions.
140. Deletion Tests
Verify:
AVAILABLE → DELETED✓DELETED → DELETEDhandled safely
Deleted Documents disappear from normal search and context.
141. Deletion Concurrency Test
Prepare deletion at:
version = 1
Document changes to:
version = 2
Execute old mutation.
Expected:
VERSION_CONFLICT
142. Download Deleted Document Test
Attempt normal content retrieval after deletion.
Expected:
DOCUMENT_NOT_AVAILABLE
143. Binary Cleanup Failure Test
Simulate:
binary stored successfullydatabase creation failsbinary cleanup fails
Expected:
upload failsorphan cleanup failure logged
Do not silently claim success.
144. Storage Failure Test
Simulate:
storage.save()→ failure
Expected:
no AVAILABLE Document rowno successful upload response
145. Audit Tests
Verify:
document.createddocument.link.createddocument.deleted
produce appropriate AuditEvents.
Document contents must not be copied into audit logs.
146. Logging
Useful structured fields:
document_idorganization_idactor_user_idmime_typefile_extensionfile_size_bytesstorage_provideroperationresultduration_msworkflow_idmutation_request_id
Avoid logging:
document binaryfull extracted contentsigned URLsstorage credentials
147. Metrics
Useful metrics include:
document_uploads_totaldocument_upload_failures_totaldocument_upload_bytes_totaldocument_downloads_totaldocument_download_failures_totaldocument_links_created_totaldocuments_deleted_totaldocument_storage_failures_total
Later:
document_processing_totaldocument_extraction_failures_total
will be added.
148. Storage Metrics
Monitor:
total stored bytesaverage document sizelargest document sizeuploads per tenantstorage failures
These become important for SaaS cost control.
149. Database Indexes
Useful indexes include:
organization_id, status, created_atorganization_id, display_namedocument_id, entity_type, entity_idorganization_id, entity_type, entity_id
Also consider uniqueness for:
document_identity_typeentity_id
within a tenant.
150. Database Migration
Part 26 requires migrations for:
documentsdocument_linksdocument_link_drafts
and any new permission seeds.
Verify:
cd backendalembic upgrade head
Then:
alembic current
The expected revision should be current.
151. Suggested Backend Structure
A modular layout might look like:
backend/app/├── documents/│ ├── models.py│ ├── schemas.py│ ├── repository.py│ ├── service.py│ ├── permissions.py│ ├── storage.py│ ├── validation.py│ └── routes.py│├── document_links/│ ├── models.py│ ├── schemas.py│ ├── repository.py│ └── service.py│└── context/ └── service.py
Exact organization can follow the conventions already established in Quorentra.
152. Storage Implementations
For example:
documents/storage/├── base.py├── local.py└── factory.py
Later:
s3.pyazure_blob.pygcs.py
can be added without rewriting the Document service.
153. Configuration
Example environment configuration:
DOCUMENT_STORAGE_PROVIDER=localDOCUMENT_LOCAL_STORAGE_PATH=./storage/documentsDOCUMENT_MAX_SIZE_MB=25
Later production:
DOCUMENT_STORAGE_PROVIDER=s3
The domain code should remain unchanged.
154. Do Not Put Secrets in Document Records
Cloud storage credentials belong in:
EnvironmentSecret ManagerManaged IdentityWorkload Identity
not:
documents
Document rows contain storage references, never storage credentials.
155. Version Update
Part 26 introduces:
Document DomainDocument StorageSecure UploadDocument MetadataDocument LinksAuthorized RetrievalDocument SearchDocument DeletionDocument AuditingCRM Context Document MetadataChatGPT Document Workflows
Update:
app/core/constants.py
from:
APP_VERSION = "0.12.0"
to:
APP_VERSION = "0.13.0"
156. Quorentra 0.13.0
Our modular MVP now looks like:
Platform├── FastAPI ✓├── PostgreSQL ✓├── SQLAlchemy ✓├── Alembic ✓└── Document Storage Abstraction ✓Identity├── Organizations ✓├── Users ✓├── Memberships ✓├── Authentication ✓└── JWT ✓Security├── TenantContext ✓├── Tenant Isolation ✓├── RBAC ✓├── Domain Permissions ✓├── Context-Aware Authorization ✓└── Document Authorization ✓CRM├── Companies ✓├── Contacts ✓├── Opportunities ✓├── Activities ✓├── Tasks ✓├── Meetings ✓└── Documents ✓Documents├── Metadata ✓├── Binary Storage ✓├── Storage Abstraction ✓├── Local Storage ✓├── File Validation ✓├── Size Limits ✓├── MIME Validation ✓├── Checksums ✓├── CRM Links ✓├── Authorized Retrieval ✓├── Search ✓├── Soft Deletion ✓└── Audit Events ✓Document Relationships├── Company ✓├── Contact ✓├── Opportunity ✓├── Activity ✓├── Task ✓└── Meeting ✓CRM Retrieval├── Unified CRM Search ✓├── Tenant-Scoped Search ✓├── Permission-Aware Search ✓├── Document Search ✓└── Bounded Results ✓CRM Context Layer├── Company Context ✓├── Contact Context ✓├── Opportunity Context ✓├── Activities ✓├── Tasks ✓├── Meetings ✓├── Document Metadata ✓├── Context Budgets ✓├── Context Provenance ✓└── Grounded CRM Evidence ✓Mutation Governance├── Drafts ✓├── Mutation Requests ✓├── Confirmation ✓├── Expiry ✓├── Idempotency ✓├── Transactions ✓├── Optimistic Concurrency ✓└── Audit Events ✓ChatGPT CRM Operations├── Search CRM ✓├── Retrieve CRM Context ✓├── Read CRM Records ✓├── Create CRM Records ✓├── Update CRM State ✓├── Schedule Meetings ✓├── Upload Document ✓├── Find Documents ✓├── Attach Documents ✓└── Delete Documents ✓ChatGPT UI├── CRM Context Widgets ✓├── Mutation Confirmation ✓├── Task Widgets ✓├── Meeting Widgets ✓├── Document Upload ✓├── Document List ✓├── Document Detail ✓└── Document Link Confirmation ✓Grounding├── Structured CRM Evidence ✓├── Document Metadata Evidence ✓├── Evidence Provenance ✓├── Context Freshness ✓└── Fact / Analysis Separation ✓Document Intelligence├── Text Extraction -├── OCR -├── Content Normalization -├── Chunking -├── Embeddings -├── Vector Search -└── RAG -AI Intelligence -
157. Acceptance Criteria
Part 26 is complete when:
✓ Part 25 regression suite remains green✓ documents.read exists✓ documents.create exists✓ documents.delete exists✓ document permissions are server-enforced✓ Document model exists✓ DocumentLink model exists✓ DocumentLinkDraft exists✓ migrations apply successfully✓ every Document is tenant-scoped✓ every DocumentLink is tenant-scoped✓ every DocumentLinkDraft is tenant-scoped✓ original_filename exists✓ display_name exists✓ MIME type exists✓ file extension exists✓ file size exists✓ SHA-256 checksum exists✓ storage provider exists✓ storage key exists✓ uploaded_by_user_id exists✓ version exists✓ AVAILABLE status exists✓ DELETED status exists✓ new Documents become AVAILABLE only after successful upload✓ DocumentStorage abstraction exists✓ LocalDocumentStorage exists✓ domain logic does not depend directly on local filesystem paths✓ storage keys are server-generated✓ storage keys are not user-controlled✓ original filenames cannot control storage paths✓ maximum upload size is configurable✓ oversized uploads are rejected✓ empty uploads are rejected✓ supported file types use an allowlist✓ unsupported executables are rejected✓ archive files are excluded unless explicitly supported✓ MIME validation exists✓ extension validation exists✓ server verifies file size✓ SHA-256 is calculated server-side✓ uploads are streamed where practical✓ large uploads are not unnecessarily loaded fully into memory✓ storage failure does not create a successful Document✓ database failure after storage triggers best-effort binary cleanup✓ orphan cleanup failures are logged✓ clients cannot provide organization_id✓ clients cannot provide uploaded_by_user_id✓ clients cannot provide authoritative status✓ clients cannot provide storage_key✓ clients cannot provide authoritative checksum✓ DocumentLink supports Company✓ DocumentLink supports Contact✓ DocumentLink supports Opportunity✓ DocumentLink supports Activity✓ DocumentLink supports Task✓ DocumentLink supports Meeting✓ target entity must exist✓ target entity must belong to active tenant✓ target entity permission is checked✓ cross-tenant DocumentLinks are rejected✓ duplicate links are prevented✓ create_document_link_draft exists✓ ambiguous targets require clarification✓ missing targets do not create links✓ Link Draft supports expiry✓ Link Draft supports cancellation✓ Link Draft supports conversion✓ Link Draft supports workflow_id✓ prepare_document_link exists✓ preparation creates no DocumentLink✓ document.link.create MutationRequest exists✓ link confirmation widget exists✓ create_document_link accepts mutation_request_id only✓ execution reloads Document✓ execution reloads target entity✓ execution rechecks tenant✓ execution rechecks permissions✓ execution rechecks duplicate relationship✓ successful execution creates DocumentLink✓ successful execution creates AuditEvent✓ successful execution executes MutationRequest✓ successful execution converts Link Draft✓ get_document exists✓ Document metadata retrieval is tenant-scoped✓ Document metadata retrieval is permission-aware✓ raw filesystem paths are never exposed✓ cloud credentials are never exposed✓ search_documents exists✓ search by display name works✓ search by filename works✓ entity filtering works✓ MIME filtering works✓ date filtering works✓ search results are bounded✓ deleted Documents are excluded by default✓ Company Documents can be retrieved✓ Contact Documents can be retrieved✓ Opportunity Documents can be retrieved✓ Activity Documents can be retrieved✓ Task Documents can be retrieved✓ Meeting Documents can be retrieved✓ authorized content endpoint exists✓ content endpoint requires authentication✓ content endpoint verifies tenant✓ content endpoint verifies permission✓ storage path cannot be supplied by caller✓ binary response is streamed✓ Content-Disposition uses a safe filename✓ deleted Documents cannot be downloaded normally✓ predictable public document URLs do not exist✓ prepare_document_deletion exists✓ deletion requires confirmation✓ deletion uses optimistic concurrency✓ deletion sets DELETED✓ deleted_at is recorded✓ deleted_by_user_id is recorded✓ version increments✓ document.deleted AuditEvent exists✓ soft deletion preserves Document metadata✓ normal search excludes deleted Documents✓ normal CRM context excludes deleted Documents✓ unlinking is semantically distinct from deleting✓ architecture can support document.link.delete✓ each uploaded file is an independent Document✓ formal document versioning is not required yet✓ Document metadata can appear in CRM Context✓ binary content never appears in CRM Context✓ Document Context results are bounded✓ Document context respects documents.read✓ Document provenance retains Document ID✓ ChatGPT can find Documents by metadata✓ ChatGPT can attach an uploaded Document to a resolved CRM entity✓ ChatGPT cannot pretend to search inside document contents✓ ChatGPT cannot claim document facts that have not been extracted or otherwise provided✓ document.created AuditEvent exists✓ document.link.created AuditEvent exists✓ document.deleted AuditEvent exists✓ audit logs do not contain document binary contents✓ document operational metrics exist✓ storage metrics exist✓ upload failures are observable✓ download failures are observable✓ Quorentra reports version 0.13.0
Most importantly:
Quorentra can now securely accept customer files, store them independently of the relational database, associate them with authoritative CRM entities, expose them only through tenant- and permission-aware retrieval, and let ChatGPT work with those document relationships without pretending to understand document contents that have not yet been processed.
158. What We Have Achieved
The user can now take:
AdventureWorks-Azure-Migration-Proposal-v3.pdf
and say:
Attach this proposal to the Adventure Works Azure Migration opportunity.
The workflow becomes:
User Upload ↓File Validation ↓Secure Storage ↓Document Metadata ↓Document Created ↓ChatGPT ↓Resolve Adventure Works ↓Resolve Azure Migration ↓Document Link Draft ↓Relationship Validation ↓Preparation ↓Confirmation ↓DocumentLink ↓Audit
This is another important step toward the complete MVP.
159. The Opportunity Workspace Is Becoming Richer
Our Azure Migration Opportunity can now look like:
Azure MigrationAdventure Works€90,000Proposal60%│├── Contacts│ ├── Sarah Johnson│ └── Lisa Chen│├── Activities│ ├── Discovery Call│ ├── Requirements Meeting│ └── Pricing Discussion│├── Tasks│ ├── Send revised pricing│ └── Update architecture diagram│├── Meetings│ └── Proposal Review│└── Documents ├── Requirements.pdf ├── Azure-Architecture.pptx ├── Pricing-v3.xlsx └── Proposal-v3.pdf
That is starting to resemble a genuine customer workspace rather than a collection of isolated CRM records.
160. But ChatGPT Still Cannot Read the Documents
This boundary is intentional.
At the end of Part 26, ChatGPT can know:
Proposal-v3.pdf exists.It belongs to:Adventure WorksandAzure Migration.It was uploaded:2 August 2026.Its size:2.4 MB.Its type:PDF.
But ChatGPT cannot yet reliably answer:
What does the proposal say about pricing?
because we have not extracted the document’s content.
161. The Next Problem
Documents are binary containers.
Before we can use them as AI knowledge, we need:
PDFDOCXPPTXTXT ↓Text
That sounds simple.
It is not.
Different document formats require different extraction strategies.
PDFs may contain:
Digital textScanned pagesTablesHeadersFootersImagesMulti-column layouts
DOCX files contain structured XML.
PPTX files contain slide-based text.
Some files may fail extraction entirely.
Therefore document extraction deserves its own module.
162. Keep Extraction Separate from Upload
Do not make:
Upload
synchronously perform:
ExtractChunkEmbedIndex
Otherwise a simple upload becomes coupled to the entire AI pipeline.
Instead:
Upload ↓Document AVAILABLE
and later:
Document ↓Processing Pipeline
This preserves our modular architecture.
163. The Future Document Pipeline
We are heading toward:
Document Upload ↓Secure Storage ↓Document Metadata ↓Text Extraction ↓Normalization ↓Chunking ↓Embeddings ↓Vector Storage ↓Hybrid Retrieval ↓CRM Context ↓ChatGPT
But each layer should be independently testable.
164. Why Extraction Comes Next
Before embeddings, we need trustworthy text.
Before chunking, we need normalized text.
Before RAG, we need traceable chunks.
Therefore the correct next step is not:
Vector Database
It is:
Document Text Extraction
165. Next Article
In Part 27, we will build:
Building the Document Text Extraction Pipeline — PDF, DOCX, PPTX, TXT, Extraction Jobs, Normalization, Provenance, and Failure Handling
We will introduce:
documents.processDocumentProcessingJobProcessing StatusPENDINGPROCESSINGCOMPLETEDFAILEDDocumentExtractorPDFExtractorDOCXExtractorPPTXExtractorTXTExtractorCSV Extraction StrategyExtraction RouterExtractedDocumentExtractedPageExtractedSectionRaw Extracted TextNormalized TextPage NumbersSlide NumbersParagraph BoundariesSource ProvenanceCharacter CountsExtraction MetadataExtractor VersionProcessing ErrorsRetry PolicyIdempotent ProcessingAsynchronous Processing BoundaryWorker ArchitectureDocument Processing ServiceChatGPT Processing StatusProcessing MetricsExtraction Tests
The target flow will become:
Proposal-v3.pdf ↓DocumentProcessingJob ↓PDFExtractor ↓Extracted Pages ↓Normalized Text ↓Stored Extraction ↓Document └── Searchable Text Foundation
Then a future user question:
What does the Adventure Works proposal say about pricing?
will eventually flow through:
Question ↓CRM Context +Document Retrieval ↓Relevant Extracted Content ↓Grounded ChatGPT Answer
But we will not jump there yet.
Part 27 first gives Quorentra a reliable way to transform an authoritative CRM Document into traceable, normalized text.
That is the next layer in Quorentra — A Modular, ChatGPT-Native AI CRM.