Data model
How PactPal's database is organised, in plain words. Tables marked built exist in a migration; tables marked planned show where later sub-phases are heading and may change. Column details for built tables live in the migration (packages/db/src/migrations.ts); this page explains what they mean and why.
erDiagram
users ||--o{ deals : "sells (seller_id)"
users |o--o{ deals : "buys (buyer_id)"
deals ||--o{ ledger_entries : "has events"
users |o--o{ ledger_entries : "acted (actor_user_id)"
deals ||--o{ scope_versions : "agreed work"
ledger_entries |o--o| scope_versions : "recorded by"
deals ||--o{ acceptances : "signed"
scope_versions ||--o| acceptances : "version signed"
ledger_entries |o--o| acceptances : "recorded by (ACCEPTED)"
users ||--o{ acceptances : "signed by (buyer_id)"
deals ||--o{ milestones : "invoiced stages"
deals ||--o{ deliveries : "delivered work"
deliveries ||--o{ delivery_files : "files"
artifacts ||--o{ delivery_files : "delivered as"
scope_versions ||--o{ milestones : "stage of"
ledger_entries |o--o| milestones : "recorded by (INVOICE_SENT)"
deals ||--o{ artifacts : "has files"
deals ||--o{ scope_drafts : "AI drafts"
artifacts ||--o{ scope_drafts : "drafted from"
ledger_entries |o--o| scope_drafts : "recorded by (when ready)"
ledger_entries |o--o| artifacts : "recorded by"
webhook_events }o--o{ deals : "matched later (Phase 4 and 5)"
deals |o--o{ disputes : "disputed (when matched)"
ledger_entries |o--o| disputes : "opened and decided by"
users ||--o{ sessions : "signed in"
users ||--o{ link_tokens : "buyer links"
users ||--o{ seller_connections : "PayPal connections (Phase 6)"
deals ||--o{ link_tokens : "for one deal"
Rules that apply everywhere
- Ids are uuids chosen by the server code (passed in, so tests are exact), never counters that reveal how many deals exist.
- Money is whole minor units (cents) plus a currency code, never decimals. A deal has one currency; amounts live on the payment stages (milestones) of the agreed work.
- Times are UTC and taken by the server.
- Changes to the structure are numbered, forward-only migrations. The database refuses edits and deletes on evidence tables.
- Personal data is kept to the minimum: email, one display name, PayPal ids. The evidence log refers to people by user id, so a user row can later be anonymised without touching the log. How long each kind is kept is in privacy.md: a deal's whole record 210 days after its last activity (held while a PayPal dispute is open), raw notifications and AI drafts 60 days (9c).
Built (sub-phases 2b to 6h-1)
users: everyone
One row per person. "Seller" and "buyer" are roles on a deal, not kinds of person, so one person can sell in one deal and buy in another.
| Column | Meaning |
|---|---|
id |
The person's PactPal id |
email |
Required. Unique ignoring upper and lower case |
email_verified_at |
When the address was proven (PayPal says verified, or an emailed link was used). Empty if not proven |
display_name |
One free-text name; may be empty. The legally relevant name is the one a buyer types when agreeing, which the ledger records |
paypal_user_id, paypal_payer_id |
PayPal's login subject and account id. Empty until the person signs in with PayPal; each unique. To be confirmed against PayPal's test environment in 2e |
created_at, updated_at |
Server time |
A person invited by email has no PayPal ids. If they later sign in with PayPal, their row is linked (only when the email is proven both ways; built in 2e).
deals
| Column | Meaning |
|---|---|
id |
The deal |
seller_id |
Required; a user |
buyer_id |
A user; empty until known. The database refuses a deal where buyer and seller are the same user |
title |
The deal's name, shown to both sides |
kind |
service, digital or physical: how delivery is proven (tracked link, or shipment tracking) and PayPal's SERVICE or PRODUCT dispute type |
category |
The seller's chosen category from scope.md. Text checked in code, because the list changes |
currency |
Three capital letters, for example USD |
status |
draft, awaiting_acceptance, active, completed, cancelled. A convenience copy of the stage; the ledger is the truth (see below) |
created_at, updated_at |
Server time |
A dispute is not a deal status: disputes get their own table in Phase 5.
How the status moves (4a), each change in one transaction with its log entry:
| From | To | When | Log entry |
|---|---|---|---|
draft |
awaiting_acceptance |
The seller sends the newest version for signing | ACCEPTANCE_REQUESTED |
awaiting_acceptance |
draft |
The seller withdraws the request | ACCEPTANCE_WITHDRAWN |
awaiting_acceptance |
draft |
The buyer declines (optional reason) | DECLINED |
awaiting_acceptance |
active |
The buyer signs with a typed name | ACCEPTED |
active |
completed |
The buyer approves the last unapproved stage | MILESTONE_APPROVED |
The agreed work can be edited only in draft, so the version sent is the version signed.
ledger_entries: the evidence log
One row per event, never changed or removed. An entry is identified by its deal and its position (deal_id, seq), so a citation reads deal:seq.
| Column | Meaning |
|---|---|
deal_id, seq |
Which deal and which position (0, 1, 2, ...). Together they are the key, so two entries can never share a position |
type |
What happened (for example ACCEPTED); checked in code against the event list |
actor |
The role that acted: seller, buyer or system |
actor_user_id |
The person who acted; empty for system. Covered by the hash |
payload |
The event's details (JSON) |
payload_hash |
Fingerprint of the payload |
prev_hash |
The previous entry's entry_hash: the link that makes the log a chain |
entry_hash |
Fingerprint of the whole entry including prev_hash. Changing anything in an entry, or any earlier one, breaks it |
server_time |
When the server wrote it |
ip, user_agent |
Where and with what the actor connected, when relevant. Plain text, because Postgres's address type rewrites addresses and the exact text is hashed. The IP address is the visitor's, worked out with the number of proxies in front of the server (TRUST_PROXY_HOPS, 2 on Render); without that setting only the browser is recorded |
What protects it: the database refuses updates, deletes and truncation; a deal or user with entries cannot be deleted; and verification recomputes every fingerprint and link and names the first bad entry. Someone with full database access could switch the refusal off, but could not hide the change from verification.
artifacts and file_contents: the deal's files
A file is stored once, by the SHA-256 of its bytes, in file_contents; artifacts says which deal it belongs to and what it is. Both tables are append-only. Adding a file saves the bytes first, then in one transaction writes the ledger entry (SOURCE_ADDED or DELIVERABLE_ADDED) and the artifact, so a record never points at missing bytes.
On the website (2f-4) the seller adds source files; both sides list and download them, and every download is checked against the fingerprint first. Delivered work (deliverable) is added through delivery links in Phase 4.
Since 5b the buyer also adds source files (reference material such as a brief or pictures), signed in or by their private link: added_by is then the buyer, and the SOURCE_ADDED entry has actor buyer with IP address and browser. A buyer adds only PDF, PNG, JPEG, GIF or plain text, at most 20 files and 20 MB per deal. The files list says who added each file (seller, buyer or PactPal).
Column (artifacts) |
Meaning |
|---|---|
id, deal_id |
The file and its deal |
kind |
source (what the deal was made from, such as a pasted chat) or deliverable (delivered work) |
filename |
A plain name: 1 to 255 characters, no slashes, no control characters |
media_type |
Found from the bytes, never from the name: PDF, PNG, JPEG, GIF, text/plain, or application/octet-stream |
size_bytes, sha256 |
The file's size and fingerprint; reading the file checks the bytes against both |
added_by |
The user who added it (the seller, or since 5b the buyer); empty when the system did |
ledger_seq |
The ledger entry (with deal_id) that recorded it |
Column (file_contents) |
Meaning |
|---|---|
sha256 |
Key: the fingerprint of the bytes. The same file added twice is stored once |
size_bytes, bytes |
The length (checked to match) and the content |
created_at |
Server time |
artifacts has no database link to file_contents, so the bytes can move to object storage later by changing one adapter. Limits protect the 1 GB database: 10 MB per file and 50 MB per deal.
webhook_events: notifications as received
A webhook is a message PayPal posts to our server when something happens (an invoice created or paid). Each genuine one is saved here in full, exactly once, before the server answers "got it", so a repeat is recognised even after a restart and nothing PayPal tells us is lost. Handling the contents (a milestone paid, a dispute matched) comes in Phases 4 and 5; this table is not the evidence record, the ledger is.
| Column | Meaning |
|---|---|
source, event_id |
Who sent it (paypal, or resend for forwarded emails since 4p-2, keyed by the Svix message id) and its own id for the event. Together the key: the same pair again is a repeat |
event_type |
What happened, in PayPal's words (for example INVOICING.INVOICE.PAID) |
resource_type |
What it is about (for example invoices); may be empty |
event_created_at |
When PayPal says it happened; empty if PayPal's value was not a date |
received_at |
When our server received it (UTC) |
body |
The whole event as received. Contains buyer details, so it is removed 60 days after it arrives (9c) |
Unlike the ledger it can be changed and deleted: the daily retention clean-up (9c) removes events 60 days after they arrive.
sessions: who is signed in
One row per sign-in. The browser holds a random 43-character token in a cookie that page scripts cannot read; the table stores only the token's SHA-256, so the table itself cannot be used to sign in.
| Column | Meaning |
|---|---|
token_hash |
SHA-256 of the cookie's token (the key) |
user_id |
Who is signed in. Deleting a user deletes their sessions |
created_at, expires_at |
When the sign-in happened and when it stops working (7 days later) |
An expired session is refused and removed when it is next used; signing out deletes it.
link_tokens: buyer links
A private link that lets a deal's buyer into that one deal without a PayPal account. The secret travels after # in the address (/link#t=...), so the server never logs it; only its SHA-256 is stored. Opening the link shows a "Continue" button, because mail scanners open links but do not press buttons.
| Column | Meaning |
|---|---|
token_hash |
SHA-256 of the link's secret (the key) |
deal_id, user_id |
The deal it opens and the buyer it is for |
purpose |
deal-access for now |
created_at, expires_at |
When it was made and when it stops working (14 days later) |
revoked_at |
Set when a newer link for the same deal and buyer replaces it |
last_used_at |
The last time it let someone in |
Using a link marks the buyer's email as proven and starts a session limited to that deal.
scope_versions: the agreed work
What a deal covers and how its price is split into payment stages. Each save by the seller is a new numbered version; old versions are kept and the database refuses edits, deletes and emptying the table.
| Column | Meaning |
|---|---|
deal_id, version |
Which deal and which version (1, 2, 3, ...). Together the key. The number is chosen while the deal row is locked, so two saves at once get different numbers |
format |
Which content layout the version uses. Format 1 is written by the seller; AI drafts with quotes from the source (Phase 3) will be a new format |
content |
The agreed work (JSON): summary, what will be delivered, what is not included, revisions included, due date, and 1 to 20 payment stages (title, amount in minor units, optional due date) |
content_sha256 |
Fingerprint of the content's canonical JSON. Checked every time a version is read; the same value is in the ledger entry |
created_by |
The seller who saved it |
ledger_seq |
The SCOPE_VERSIONED ledger entry (with deal_id) that recorded it. Its payload holds the version, fingerprint, total, currency and number of stages |
created_at |
Server time |
When a version is saved from an AI draft (see scope_drafts), the ledger also gets a SCOPE_CONFIRMED entry right after the SCOPE_VERSIONED one: the version, its fingerprint, the draft's id and fingerprint (the stored winner's), and, when the council could not rank its drafts and the seller chose one, usedCandidate (its letter and fingerprint); then for each of summary, deliverables, exclusions, revisions, due date and total price whether the seller kept it (unchanged), changed it, added it (the draft left it missing) or removed it. It holds no text. When usedCandidate is present, the kept/changed words compare with that draft, not with the winner.
Payment stages are part of the content, so their titles and amounts are fingerprinted and frozen with the rest: changing an amount means saving a new version. Only the seller can save, and only while the deal is a draft.
Since 4i the content can also hold buyerProvides: up to 20 things the buyer provides, each with text (1 to 500 characters) and an optional date. It is still format 1: the list is optional and never stored empty, so versions saved before it existed keep their exact content and fingerprint. The acceptance document's terms carry it only when present, so documents for older versions are rebuilt byte for byte.
Since 4i-2 the content can also hold deliverableKinds: one mark per line of what will be delivered, in the same order: objective (can be checked), process (a step in the work), subjective (a matter of taste, with the approvalStage whose approval by the buyer settles it), or null (not marked). It is stored only when at least one line is marked, must have one entry per line, and a stage must exist in the version. The acceptance document carries it only when present. The deliveries view lists, for each delivery, the matters of taste its stage settles.
acceptances: signed agreements
What the buyer signed, kept whole. Built in 4a (migration 0009). Append-only: the database refuses edits, deletes and emptying the table.
The buyer's page is drawn only from an acceptance document (format 1), plain data built by the server: the deal title, category, the seller's display name, the version of the agreed work with its fingerprint, every term with amounts in minor units and as text, the total, and the notice shown above the sign button (with its version). Its fingerprint is the SHA-256 of its canonical JSON. When the buyer signs, the server rebuilds the document and refuses if it differs from the one their page showed.
| Column | Meaning |
|---|---|
deal_id, ledger_seq |
The deal and its ACCEPTED ledger entry. Together the key |
version |
The version of the agreed work signed (must exist in scope_versions). Unique per deal |
document, document_sha256 |
The acceptance document exactly as shown, and its fingerprint; checked on every read |
buyer_id |
The buyer who signed |
typed_name |
The name the buyer typed (2–200 characters). Not compared with anything; not proof of identity |
created_at |
Server time |
The ACCEPTED entry holds the version, the content and document fingerprints, the typed name, the notice version and how the buyer reached the page (link or paypal), plus the browser and, when known, the IP address. The typed name and the terms are personal data, so they go with the deal, 210 days after its last activity (9c). Change orders (4j) will add further rows for later versions.
When the email service accepts a receipt email (4c), a RECEIPT_SENT entry (by the system) records the version, which side it went to (buyer or seller), the receipt's SHA-256, a SHA-256 of the lower-cased address (never the address) and Resend's message id. A failed send records nothing. At most 5 receipt emails are sent per signed version.
The receipt PDF (4b) is not stored: it is made from the newest row each time it is downloaded, after checking the row against its fingerprint and against its ACCEPTED entry (same document fingerprint, typed name and time). The same row always gives the same bytes.
milestones: payment stages invoiced through PayPal
Built in 4d (migration 0010). One row per payment stage of the signed version that has a PayPal invoice; the stage's title, amount and due date stay in the signed version (scope_versions), so they are fingerprinted with it. Not append-only: the status moves on, and the evidence is the INVOICE_SENT entry.
| Column | Meaning |
|---|---|
deal_id, version, stage |
The deal, the signed version and the stage number (from 1). Together the key |
amount_minor, currency |
What was invoiced |
status |
sending (reserved before PayPal is asked), sent (PayPal confirmed), paid (4e) |
request_id |
The fixed PayPal-Request-Id, pp-<deal>-v<version>-s<stage>: every retry reaches PayPal as the same request, so a stage is never invoiced twice |
invoice_sha256 |
Fingerprint of the item name, description, note and amount the seller confirmed |
invoice_id, pay_url |
PayPal's invoice id (also kept for a created-but-unsent invoice, so a retry re-sends it) and the buyer's view-and-pay link |
ledger_seq, sent_at |
The INVOICE_SENT entry and when it was sent; set together with sent |
paid_at, payment_ledger_seq, transaction_id |
Once PayPal confirms the invoice fully paid (4e): when, the PAYMENT_RECEIVED entry, and PayPal's transaction id. Set together with paid |
created_at |
When the stage was reserved |
PAYMENT_RECEIVED (by the system, 4e) holds the version, stage, PayPal invoice id, the amount PayPal says was paid, PayPal's transactions (id, date, method) and how PactPal learned of it (notification with PayPal's event id, or status-check). It is written once, only after PactPal re-read the invoice from PayPal and found it PAID.
Since 5a (migration 0020) a paid stage also keeps invoice_number, PayPal's own number for the invoice (not its id), read from PayPal's answer when the payment is recorded, so a dispute can be matched by it.
Since 6h-3 (migration 0031) a stage also keeps seller_connection_id: the seller's own linked app its invoice is sent from (a seller_connections row), or empty for PactPal's own test seller app. It is set when the stage is reserved and never changes, so retries, payment checks and disputes always use the account that made the invoice. The INVOICE_SENT entry of such an invoice also carries paypalAccount, the seller's PayPal account id.
INVOICE_SENT (by the seller) holds the version, stage, PayPal invoice id, amount and currency, the fingerprints of the agreed work and of the invoice text, a SHA-256 of the buyer's lower-cased address (never the address) and the route PayPal answered by (rest).
deliveries and delivery_files: delivered work
Built in 4f (migration 0012); both append-only. A delivery is a set of the deal's delivered files (artifacts of kind deliverable) for one stage of the signed version, with an optional message (up to 1,000 characters), sent by the seller. A stage can have several deliveries. delivery_files lists each delivery's files in order and only allows files of the same deal.
Column (deliveries) |
Meaning |
|---|---|
id, deal_id |
The delivery and its deal |
version, stage |
The signed version and the payment stage it delivers for |
message |
The seller's message to the buyer, if any |
sent_by, ledger_seq, created_at |
The seller, the DELIVERY_SENT entry and when |
Delivered files are not listed or served with the deal's source files: they are reached only through their delivery, so every buyer download is logged. The log entries:
DELIVERY_SENT(seller): the delivery, version, stage, message and each file's id, name, size and SHA-256.DELIVERY_EMAILED(system): the delivery, a SHA-256 of the buyer's lower-cased address and the email service's message id; written only when the email was accepted. Each email carries a fresh private buyer link.DELIVERY_VIEWED(buyer, with IP address and browser): the delivery and how the buyer came in (linkorpaypal); at most once per delivery every 10 minutes.DELIVERY_DOWNLOADED(buyer, with IP address and browser): the delivery and the file's id, name, size and SHA-256; every download, after the file's bytes passed their fingerprint check. The seller's own downloads are not logged.
delivery_answers: the buyer's answer to a delivery
Built in 4g (migration 0013); append-only. The buyer answers a delivery once, and only after opening it (a DELIVERY_VIEWED exists): they approve it, or they request changes. The database refuses a second answer to the same delivery, so two tabs cannot answer twice. After a change request the seller sends a new delivery for the same stage, which can be answered in turn.
| Column | Meaning |
|---|---|
delivery_id, deal_id |
The delivery answered (the key) and its deal |
kind |
approved or changes |
request |
What should change (1 to 2,000 characters); only for changes |
revision |
Which change request of the deal this is (earlier change requests plus one); only for changes |
answered_by, ledger_seq, created_at |
The buyer, the log entry and when |
Revisions are counted across the whole deal and compared with the signed version's revisionsIncluded. A request beyond that number is never refused: it is recorded as beyond the included revisions. The log entries (both by the buyer, with IP address and browser):
MILESTONE_APPROVED: the delivery, version, stage and each delivered file's id and SHA-256.CHANGE_REQUESTED: the delivery, version, stage, the request, the revision number, the revisions included (or none when the agreement states no limit) andbeyondIncluded.
When an approval leaves every payment stage of the signed version with an approved delivery, the deal's status becomes completed in the same transaction; the approvals are the evidence, so there is no separate entry. Invoices and deliveries still work on a completed deal.
Evidence completeness score: computed, not stored
The score (4h) has no table. It is worked out from ledger_entries and the signed (or newest) version in scope_versions each time the seller's page asks, so it always matches the log.
change_orders: changes to a signed agreement
Built in 4j (migration 0014). After the buyer has signed, the seller can propose a change order: a later version of the agreed work, stored in scope_versions like any other, that only adds to the signed one (lines with their marks, exclusions, buyer-provides items and payment stages after the signed ones, a new overall due date, more revisions when the agreement states a number). The buyer signs it as they signed the agreement (a new acceptances row for the new version), or declines it; the seller can withdraw it while it waits. At most one change waits per deal.
| Column | Meaning |
|---|---|
id, deal_id |
The change and its deal |
from_version, to_version |
The signed version it changes and the proposed version (in scope_versions) |
message |
The seller's message to the buyer, if any (up to 1,000 characters) |
status |
proposed, then once accepted, declined or withdrawn; the database refuses any other change and any removal |
reason |
The buyer's reason, when declined (optional) |
proposed_by, proposed_seq, created_at |
The seller, the CHANGE_ORDER_PROPOSED entry and when |
answered_seq, answered_at |
The answering entry and when; empty while waiting |
The log entries:
CHANGE_ORDER_PROPOSED(seller): the change, both versions, the new content's fingerprint and total, how much was added, and the message.CHANGE_ORDER_ACCEPTED(buyer, with IP address and browser): whatACCEPTEDrecords, plus the change and the version it changed. The receipt of the new version is emailed as after the first signature.CHANGE_ORDER_DECLINED(buyer, with IP address and browser): the change, the version and the reason, if any.CHANGE_ORDER_WITHDRAWN(seller): the change and the version.
The emails about a change (to the buyer when it is proposed, with a fresh private link; to the seller when it is answered, 4j-2) are notices and are not logged.
A stage is the same stage in every version, because a change order only adds. So a deal has at most one invoice per stage (milestones_one_per_stage), an invoice, payment, delivery or approval counts for its stage whatever version it happened under, and the deal is completed when every stage of the current signed version is approved. A completed deal that gains stages is active again. Once signed, the deal page shows the signed version as the agreement; a waiting or declined change is shown on its own.
buyer_questions: questions about the agreement before signing
Built in 4k (migration 0015). While the buyer is asked to sign, they can ask about the page they see. A model answers only from the agreement's numbered lines, and the answer is shown only when code finds every quote on its line; otherwise the question goes to the seller, who answers it. At most 20 per deal, and one being answered at a time.
| Column | Meaning |
|---|---|
id, deal_id |
The question and its deal |
version, document_sha256 |
The version asked about and the fingerprint of the page the buyer saw |
question |
The buyer's words (1 to 500 characters) |
status |
asked (being answered), then answered or forwarded; answered can become forwarded ("Ask the seller instead"); forwarded becomes seller-answered |
ai_answer |
The checked automatic answer, kept as a draft: text, citations (line and quote), model and prompt version; empty when none was shown |
forward_reason |
Why it went to the seller: not-in-agreement, quote-not-found, no-model or buyer-asked |
seller_answer |
The seller's answer (1 to 2,000 characters) |
asked_by, asked_seq, created_at |
The buyer, the BUYER_QUESTION entry and when |
answered_seq, forwarded_seq, seller_seq |
The AI_ANSWER_SHOWN, QUESTION_FORWARDED and QUESTION_ANSWERED entries, once they happen |
updated_at |
The last step |
The database refuses any other move, any change to what a step recorded, and any removal.
The log entries:
BUYER_QUESTION(buyer, with IP address and browser): the question, the version and the page's fingerprint.AI_ANSWER_SHOWN(system): the question, the answer's fingerprint, the model, the prompt version and the cited line numbers. The answer's text stays inbuyer_questions.QUESTION_FORWARDED(system, or the buyer with IP address and browser for "Ask the seller instead"): the question and the reason.QUESTION_ANSWERED(seller): the question and the seller's answer.
The emails about a question (to the seller when it is sent to them; to the buyer, with a fresh private link, when the seller answers, 4k-2) are notices and are not logged.
delivery_checks: the seller's pre-delivery checks
Built in 4l (migration 0016). Advice for the seller only, kept apart from the evidence log: nothing about a check is logged, and the buyer never sees it. Rows are never changed or removed. At most 20 per deal.
| Column | Meaning |
|---|---|
id, deal_id |
The check and its deal |
version |
The signed version whose lines were checked |
stage |
The payment stage the seller chose |
files |
The checked files: id, name and SHA-256 (1 to 10; each one of the deal's delivered files) |
report |
The lines checked, what code read from each file (type, size, pixel size, transparency), the code results per line, and the model's look per image as a draft (model, prompt version, verdicts, notes) |
checked_by |
The seller |
created_at |
When |
message_reviews: council reviews of the buyer's messages
Built in 4m (migration 0017). The seller pastes a new message from the buyer on a signed deal. The message is saved as a source file (artifacts, logged SOURCE_ADDED) and a council reviews it. The review is advice for the seller only, kept apart from the evidence log, and the buyer never sees it. A review moves once, from running to ready or failed, and is never removed. At most one runs per deal, and a deal has at most 30.
| Column | Meaning |
|---|---|
id, deal_id |
The review and its deal |
version |
The signed version the message was read against |
source_artifact_id, source_sha256 |
The saved message and its fingerprint |
status |
running, then ready or failed |
prompt_version, rank_prompt_version |
The review prompt and the ranking prompt used |
review |
The winning review, a draft: each request (quote, label, citations, why), the drafted reply and change lines |
council |
What each model did, the votes, the scores and the agreement (safe error texts only, never the message) |
failure_reason |
Why it failed (never repeats the message) |
requested_by, created_at, finished_at |
The seller and the times |
Chat screenshots (4o) are source files like any upload (SOURCE_ADDED). The text a model reads from them is not stored: it only fills the chat box, and what the seller checks and drafts from is saved as the chat file.
intake_keys: sellers' private keys for creating deals from other tools
Built in 4p (migration 0018). A tool posts text with a seller's key, and a draft deal is created for that seller with the text saved as a source file. DEAL_CREATED then also records via: "intake" and the key's last 4 characters.
Since 4p-2 (migration 0019) a row is one of two kinds: a key for other tools, or an address for forwarding emails to deals+<code>@in.pact-pal.app. A forwarded email from the seller's own address that passes SPF or DKIM becomes a draft deal the same way, with via: "email" and the address's last 4 characters. The Resend event is saved once in webhook_events (source resend).
| Column | Meaning |
|---|---|
id, seller_id |
The key and its owner |
kind |
key or address (4p-2); it never changes |
token_hash, key_ending |
The key's SHA-256 and its last 4 characters; the key itself is never stored |
default_category, default_currency |
What deals created with it get unless the post names others |
created_at, last_used_at, turned_off_at |
When it was made, last used and turned off (one of each kind per seller is on) |
uses_day, uses_count |
Deals created with it on that UTC day, for the limit of 30 |
A key keeps its owner, kind, hash and defaults, is turned off once, and is never removed.
agent_requests: what an agent asks the seller to let it do (8c)
Built in 0040, which also lets intake_keys.kind be agent (the key an MCP client sends) and notifications.kind be agent-request. One row per request; status moves waiting → approved or declined, then done, failed or expired.
| Column | Meaning |
|---|---|
id, seller_id, deal_id, key_ending |
The request, its seller and deal, and the agent key's last 4 characters |
kind |
send-invoice or send-evidence |
path, body, body_sha256 |
The exact PactPal request to run once approved, and its fingerprint |
summary |
What the seller reads before approving |
code_hash, code_expires_at, wrong_codes |
The one-time code's hash (never the code), its 10-minute end, wrong tries (5 spend it) |
created_at, answered_at, used_at |
Server times |
result_status, result_error |
How running it ended |
Log entries: AGENT_REQUESTED (kind, key ending, body fingerprint), AGENT_REQUEST_ANSWERED (approved or declined), AGENT_ACTED (result); the action itself is logged by its own step (such as INVOICE_SENT). Deals an agent makes from text record via: "agent".
scope_drafts: AI drafts of the agreed work
A draft is a proposal made by the council from a chat saved as a text source file. It is not evidence and not an agreement, so it lives in its own table and is not append-only: a row starts as running and moves once to ready or failed. Only a ready draft is recorded in the ledger (SCOPE_DRAFTED, by fingerprint, never with text). Nothing becomes agreed work until the seller confirms it (3e).
| Column | Meaning |
|---|---|
id, deal_id |
The draft and its deal |
source_artifact_id, source_sha256 |
The chat file it was made from (the database makes it a file of the same deal) and that file's fingerprint, so every citation can be checked against it |
created_by |
The seller who asked |
status |
running, ready or failed. At most one running per deal |
created_at, finished_at |
Server time |
draft_prompt_version, rank_prompt_version |
Which wording of the drafting and ranking prompts produced it (drafting wording 2 since 3fb) |
draft, draft_sha256 |
The winning draft (JSON) and the fingerprint of its canonical JSON; checked on every read. Only on ready |
council |
What each model did (valid, invalid with reasons, failed), the votes, which label was which model, every valid draft, scores, agreement, tokens and time. On ready, and on failed when the council ran |
failure_reason |
Why it failed; never repeats the chat. Only on failed |
ledger_seq |
The SCOPE_DRAFTED entry (with deal_id). Only on ready |
Lines of the chat that look like orders to an AI are not stored anywhere; they are found again from the chat file whenever a finished draft is read (source.hiddenLines, line numbers only). A deal can have at most 10 drafts. A draft still running when the server restarts is marked failed. The chat quotes inside draft and council are personal data, so a finished draft is removed 60 days after it finished; its fingerprint stays in the log (9c).
Drafts made with drafting prompt version 3 (4n) are "format 3": each delivered line also has a suggested mark (objective, process, subjective or unsure) and the draft lists what the buyer provides (text, a date or none, citations). Older drafts stay "format 2" and are read as they are. When agreed work is saved from a draft, SCOPE_CONFIRMED also says for buyerProvides and deliverableMarks whether the seller kept the draft's suggestion.
disputes: PayPal disputes found by checking
Built in 5a (migration 0020). A Render Cron Job starts the Workflow task check-disputes every 5 minutes (also "Check now" and any PayPal dispute notification). It reads the seller's dispute list, reads in full each dispute that is new or changed, ties it to a paid stage and saves it here. A working copy of PayPal's latest answer, not evidence: it moves with PayPal, but once tied to a deal it stays tied, keeps the log entries that recorded its opening and decision, and is never removed.
| Column | Meaning |
|---|---|
dispute_id |
PayPal's id (the key), e.g. PP-R-EMM-10195224 |
deal_id, stage, invoice_id |
The deal and paid stage it is about, and that stage's invoice; all empty while it matches no deal |
matched_by |
transaction (the seller transaction id equals the stage's) or invoice-number (the invoice the dispute names, which PayPal gives as the invoice's id) |
reason, status, dispute_state |
PayPal's own words, e.g. MERCHANDISE_OR_SERVICE_NOT_AS_DESCRIBED, WAITING_FOR_SELLER_RESPONSE, REQUIRED_ACTION |
stage_name |
PayPal's stage, e.g. INQUIRY, CHARGEBACK, PRE_ARBITRATION (not a payment stage) |
amount_minor, currency |
The disputed amount |
seller_due_at, buyer_due_at |
When each side must respond, as PayPal says |
allowed_actions, offer_types |
What the seller may do right now, and the refund offers PayPal accepts (read again before any answer) |
outcome |
PayPal's decision, once there is one |
details |
PayPal's whole answer as read, including the buyer's messages: personal data, so it goes with the deal, 210 days after its last activity (9c) |
paypal_created_at, paypal_updated_at |
PayPal's own times |
first_seen_at, checked_at |
When PactPal first saw it and last read it |
opened_seq, resolved_seq |
The DISPUTE_OPENED and DISPUTE_RESOLVED entries; set once |
The log entries (all by the system; ids and PayPal's words, never the buyer's messages):
DISPUTE_OPENED: once, when the dispute is first tied to a deal: dispute id, reason, status, PayPal stage, amount, seller due date, payment stage, invoice id and how it was matched.DISPUTE_UPDATED: when the status, PayPal stage or seller due date changes, with before and after. A change only to what the seller may do is saved on the row, not logged.DISPUTE_RESOLVED: once, with PayPal's outcome and the final status.
dispute_checks: each check of the seller's disputes
Built in 5a (migration 0020). One row per check: source (schedule, button or notification), started_at, finished_at, the counts (listed, read_in_full, matched, unmatched, new_entries) or failure (PayPal's status only, never its text). The deal page shows the newest one as "last checked". Old rows can be removed later; nothing refers to them.
claim_analyses: council comparisons of a dispute claim with the record
Built in 5d (migration 0021). A draft for the seller only, kept apart from the evidence log. It moves once, from running to ready or failed, keeps what it recorded and is never removed. One runs per dispute at a time, at most 10 per dispute, and one still running after 15 minutes is marked failed ("did not finish in time").
| Column | Meaning |
|---|---|
id, deal_id, dispute_id |
The comparison, its deal and its dispute |
pasted_artifact_id |
The seller's pasted copy of the claim (a source file of the deal), when PayPal showed no buyer message |
status |
running, then ready or failed |
prompt_version, rank_prompt_version |
The claim prompt and the ranking prompt used |
claim_sha256, record_sha256 |
Fingerprints of the claim lines and record lines the models were given (set when ready) |
analysis |
The winning comparison, a draft: each statement (quote, line, finding, citations, relation) and a suggestion |
council |
What each model did, the votes, the scores and the agreement (safe error texts only) |
failure_reason |
Why it failed (never repeats the claim) |
ledger_seq |
The CLAIM_ANALYZED entry, when ready |
requested_by, created_at, finished_at |
The seller and the times |
CLAIM_ANALYZED (by the system) holds ids, fingerprints, prompt versions, model names, the winner, the number of statements and whether it needs review; no AI text.
Since 5f (migration 0023) the table also holds packet summaries: kind is claim (a comparison) or summary, and based_on names the comparison a summary was drafted from (set for summaries only). One runs per dispute per kind at a time, at most 10 per dispute per kind. For a summary, analysis holds the sentences with their log entries, the document picks and the draft note; claim_sha256 is the fingerprint of the comparison rows it read. A ready summary logs SUMMARY_DRAFTED (by the system): ids, basedOn, the fingerprints of the draft, of the comparison rows, record lines and document list it read, models, the winner and the number of sentences; no AI text.
Since 5h (migration 0025) kind may also be response: answer help, with no based_on. Its analysis holds the draft message, one note per option PayPal allowed (with record entries) and the offer note; a ready one logs RESPONSE_DRAFTED (by the system): ids, fingerprints, models, the winner and the number of options; no AI text.
Since 5i (migration 0026) kind may also be appeal: the appeal check. Its analysis holds a judgement per new item (whether it speaks to the claim, why, cited entries) and a short summary; a ready one logs APPEAL_CHECKED (by the system): ids, the fingerprint of the new-items list (itemsSha256) and of what the models read, models, the winner and how many items it judged as speaking to the claim (speaking); no AI text. Since 5i the dispute's stored details also hold decisionReasons (PayPal's decision reason codes) and sellerEvidence (evidence PayPal lists as sent by the seller: type and document names).
Since 5k-fix-1 the stored details also hold readVersion (how PactPal read the answer, now 2; a copy with another or no version is read again once), and the buyer's messages include what the buyer wrote in their own evidence entries (SUBMITTED_BY_BUYER notes).
Since 5i-2 (migration 0027) kind may also be statement: the appeal statement, based on a ready appeal check (based_on). Its analysis holds the sentences with their record entries and the note; a ready one logs APPEAL_DRAFTED (by the system): ids, basedOn, fingerprints, models, the winner and the number of sentences; no AI text.
Since 5d the dispute's stored details also hold requestedEvidence: the evidence types PayPal asks the seller for.
evidence_packets: built evidence packets
Built in 5e (migration 0022); append-only, at most 20 per dispute. One row per packet the seller builds for a dispute.
| Column | Meaning |
|---|---|
id, deal_id, dispute_id |
The packet, its deal and its dispute |
packet_sha256, packet_bytes |
The packet PDF's fingerprint and size; its bytes are stored by fingerprint in file_contents |
exhibits |
Each document considered: id, name, who added it, fingerprint, size, how it is sent (as it is, text as PDF, left out with the reason), and for an included one its number and the fingerprint and size of the file PayPal gets |
total_bytes |
The packet PDF plus every included exhibit |
chain_entries, chain_head |
The log's seal the packet carries: how many entries and the latest entry's fingerprint |
analysis_id, summary_id |
Since 5f: the AI comparison and summary the packet carries (both or neither; a ready pair of this dispute) |
built_by, ledger_seq, created_at |
The seller, the PACKET_BUILT entry and when |
PACKET_BUILT (by the seller) holds the packet id, dispute id, the packet's fingerprint and size, each included exhibit's id and fingerprints, how many were left out, the total size and the seal, and since 5f the AI comparison and summary ids with the fingerprints of the two drafts when the packet carries them.
dispute_actions: what the seller sent PayPal for a dispute
Built in 5g (migration 0024). One row per escalation or evidence upload the seller confirmed. A row moves from sending to sent, refused or unknown; an unknown one may go back to sending to be sent again with the same request id. Nothing it recorded changes and nothing is removed. One sending row per dispute at a time; at most 10 evidence uploads per dispute, refused ones not counted.
| Column | Meaning |
|---|---|
id, deal_id, dispute_id, seller_id |
The action, its deal, dispute and the seller who confirmed it |
kind |
escalate or evidence |
status |
sending, sent, refused (nothing was sent) or unknown (PayPal may have it) |
request_id |
The fixed PayPal-Request-Id, pactpal-<kind>-<id> |
packet_id, evidence_type |
For an upload: the packet and PayPal's evidence type |
note, note_sha256 |
The confirmed note (at most 2,000 characters) and its fingerprint |
files, total_bytes |
For an upload: each file's exhibit number (none for the packet PDF), name, type, size and fingerprint; the total |
transport, debug_id, failure |
How it went and PayPal's debug id, or why not |
ledger_seq, created_at, finished_at |
The DISPUTE_ESCALATED or EVIDENCE_UPLOADED entry when sent, and the times |
Since 5h (migration 0025) a row may also be a message to the buyer, a refund offer or an accept of the claim. offer_type (REFUND), amount_minor and currency are set for an offer (the amount offered) and an accept (the disputed amount refunded), and kept as confirmed. Sent ones log DISPUTE_MESSAGE_SENT, OFFER_MADE (with the type and amount) or CLAIM_ACCEPTED (with the amount), by the seller.
Since 5i-2 (migration 0027) a row may also be an appeal: like evidence, with a packet, an evidence type and the files sent; a sent one logs APPEAL_SUBMITTED (by the seller) with the same fields as EVIDENCE_UPLOADED.
DISPUTE_ESCALATED and EVIDENCE_UPLOADED (by the seller) hold the action and dispute ids, the note's fingerprint, the transport and PayPal's debug id; an upload also holds the packet id, the evidence type, each file's exhibit number, name, size and fingerprint, and the total size. Never the note's words.
dispute_notices: emails sent to the seller about disputes
Built in 5j (migration 0028); append-only. One row per email key: a new dispute, a change, PayPal's decision or a deadline reminder. Notices, not evidence: they never go in the evidence log.
| Column | Meaning |
|---|---|
key |
The email's fixed key, also Resend's idempotency key: dispute-entry:<deal>:<seq> or dispute-reminder:<dispute>:<respond-by date>:<72h, 24h or 6h> |
deal_id, dispute_id |
The deal and the dispute |
kind |
opened, changed, decided or reminder |
ledger_seq |
For an event email, the dispute entry it is about |
sent_at |
When it was sent or refused |
email_id, failure |
Resend's id when sent, or rejected when the address was refused (exactly one of the two) |
seller_connections: sellers connected to their own PayPal account
Built in 6c (migration 0029); own apps added in 6h-1 (migration 0030) and their alert address in 6h-4 (migration 0032). One row per attempt to connect through Partner Referrals ("Connect with PayPal"), or per link of a seller's own REST app. Not append-only, because the status changes, and not evidence. Ending a connection keeps the row, and reconnecting makes a new one, so it stays known which PayPal account past invoices came from. Sellers with no live connection use PactPal's own test seller app, as before. Nothing on the site writes rows yet: own apps from 6h-2, Partner Referrals from 6d.
| Column | Meaning |
|---|---|
id, user_id |
The attempt and the PactPal seller. Deleting the user deletes their rows |
method |
partner (Partner Referrals) or own_app (the seller's own REST app, 6h) |
tracking_id |
Partner Referrals only: PactPal's own id for the attempt, sent to PayPal and given back when the seller is looked up. Unique |
client_id |
Own apps only: the app's client id. Not secret |
client_secret_sealed |
Own apps only, while connected: the app's secret encrypted with SELLER_KEYS_ENCRYPTION_KEY (AES-256-GCM, bound to the row). Wiped when the link ends |
webhook_id |
Own apps only (6h-4): PayPal's id for the alert address registered on the app, /webhooks/paypal/seller/<id>; empty when PayPal refused or the site was not https. Not secret |
status |
pending (link made, not confirmed), connected or disconnected |
merchant_id |
The seller's PayPal account id (payer id), set once connected. Not secret; no email, name or key is stored |
scopes |
The permission addresses PayPal lists for the app that acts for the seller (the platform app, or the seller's own app), as PayPal writes them |
created_at, connected_at, disconnected_at |
Server times of each step |
The database refuses a connected row without an account or time, a pending row with either, and a disconnected row without its time. A Partner Referrals row needs its tracking id and holds no app keys; an own-app row needs its client id, is never pending, holds its sealed secret while connected, and no row holds a secret once ended. At most one connected row per user and one per PayPal account (partial unique indexes), so a dispute can never be shown to the wrong seller.
deal_messages: the deal's chat (7b)
Messages from the deal's seller and buyer, and PactPal's own cards in the chat. Append-only: a message is never edited or removed.
| Column | Meaning |
|---|---|
id, deal_id, seq |
The message, its deal and its position in the chat (1, 2, 3, ...; chosen while the deal row is locked, so two posts never clash) |
author_id, role |
Who wrote it (seller or buyer), or system with no author for PactPal's cards |
kind |
text (a person's message) or notice (a PactPal card) |
body, body_sha256 |
The words (1 to 4,000 characters) and their fingerprint; every read checks the words against it |
notice |
For cards: what it is about (review-requested, review-withdrawn, changes-requested, signed, step, reminder) and the version or step |
client_id |
The page's own id for a message, so a retried post returns the same message instead of making a second |
ledger_seq |
For a message, its MESSAGE_SENT entry (the message id, position, fingerprint and length; never the words). For a card, the entry it announces, if any |
created_at |
Server time |
A deal holds at most 5,000 messages; a cancelled deal's chat is closed.
message_reads: how far each person has read (7c)
One row per deal and person: read_seq (the last message they have read; it only moves forward), logged_seq and logged_at (what was last written to the log). It changes as they read, so it is not append-only; the evidence is the MESSAGES_READ entry (upToSeq), written when the position has moved, at most once a minute per person, and always when their page closes.
notifications: what needs each person (7e)
| Column | Meaning |
|---|---|
id, user_id, deal_id |
The notification, who it is for and its deal |
kind |
message, review-requested, changes-requested, signed, step (a step waits for them), step-news (a step approved or done; migration 0039) or reminder |
tab |
The deal space tab it opens |
text |
The words shown (at most 300 characters; for a message, the sender's name and the start of the message) |
created_at, read_at |
Server times; read_at is empty until seen |
Not evidence: the log records what happened. News (signed, step-news) is marked read when the person opens the tab it points at (7o); the other kinds ask them to act and stay until opened from the list. A person has at most one unread message notification per deal; later messages fold into it until they read the chat, which also marks it read. Each new notification is emailed (the buyer's with a fresh private link), except a message while the person has the deal open. Emails never quote a message.
retention_runs: each daily clean-up (9c)
One row per run of the retention-sweep Workflow task: when it ran, whether it was a dry run, its two cutoffs (deals_before, 210 days back; short_before, 60 days back) and counts, the number of rows removed per kind (deals, deals held by an open dispute, deals that failed, notifications, drafts, sessions, buyer links, unmatched disputes, file bytes). No personal data and no deal ids.
The guarded tables still refuse every UPDATE. Since migration 0041 each also has a <table>_no_delete guard that refuses every DELETE unless the transaction has set pactpal.retention_purge = 'on' (SET LOCAL). Only the clean-up sets it, and it removes a deal from every table in one transaction (purgeDeal), so a deal is never left in part. File bytes go when no file or packet uses them any more.
Phase 7 (deal space)
Being built in 7b–7m; each table is described in full above once it exists.
| Table or change | Sub-phase | What it holds |
|---|---|---|
deal_messages |
7b | The deal's chat: who wrote each message, its text and fingerprint, its position in the chat and the MESSAGE_SENT log entry. Append-only; PactPal's own cards (notice) are messages from system |
message_reads |
7c | How far each person has read the chat. The log gets MESSAGES_READ when it moves, at most once a minute per person |
notifications |
7e | What each person has not seen yet (a message, a review request, a reminder) and when they saw it. Not evidence |
Agreement timeline |
7f ✅ | Built: an ordered list of steps inside the agreed work (scope_versions.content.timeline). Each step is buyer-provides (an item number), payment (a stage number), review (words, optional due date), delivery (a stage number, optional words and date) or other (who does it, words, optional date). Every stage and item appears exactly once. Left out when absent, so earlier fingerprints never change; timelineOf implies one (buyer items, then each payment) for versions without it |
Agreement listing |
8e ✅ | Built: the product as listed (scope_versions.content.listing): the Channel3 facts (product id, page, domain, time read, title, brand, description, materials, features, attributes, weight, price, image addresses), photos (the saved files: id, fingerprint, name) and snapshotSha256 (the facts' fingerprint, checked on every save). Left out when absent, so earlier fingerprints never change; a change order cannot change it. The photos are source files named listing-photo-N.jpg (converted to JPEG, 8h) |
scope_drafts source fields |
7h, 7i ✅ | Built (migration 0036): chat_through_seq, the last in-app chat message a draft read (empty when it read none), and based_on_version, the version an update started from (empty for a draft from the whole conversation). The text a draft read is a source file of the deal named pactpal-chat-<time>.txt |
timeline_progress |
7k ✅ | Built (migration 0037): each update of a manual step (submitted, approved, changes or done), who did it, an optional note (its fingerprint in the log), the files with it (ids, names, fingerprints) and its log entry (STEP_SUBMITTED, STEP_APPROVED, STEP_CHANGES_REQUESTED or STEP_DONE). Append-only; read across signed versions, since a change order keeps step numbers. Payment and delivery steps are read from milestones, deliveries and delivery_answers |
timeline_reminders |
7l ✅ | Built (migration 0038): one row per step and day reminded (who it went to, soon or overdue, the REMINDER_SENT entry). The key (deal, step, day) means a step is reminded at most once a day. Append-only |
Planned
| Table | Sub-phase | What it will hold |
|---|---|---|
artifacts and their file contents |
2c | Uploaded files: name, type, size, SHA-256, who added it and the ledger entry that recorded it; the bytes in a separate table |
webhook_events |
2d | PayPal notifications as received, one row per event id, so repeats are skipped even after a restart |
link_tokens |
2e-2 | Single-purpose expiring links tied to one deal and one person, for buyers who do not sign in; only hashes are stored |
scope_versions |
2f-3 | The agreed work, each version kept and never edited, with its payment stages |