Skip to main content

Data model

ContentsVision stores everything in a single SQLite database (storage/inventory.db) through SQLAlchemy 2's async ORM. The models live in backend/app/models/. This page is the reference for every table and relationship.

Entity relationships​

Foreign keys vs implicit links

The parent-child links inside a claim (Room.claim_id, ProcessingJob.room_id, InventoryItem.job_id, ItemPhoto.item_id, StagedPhoto.job_id) are real SQLAlchemy ForeignKey columns. The ownership links team_id (on users and claims) and Claim.created_by_user_id are plain indexed strings, not database-level foreign keys. Team scoping is enforced in the application layer, not by the database.

Tables​

teams​

The organization. Auto-created on first login from the SSO token.

ColumnTypeNotes
idstring (UUID)Primary key.
created_atdatetime
external_idint, nullableThe AdjustSquare team id.
namestringTeam display name.
team_tokenstring, indexedThe team's Adjust Square API token, used as the bearer token when submitting claims.
credit_ratefloat, nullableDollars of revenue per credit, for the media-processing Slack notification. NULL (the default) means use the global DEFAULT_TEAM_CREDIT_RATE.

users​

A person. Auto-provisioned on first login.

ColumnTypeNotes
idstring (UUID)Primary key.
created_atdatetime
external_idint, nullable, unique, indexedThe AdjustSquare user id; becomes the adjuster id on submission.
emailstring, unique, indexed
namestring
team_idstring, indexedThe owning team's id.
is_activebool
last_seen_atdatetime, nullable, indexedWhen the user last made an authenticated request. Stamped by get_current_user (at most once a minute per user); powers the admin "Active Users (live)" stat.
timezonestringIANA zone picked on Settings > Profile ("" = browser local). Display preference only; storage stays UTC.

component_visibility​

Admin-controlled show/hide overrides for UI components (currently training). One row per override; resolution is team override > global override (team_id="") > the registry default in services/component_visibility.py (training defaults to HIDDEN). See GET /api/admin/components and the admin Teams tab.

ColumnTypeNotes
idstring (UUID)Primary key.
updated_atdatetime
componentstring, indexedKey from the component registry, e.g. training.
team_idstring, indexed"" = the global row; otherwise the team this override applies to.
visiblebool

claims​

The top-level unit of work.

ColumnTypeNotes
idstring (UUID)Primary key.
created_at / updated_atdatetimecreated_at indexed.
claim_numberstring, indexed
date_of_lossdate
zip_codestringThe loss site's US ZIP (10001) or Canadian postal code (M5V 3L9). One field for both: the two formats cannot be confused, so app/core/postal.py tells them apart, validates, and stores the canonical form. Sent onward as zip either way.
policy_limit / policy_deductiblefloat
insured_name / adjuster_namestring
loss_type / coverage_type / claim_typestringHuman-readable; mapped to Adjust Square codes on submit.
team_idstring, indexedOwning team.
is_trainingbool, indexedTraining sandbox flag: uploads complete instantly with fixture items (no AI, no credits) and submission is a dry run. One per user, auto-created by the Training hub. See training.
created_by_user_idstring, indexedThe users.id of the member who created the claim. That user's external_id is the Adjust Square user id.
adjust_square_claim_keystringSet after the first submission.
adjust_square_claim_idintThe integer claim id on Adjust Square.
submitted_atdatetime, nullable
is_archivedbool, indexedSoft-hide flag. Archived claims drop out of the default GET /api/claims list. Nothing is ever deleted by hand; archiving is the only removal path. A claim that has been submitted (submitted_at set) is locked and cannot be archived.
archived_atdatetime, nullableWhen the claim was archived (null when active). The daily purge permanently deletes anything archived more than 30 days ago; restoring clears this.

rooms​

A labeled bucket inside a claim.

ColumnTypeNotes
idstring (UUID)Primary key.
claim_idstring, FK to claims.id, indexed
created_at / updated_atdatetime
namestringe.g. "Master Bedroom".
is_archivedbool, indexedSoft-hide flag. Archived rooms drop out of the default room list and their items stop counting toward the claim. A room that contains any submitted item is locked and cannot be archived.
archived_atdatetime, nullableWhen the room was archived (null when active). Drives the 30-day purge.

processing_jobs​

One upload and its processing run.

ColumnTypeNotes
idstring (UUID)Primary key.
room_idstring, FK to rooms.id, indexed
source_typestringvideo, photo, or audio.
filename / file_pathstringThe original file (or photos dir).
file_size_bytesint
duration_secondsfloatDrives video/audio billing.
loss_type / claim_numberstringCopied from the claim at creation.
uploaded_bystringWho put this here, as a label to show: an adjuster's User.name, or the homeowner's email address when it arrived through their own link. Free text, because the two sources have nothing in common to key on. Blank on anything predating the column.
originstring, indexed"" (the adjuster's own upload), insured_list (a list the homeowner TYPED: no file, already complete, no cost), or insured_media. The UI branches on this rather than guessing from a filename.
statusstringpending, importing_pdf, awaiting_grouping, awaiting_confirmation, extracting, transcribing, segmenting, analyzing, synthesizing, complete, error.
progress_pctint0-100, streamed to the UI.
current_stagestringHuman-readable current step.
error_messagetextSet on failure.
total_frames / rooms_detected / items_foundintProcessing metadata.
transcript / transcript_segments / transcript_languagetext / JSON / stringTranscription output.
room_segmentsJSONTime-chunk ranges.
completed_atdatetime, nullable
billed_cost_usdfloatCredits charged (name kept for backward compatibility).
billed_minutesfloatWhole minutes billed (video/audio).
photo_request_countintAI calls made (photo jobs).
billing_chargedboolWhether credits were spent up front against the external billing service (only when ENABLE_BILLING). false otherwise.
billing_txn_idstringThe billing service's transaction id for the up-front spend, when one was made.
billing_amountfloatCredits spent up front, so a refund can return the exact amount.
billing_refundedbooltrue once the up-front charge was refunded because processing failed.
recovery_attemptsintHow many times the self-healing sweep requeued this job after an interruption (restart, crash, hang). One retry is allowed; a second interruption fails and refunds the job. See troubleshooting.
ai_cost_usdfloatOur internal OpenRouter spend in dollars (token counts priced at the model's per-million rates), copied from the in-memory observability counters when the job finishes. Separate from customer credit billing. Jobs predating this stay at 0.
ai_input_tokens / ai_output_tokens / ai_api_callsintThe token counts and AI call count behind ai_cost_usd.
pdf_template_labelstringThe recognised PDF layout, e.g. Encircle contents list. Not shown to the user; kept for the pipeline log and the support email when an import goes wrong. Blank for non-PDF jobs.
photo_truncated_groupsJSONPhoto groups whose answer ran out of room, so items in them were never read. One entry per group: {"group_index", "passes", "found": [names]}. passes counts the AI calls that group has had (capped at PHOTO_MAX_PASSES); found is what has been read out of it, sent back on the next pass so only what is missing comes back. Empty on nearly every job. See the AI pipeline.

job_log_archives​

The durable copy of a job's pipeline log lines. While a job runs its logs live in the in-memory ring buffer (see observability); when the job reaches complete or error the buffer is copied here so the detail survives restarts and stays readable on the Logs page. One row per job, upserted.

ColumnTypeNotes
job_idstring, FK to processing_jobs.idPrimary key.
created_atdatetime
logsJSONThe full list of structured log entries (ts, level, msg, stage, elapsed).

submissions​

One "Send for Estimation" event for a claim (a claim can be submitted several times; later submissions add the new items to the same Adjust Square claim).

ColumnTypeNotes
idstring (UUID)Primary key; shown as the submission id in the UI.
claim_idstring, indexed
created_atdatetime
items_count / photos_countintWhat the submission carried.
adjust_square_claim_keystringThe portal claim it landed on.
is_resubmitboolFalse for the claim-creating first submission.
is_dry_runboolTraining claims: recorded, sent nowhere.
statusstringprocessing while background item/photo uploads run, then complete.
processing_secondsfloatEnd to end: request start to background finish.

api_request_logs​

One outbound AI API call (OpenRouter), for the admin "API Requests" inspector on the Logs page. The request JSON is stored with base64 images redacted to size placeholders; the response is stored in full.

ColumnTypeNotes
idstring (UUID)Primary key.
created_atdatetime, indexed
job_idstring, indexedDerived from the per-job pipeline logger.
service / method / url / modelstringopenrouter, POST, the endpoint, and the model id.
status_code / duration_ms / attemptsint / float / intFinal outcome and total time across retries.
input_tokens / output_tokens / cost_usdint / int / floatFrom the response usage block (OpenRouter reports real cost).
request_json / response_jsonJSONRedacted request; full response.
errortextSet when the call ultimately failed.

inventory_items​

A single line of the inventory.

ColumnTypeNotes
idstring (UUID)Primary key.
job_idstring, FK to processing_jobs.id, indexed
source_typestringCopied from the job.
uploaded_bystringCopied from the job for the same reason source_type is. An item an adjuster types by hand carries THEIR name, even when the job around it came from the homeowner.
item_namestringSearchable retail-style name.
quantityintIdentical items are grouped.
roomstringRoom name (denormalized for convenience).
conditionstringNew / Above Average / Average / Below Average.
age_years / age_monthsintSet by the AI only from a spoken age (video narration, audio). Always 0 from photos, and never estimated from appearance.
notestext
brand / model_number / serial_number / skustring
damage_typestringsmoke / water / heat / impact / mold.
estimated_valuefloat
confidencefloat0.0-1.0.
audio_evidence / visual_evidencetextWhat was said / what was seen.
audio_start_seconds / audio_end_secondsfloatThe recording slice (audio items).
photo_path / crop_photo_path / photo_context_path / source_photo_pathstringLegacy photo columns, kept as a safety net.
photo_frame_timefloatTimestamp in the video.
photo_framestext (JSON)All analyzed frame URLs.
reviewed / flagged / approvedboolHuman review state.
is_archivedbool, indexedSoft-hide flag. Archived items drop out of the inventory lists, counts, exports, and Adjust Square submission, but keep their item_number (no renumbering on archive) so unarchiving restores the exact slot. Submitted items cannot be archived. Archiving is the only way to remove an item; there is no manual delete.
archived_atdatetime, nullableWhen the item was archived (null when active). Drives the 30-day purge.
sort_orderint
item_numberint, indexedUnique per claim.
submittedbool, indexedSent to Adjust Square.
adjust_square_item_idintRemote item id after submission.
photosrelationshipThe item_photos rows (source of truth for images).

item_photos​

Photos attached to a finished item. The source of truth for item imagery (the legacy photo_path columns on the item are kept for compatibility).

ColumnTypeNotes
idstring (UUID)Primary key.
item_idstring, FK to inventory_items.id, CASCADE delete, indexed
path / crop_path / context_pathstring
is_primaryboolThe primary thumbnail. Setting it via PUT /api/inventory/item/{id}/photos/{photo_id} demotes every other photo on the item AND copies the photo onto InventoryItem.photo_path, which is what the inventory table and the export actually read. ItemPhotosModal is the user-facing way to set it.
photo_orderint"order" is reserved in SQL, hence the name.

staged_photos​

Raw uploaded photos waiting to be grouped before AI runs.

ColumnTypeNotes
idstring (UUID)Primary key.
job_idstring, FK to processing_jobs.id, CASCADE delete, indexed
filenamestringFile on disk under <storage>/<job_id>/photos/ (a synthetic orig_####.jpg sequence, or pdf_####.jpg for a photo pulled out of a PDF, so the two can never overwrite each other).
original_filenamestringThe photo's real upload name (e.g. IMG_1234.jpg). The on-disk filename is synthetic, so this is the only record of the real name; grouping's "Sort by name" re-orders on it. Blank for manual segments and for rows created before the column existed.
captured_atstringWhen the photo was taken (YYYY-MM-DDTHH:MM:SS), read from EXIF in the browser at upload (the re-encode strips EXIF, so it must be captured before then); falls back to the file's modified time. Powers grouping's "Sort by date". Blank when unknown and for manual segments.
group_indexint, indexedPhotos sharing a group become one item. Manual segments use a high range (>= 1_000_000) so they never collide with photo groups.
photo_orderint
deletedbool, indexedSoft-delete; file removed on Process.
is_segmentboolTrue for a crop the user drew from a parent photo during grouping (a known item the AI never re-splits).
quantityintUser-set quantity for a described or segment item (e.g. 4 for four boxed rolls of tape). 1 by default.
manual_namestringUser-typed name for the item (a whole photo / merged group, or a segment). When set, the AI is skipped for it (no request, no credits). Blank means the AI names it.
source_filenamestringThe whole photo a segment was cropped from, so "clear segments" can restore it.
bboxstringJSON [x, y, w, h] (normalized) of the box on the source photo, kept for re-editing.
source_descriptiontextFor a photo imported from a PDF: the note printed beside it, lifted verbatim off the page (e.g. Charred barber products). Kept exactly as printed, however poor, because it is the source record. It is never the item's name: it is passed to the AI as a hint when the photo is analyzed, and saved onto the finished item's notes. Blank otherwise.
source_item_keystringThe vendor's own label on the page (Item 42). Photos printed under ONE label are one item, so they are staged sharing a group_index and arrive already merged. Blank otherwise.

pdf_templates​

One learned recipe for reading a vendor's PDF page layout, so the AI is asked once per FORMAT rather than once per file. Deliberately global, not team-scoped: these rows hold layout rules only (coordinates, regexes), never claim content, so the first upload of a new format teaches the whole app. signature_json keeps the page furniture lines the fingerprint was built from, so a vendor that prints the insured's name in that furniture (which changes the hash per claim) is still recognised by a near match rather than re-learned. See PDF import.

ColumnTypeNotes
idstring (UUID)Primary key.
fingerprintstring, unique, indexedHash of the layout signature: the lines repeating on most pages with digits masked, plus page size and producing application. Stable across two exports from the same tool, distinct between vendors.
labelstringThe model's human name for the format, e.g. Encircle contents list. Shown to the user.
recipe_jsontextThe recipe. Always re-validated through Recipe.from_dict() before use: a stored recipe is data, never code, and a bad one degrades rather than raising.
sourcestringai when the model wrote it, builtin for one we ship.
created_by_team_idstringThe team whose upload first taught us this format. Kept for support; not used to scope lookups.
use_count / last_photo_count / last_used_atint / int / datetimeHow often the recipe has been reused and how well it did last time. A recipe that keeps finding nothing is the signal to re-derive it.

activity_log​

One row per meaningful action, powering the Settings activity log and its Restore button. Covered in observability.

ColumnTypeNotes
idstring (UUID)Primary key.
created_atdatetime, indexed
user_id / user_namestringWho did it.
action_typestringMachine code the restore logic dispatches on.
action_label / targetstringHuman-readable text.
entity_type / entity_idstringWhat was acted on.
snapshotJSON, nullableEverything needed to undo the action.
restorable / restored / restored_atbool / bool / datetimeRestore state.

training_progress​

One row per user per training mission; the source of truth for points and badges (mission complete = badge earned). See training.

ColumnTypeNotes
idstring (UUID)Primary key.
created_at / updated_atdatetime
user_id / team_idstring, indexedWho is training, and their team (for the leaderboard).
mission_keystring, indexedCurriculum mission key, e.g. messy-claim.
statusstringin_progress or complete.
quiz_score / quiz_total / quiz_attemptsintBest quiz result and attempt count.
checks_passedJSONCheck keys that passed on the latest grading run.
check_attemptsint
pointsintBanked once at completion: mission base + quiz bonus.
completed_atdatetime, nullable

training_certificates​

Issued certifications, publicly verifiable by code. See training.

ColumnTypeNotes
idstring (UUID)Primary key.
codestring, unique, indexedShareable verification code, e.g. CV-3F9A-C24D-71BE.
user_id / team_idstring, indexed
holder_name / team_namestringSnapshotted at issue time so the public page stays stable.
levelstringCertified Contents Specialist.
score_pct / pointsintOverall quiz accuracy and total points at issue time.
issued_atdatetime
revokedboolInvalidates the certificate while keeping the code resolvable.

One invitation sent to the INSURED (the homeowner), letting them build their own inventory for exactly one claim through a public link. See Insured inventory link.

ColumnTypeNotes
idstring (UUID)Primary key.
claim_idstring, FK to claims.id, CASCADE delete, indexedThe one claim this link can reach. Checked on every public request.
team_id / created_by_user_idstring, indexedDenormalised from the claim so the adjuster-side ownership check is one query.
token_hashstring, unique, indexedSHA-256 of the raw token. The raw token is never stored, so a database leak yields no working links. It exists only in the emailed URL.
insured_emailstring, indexedThe address the adjuster sent it to.
insured_namestringWhat the person typed on arrival. Neither this nor the email is a gate: whoever holds the link may use it. Both exist so the record shows who entered the inventory.
expires_atdatetime, indexedINSURED_LINK_TTL_DAYS from creation (30 by default).
revoked_atdatetime, nullableManual kill switch. Bites on the holder's next request.
email_statusstringFurthest state Mailgun reported: "", not_configured, failed, sent, delivered, opened, clicked.
email_message_idstring, indexedMailgun's message id.
email_errortextWhy a send or delivery failed, shown to the adjuster.
email_sent_at / email_delivered_at / email_opened_at / email_clicked_atdatetime, nullableFrom the Mailgun webhook. An email "open" fires on a tracking pixel that Apple Mail loads regardless of whether a human looked, so treat it as weak evidence.
send_countintHow many times it was sent. A resend rotates token_hash, so the earlier email's URL stops working.
link_first_opened_at / link_last_opened_at / open_countdatetime, intMeasured on our own server when the page is fetched. This is the signal worth trusting, unlike an email open.
started_atdatetime, nullableFirst time they entered anything.
submitted_atdatetime, nullableWhen they pressed Submit. A submitted link becomes read-only.
submitted_item_count / submitted_photo_count / submitted_video_countintWhat the submit produced.
uploaded_bytesintLifetime total across every request, enforced against INSURED_LINK_MAX_TOTAL_MB. Deleting an upload does not credit it back, so upload-then-delete cannot push unlimited bytes.

status is a derived property (revoked / submitted / expired / in_progress / opened / sent), not a column.

insured_draft_rooms​

A room the HOMEOWNER added, before submit. The adjuster sets up the rooms they know about; the homeowner knows their own home and can add the loft, the shed, the spare room nobody mentioned.

These are drafts rather than real rooms because adding one is a single tap and abandoning it costs nothing, so creating a Room on the spot would fill the adjuster's claim with empty rooms somebody typed and thought better of. A drafted room reaches the claim at the same moment its contents do: on submit, and only if it has contents to bring.

ColumnTypeNotes
idstring (UUID)Primary key. Draft rows point at this in their room_id until submit.
link_idstring, FK to insured_links.id, CASCADE delete, indexed
namestringMatched case-insensitively against the claim's rooms at submit, so a "Garage" the adjuster made meanwhile is adopted rather than duplicated.
positionintOrder the homeowner added them in.
room_idstring, indexedThe rooms.id it became. Blank while still a draft. A materialised draft room is never listed again: the real room is on the claim by then.

insured_draft_items​

One typed row in the homeowner's table, before submit. Every column maps onto an InventoryItem field, which is where the row lands.

ColumnTypeNotes
idstring (UUID)Primary key.
link_idstring, FK to insured_links.id, CASCADE delete, indexed
room_idstring, indexedEither a real rooms.id (a room the adjuster set up) or an insured_draft_rooms.id (one the homeowner added). Submit resolves the second into the first and rewrites this column.
positionintOrder within the room; drives the "Item No." the homeowner sees. The real claim-unique item_number is assigned at submit.
item_namestringTitle. The only required text field, and only at submit.
quantityintPositive integer, minimum 1.
brand / model_number / notesstring / textOptional.
estimated_valuefloatNon-negative.
age_years / age_monthsintNon-negative. Months of 12 or more are folded into years (18 months becomes 1 year 6 months).

insured_draft_files​

One photo or video the homeowner uploaded, before submit. The bytes are written to <storage>/insured/<link_id>/ on arrival, so a flaky connection never costs a re-upload; only the row that becomes a StagedPhoto waits for submit.

ColumnTypeNotes
idstring (UUID)Primary key.
link_idstring, FK to insured_links.id, CASCADE delete, indexed
room_idstring, indexed
draft_item_idstring, indexedSet only for kind = "item_photo": the typed row it belongs to.
kindstringroom_photo, room_video, or item_photo.
filenamestringSynthetic name on disk. Photos are re-encoded to JPEG right after the upload replies (a background task; see batched photo upload), which strips EXIF (so a phone photo never carries the homeowner's GPS location onto the claim) after baking the EXIF orientation into the pixels (upright_rgb in services/image_encode.py), so a portrait phone photo stays upright without the tag.
original_filenamestringWhat their phone called it.
content_type / size_bytesstring / int
notetextThe homeowner's note about this one file ("the TV is behind the boxes on the left"). At submit it becomes StagedPhoto.source_description and reaches the AI through the same path as a PDF caption. For a video it becomes ProcessingJob.notes instead.
positionint

How the schema is created and migrated​

There is no migration tool (no Alembic). On startup, core/database.py's init_db():

  1. Calls Base.metadata.create_all to create any missing tables.
  2. Runs a list of idempotent ALTER TABLE ... ADD COLUMN statements, each wrapped in a try/except so an already-existing column is silently skipped. This is how new columns are added to existing databases.
  3. Runs a few one-time backfills (also guarded): assigning sequential item_numbers to old items per claim, repairing crop_photo_path for old photo items, and seeding item_photos rows for items that predate the multi-photo model.
Adding a column

To add a column: add it to the model and append an ALTER TABLE ... ADD COLUMN line in init_db(). create_all only creates whole new tables; it never alters an existing one, so without the ALTER line the column exists in the model but not in an already-deployed database.