Contents Estimation integration
Contents Estimation is the clean-stack rebuild of Adjust Square (the AI contents estimation / claims platform for public adjusters). ContentsVision can push a finished claim inventory into it, which creates the claim and its items there and starts pricing. This is handled by the send endpoint (POST /api/claims/{id}/submit-to-contents-estimation) and a thin API client in backend/app/services/contents_estimation.py.
It is a separate, independent action from the Adjust Square submission: sending to Contents Estimation neither reads nor changes the per-item submitted locks or the Adjust Square claim state, so the same claim can be sent to either platform (or both) without one interfering with the other.
What sending does
A send takes the items the user ticked (everything not yet sent, by default) and either creates a claim on Contents Estimation or adds them to a claim that already exists there. Which one happens is decided by the server, not guessed by the caller:
| State | What the send does |
|---|---|
The claim is already linked (it has a contents_estimation_claim_key) | Appends to the linked claim. target_claim_key in the request is ignored: a link is a fact once made, and re-pointing it would strand every item already sent. |
The request names a target_claim_key (the user picked one of their firm's claims) | Appends to that claim and stores the link. |
| Neither | Creates a claim there, as it always did. |
Only items with no contents_estimation_sent_at ever cross, whatever the caller asks for: Contents Estimation skips an item it already holds, and charging for it again would be wrong.
The modal carries a pricing selection (a depreciation schedule and a shopping profile, see below) read from Contents Estimation when it opens. That selection only travels on the send that creates the claim; a claim that already exists over there owns its own configuration.
Choosing the destination and the items (the modal)
The send modal asks two things before it sends:
- Where should this go? Two cards, "A new claim" (the default) and "An existing claim". The team's Contents Estimation claims are read through
GET /api/claims/{id}/contents-estimation-claims(proxied to theirGET /intake/claims) as soon as the modal opens, not on first click, so the second card is honest from the start: it used to look available until you clicked it and only then grey itself out. Picking it opens a search box and lists the claims by claim number, policyholder and key. When the answer comes backavailable: falsethe card is disabled, a line under the cards says why, and the choice resets to "A new claim" so the send button can never sit dead on a disabled option. A claim that is already linked skips this question entirely and says which claim it is linked to. - Items to send, from
GET /api/claims/{id}/contents-estimation-items. Every active item is listed with whether it has already gone; everything unsent starts ticked, with Select all and Clear, and a sent item can never be ticked. The credit cost re-prices live as the ticks change.
Sending more items later
This is the point of the whole arrangement: a linked claim keeps its send button (now reading Add items to ContentsEstimation) rather than being replaced by the view link, so items captured after the first send reach the same claim. Contents Estimation dedupes per item, so nothing can be delivered twice even if the whole inventory is sent again.
Payment (charged at send)
When prepaid billing is on, the send captures payment in ContentsVision, mirroring the upload gate: the team is charged cost_per_contents_estimation_item credits (default 1) per item actually being sent. The charge runs before the intake call (billing.charge_credits) and doubles as the affordability gate: a flat refusal returns HTTP 402 and nothing is sent. If the send then fails, the charge is refunded best-effort (billing.refund_credits). Contents Estimation does not re-bill the shared credit pool.
An item is never charged for twice. Items already stamped contents_estimation_sent_at are not in the batch, so a later send pays only for what it adds. The old rule ("a re-send is free") became "the items already sent are free", which is the same thing on a first send and the honest thing on a later one.
The charge reference is what stops a retry paying twice, and it differs by case:
| Case | Reference |
|---|---|
| First send (nothing linked yet) | ce-send:{claim_id}. Unchanged, so a claim charged before this feature is still recognised. |
| A later send into a linked claim | ce-add:{claim_id}:{digest}, where the digest is a short, order-independent fingerprint of exactly which item ids the batch carries. Re-sending the same batch after a timeout is free; a genuinely new batch is charged. |
A send with nothing new to deliver is a 400, not a free no-op charge. The confirm modal prices the send up front via GET /api/claims/{id}/contents-estimation-quote (which reports unsent_item_count and credit_cost_per_item so the modal can re-price as items are ticked); the submit response returns credits_charged. With billing off (the default), sending charges nothing.
Which teams can send, and the button
The claim page's send buttons are gated by two per-team feature flags delivered on /api/auth/me (features.adjust_square, features.contents_estimation), toggled from the admin Teams tab. Adjust Square is off by default (opt-in per team); Contents Estimation is on by default. With one integration enabled the page shows a single Send button; with both, a Send to dropdown (SendToMenu). After a send, the status badge deep-links into the assignment (Adjust Square {app_url}/lkq/{claim_key}, Contents Estimation {app_url}/claims/{claim_uuid} — see ADJUST_SQUARE_APP_URL / CONTENTS_ESTIMATION_APP_URL).
Once a claim is linked to a Contents Estimation claim it also gets a View in Contents Estimation button linking to {app_url}/claims/{claim_uuid} (requires the stored contents_estimation_claim_id and a configured app URL), and its send button changes wording to Add items to ContentsEstimation. The send option deliberately does not disappear any more: a linked claim can keep receiving the items captured since the last send. Whether anything is actually left to send is answered inside the modal, which lists the items and says so plainly, rather than by a second request on every claim page just to grey out a button.
Idempotency: two keys, two levels
The two calls dedupe differently, and the difference is the whole design:
- Create (
POST /intake/contentsvision) is idempotent on the whole payload, keyed byexternal_reference(this claim's id). A retry returns the same claim withcreated: falseand adds nothing. That is what makes it a one-shot. - Append (
POST /intake/contentsvision/{claim_key}/items) is idempotent per item, on each item'sexternal_id(ourInventoryItemid). An item they already hold is skipped and counted inskipped; anything new lands. That is what lets one claim keep receiving items for as long as the adjuster keeps capturing them.
Because a retry is always safe either way, the send runs synchronously (one fast call) and stores the returned claim_key / claim_id on the claim.
If a create comes back created: false, Contents Estimation already had a claim under this reference and our items did not land. The send follows up with an append to the claim they named, which is exactly what a user linking to that claim would have done. Without that step the send would report success over a push that delivered nothing.
external_reference is the only thing preventing duplicatesNever shorten, hash or otherwise change what goes into external_reference. Every claim already sent is matched on the full ContentsVision claim id, so a different value stops matching and the next re-send of an existing claim creates a second claim on the Contents Estimation side. It is a matching key, not a display value, and it is deliberately not the thing a human reads.
Items freeze once they are sent, one item at a time
A sent item is theirs now: Contents Estimation takes new items on a later send but never re-reads one it already holds, so an edit made here after that item went can never reach them, and the two systems would diverge silently while their users price different versions of it. So a send freezes the items it carried, and only those.
The rule has two halves, and both must be true to refuse an edit:
Claim.items_locked(a property on the model, so everyClaimResponsecarries it without a call site having to remember). True when the claim has acontents_estimation_claim_idorcontents_estimation_submitted_at, with two carve-outs:- A training claim is a dry run that never leaves this app. Its fake
TRAINING-DRY-RUN-...key must not freeze the sandbox the training program depends on. - An admin unlock (
contents_estimation_items_unlocked) re-opens a claim sent by mistake, and it stays open until an admin re-locks it.
- A training claim is a dry run that never leaves this app. Its fake
InventoryItem.contents_estimation_sent_atis set on the item itself.
The second half is what makes "add more items later" usable: an item added this morning has been sent to nobody, so freezing it would protect nothing and would stop the adjuster fixing a typo before it ever goes.
Their items have no stamp, and the new code reads "no stamp" as "not sent yet". A one-time data migration (ce_item_sent_at_backfill, run through _run_once in core/database.py) stamps them with the claim's own contents_estimation_submitted_at, guarded by created_at <= that time so an item added after the send keeps its NULL and stays sendable. Without it, the next send would offer an already-sent inventory again and charge for all of it a second time.
What is refused, and what is not
| Action | On an item that has been SENT | Why |
|---|---|---|
| Edit an item's fields (single or bulk) | 409 | Content we already sent. |
| Delete or archive an item | 409 | A delete as far as Contents Estimation is concerned. |
| Merge items | 409 | The worst case: a merge deletes the losing items, and their ids are the external_id Contents Estimation matches on. |
| Add, remove, reorder or re-primary a photo | 409 | Photos are part of the item, and the payload named them. |
| Anything at all on an item that has NOT been sent | Allowed | It has been sent to nobody. It is editable, deletable and mergeable exactly as on an unsent claim, and stays that way until it goes. |
| Add a new item, room or photo-upload | Allowed | New work is sendable now: a later send adds it to the same Contents Estimation claim. |
| Approve or flag an item | Allowed | Review state, never part of the intake payload. Freezing it would protect nothing and would break the Adjust Square send, which can be filtered to approved items only. |
Enforcement is in core/ownership.py: load_item_for_edit replaces load_item_for_team in every item-mutating endpoint, and assert_items_editable covers the bulk endpoint, checking per item (a batch is refused when it names a sent item, and applied when it does not). The frontend disables the matching controls per row from the item's own contents_estimation_sent_at and shows a banner above the inventory, but the server is the authority: the API refuses the write either way.
The admin unlock
POST /api/claims/{id}/contents-estimation-item-lock with {"unlocked": true|false}, gated by require_admin, writes an unlock_contents_estimation_items / relock_contents_estimation_items activity entry and logs a warning. It is admin-only deliberately: if any user could unlock, the lock would be a suggestion rather than a rule. The claim page shows an Unlock items / Re-lock items button to admins only.
This used to say "revisit when their merge ships". It has: the append endpoint adds items they have not seen, keyed on our per-item external_id, which is why the lock is now per item rather than per claim. What has not changed is that they never re-read an item they already hold, so a sent item stays frozen. The external_id we send is the InventoryItem primary key, a uuid, stable for the life of an item, with the known exceptions of a merge (the loser is deleted) and a re-run after an interrupted job (items are recreated with new ids). Adding photos to an item they already hold is still not possible.
The claim key is Contents Estimation's, not ours
claim_key is the human-facing reference Contents Estimation generates at intake and returns in the response. We do not generate it, and we must not parse it. ContentsVision stores whatever string comes back (Claim.contents_estimation_claim_key) and renders it verbatim on the claim page.
The format is therefore theirs to change, and it has: older Contents Estimation builds returned cv-<external_reference> (roughly 39 characters, since our claim id is a uuid), and newer ones return a short key. Both shapes exist in the column, and nothing on this side depends on either:
- The column is an unbounded
String, so a key of any length stores. - The claim page prints the key as text; it does not truncate, pad or validate it.
- The deep link into a sent claim uses
contents_estimation_claim_id(the remote uuid), not the key, so a key format change cannot break it.
A short key must be generated on the Contents Estimation side, because only that side can check a candidate against its own claims for collisions: this app has no endpoint to ask whether a key is already taken, so a key generated here could only ever be probabilistically unique. Shortening external_reference is not a substitute (see the warning above).
The Contents Estimation endpoints used
| ContentsVision client function | Contents Estimation endpoint | Used for |
|---|---|---|
list_catalog | GET /intake/catalog | The team's depreciation schedules, each with its shopping profiles nested, for the send modal's two dropdowns. |
submit_intake | POST /intake/contentsvision | Create a claim + rooms + items from a whole inventory, and start pricing. |
list_claims | GET /intake/claims | The team's claims on Contents Estimation, for the "add to an existing claim" picker. Searchable (q), archived claims excluded. |
append_items | POST /intake/contentsvision/{claim_key}/items | Add items to a claim that already exists there. Returns added / skipped / item_count. |
The response (IntakeResult) carries claim_id (the remote claim uuid), claim_key (Contents Estimation's own human-facing key, in their format), status (intake), item_count, and created (true on first ingest, false on an idempotent replay).
Both endpoints take the same X-API-Key service key: there is deliberately no separate read scope, so nothing needs reissuing to read the catalog. Both reject a human user session with a 403 (they are machine-only), which is why the catalog is proxied through our own route rather than called from the browser.
Pricing selection (depreciation schedule + shopping profile)
The send modal offers two dropdowns, populated by one call to GET /intake/catalog when it opens:
- Depreciation Schedule sets how much value an item loses for its age and condition.
- Shopping Profile sets which stores items are priced against.
A shopping profile belongs to exactly one depreciation schedule (a required foreign key on that side, never shared, with no global profile), so the catalog returns the profiles nested inside their schedule and one read fills both dropdowns. Changing the schedule therefore clears and re-defaults the profile: leaving a stale profile selected would send a pair Contents Estimation rejects. Doing the reset in the UI makes an invalid pair impossible to select, so the pairing is never validated on this side.
A schedule with zero shopping profiles is a normal, selectable state (the second dropdown renders disabled with a "No shopping profiles for this schedule" placeholder), not an error and never a blocked send. Schedules and profiles have no archived state upstream, so everything returned is offered.
The two ids ride the intake body as top-level uuid strings (depreciation_schedule_id, shopping_profile_id), following the same include-only-when-valid rule as the enum fields: a blank choice is omitted, never sent as null.
shopping_profile_idThe two fields do not behave symmetrically when omitted:
- Omitting
depreciation_schedule_idfalls back to the team's default schedule. - Omitting
shopping_profile_idapplies no supplier steering at all. There is deliberately no fallback to the team's default profile, because that profile is tied to the team's default schedule and auto-filling it under a different schedule would create the invalid pairing the intake otherwise rejects.
So a claim sent without a profile prices as though the team had configured nothing: a store they excluded is not excluded. The modal therefore pre-selects the chosen schedule's is_default profile (falling back to its first) and always sends it when the schedule has any.
Because a replay does not re-apply the payload (see Idempotent, one-shot), the selection only takes effect on the send that creates the claim. A re-send silently ignores both ids, so the modal shows the dropdowns disabled once a claim is linked, with a note that the options must be changed inside Contents Estimation.
Field mapping
ContentsVision stores human-readable claim values and maps them to Contents Estimation's lowercase enums in claims.py before sending. Contents Estimation validates these strictly, so an unmapped value is omitted, never sent raw:
| ContentsVision value | Intake field | Mapping |
|---|---|---|
loss_type (e.g. "Fire", "Flood / Water Damage") | peril | _PERIL_MAP then guarded against _CE_PERILS; unmapped is omitted. |
coverage_type ("ACV" / "RCV") | coverage_type | _CE_COVERAGE_MAP (ACV → acv, RCV → rcv); other values omitted. |
claim_type ("Residential" / "Commercial") | claim_type | _CE_CLAIM_TYPE_MAP (lowercased); other values omitted. |
claim.id | external_reference | The idempotency key. |
| Depreciation Schedule dropdown | depreciation_schedule_id | Uuid string. Omitted when nothing is chosen (falls back to the team default). |
| Shopping Profile dropdown | shopping_profile_id | Uuid string. Omitted when nothing is chosen, which applies no supplier steering (there is no default fallback). |
claim_number | metadata.contentsvision_claim_number | Kept in metadata, so the human claim number survives the hand-off whatever claim_key happens to look like. |
zip_code | loss_address.zip | A Canadian claim's postal code travels in this same field. |
insured_name | policyholder.name | |
created_by_user_id (the claim's creator), else the user pressing Send | created_by (user_id + email) | Who the claim starts assigned to over there. The push authenticates as the team's key, and a key is not a person, so before this every ContentsVision claim arrived with no adjuster (a dash in their Dashboard). _claim_creator_for_ce loads the creator's User row (falling back to the sender when the creator is unknown or deleted) and _ce_identity shapes it: external_id goes as user_id only when it is a UUID (a Contents Estimation SSO sign-in), because a legacy integer id would 422 the push; email always goes. Contents Estimation matches the id, then the email, against active members of the claim's own team and leaves the claim unassigned on a miss, so a stale identity can never assign the wrong person. Create only: an append never carries it (their side never changes an adjuster on append). |
adjuster_name | metadata.adjuster_name | Free text typed on the claim, kept as before. It is not an identity and assigns nobody. |
Each item maps its title, quantity, brand, sku, condition, age, notes (as description), room, and estimated price. external_id carries the ContentsVision item id for traceability, and model/serial/damage/item-number extras (with no first-class intake field) are preserved under the item's details.
Photos travel as URLs
The intake takes photos as URLs, not bytes (unlike Adjust Square's per-photo byte uploads). Every photo on an item is sent, not just its primary: _ce_item_photos walks item.photos primary-first and emits each as {url, kind}, falling back to the legacy single photo_path when an item has no ItemPhoto rows. Each path is made absolute by prefixing the stored /api/photos/... path with PUBLIC_BASE_URL (a public, unauthenticated route). The primary photo (first) also becomes the item's image_url.
PUBLIC_BASE_URL sends every claim with NO photosWhen PUBLIC_BASE_URL is empty there is no absolute URL to build, so photos are silently omitted and the inventory still sends successfully. This is intentional for local dev (localhost is unreachable from the other side) but it is indistinguishable from a healthy send in production, and it is the first thing to check when photos "are not being sent".
Two signals exist for it:
GET /api/auth/debugreportscontents_estimation.public_base_urlandphotos_will_be_sent.- A send whose items have photos but which carried none logs a
WARNINGnamingPUBLIC_BASE_URL, and its response'sphotos_includedis0.
The value must be the externally reachable origin (https://contentsvision.com), because Contents Estimation fetches these URLs itself. See the troubleshooting entry.
Authentication: per-team, and per-team only
Auth is a per-team machine-to-machine bearer token, sent in the X-API-Key header (Contents Estimation accepts the same value as Authorization: Bearer). It lives on Team.contents_estimation_key and is resolved per request from the signed-in user's team (_resolve_ce_key).
The token is the firm's identity. ContentsVision sends no team id and Contents Estimation accepts none, so whichever token authenticates the call decides whose account the claim lands in. Everything below follows from that one fact.
- There is no instance-wide fallback key. This is the important rule, and it is deliberate. A shared key does not degrade gracefully: it does not send "anonymously", it files one firm's claim under whichever firm owns that key. That is not a theoretical risk, it is what happened in production, where a customer's claim appeared under "* Adjust Square" during a demo. A team with no key of its own gets a
409and sends nothing. - Instance config is only
CONTENTS_ESTIMATION_BASE_URL(one host per deployment). Prod ishttps://api.contentsestimation.com. It is the "configured" gate, but on its own it is not enough to send:ready_to_submitalso requires the team's key. - The catalog read resolves the key exactly like the push (
_ce_key_or_nonemirrors_resolve_ce_key), unlike the Adjust Square profiles route, which is team-token-only. This is deliberate: schedule and profile ids only exist inside the tenant their key belongs to, so reading with one key and pushing with another would offer ids the intake then422s. Where the read lands is exactly where the write lands, and a test pins that invariant. Keep the two in step. The one difference is that a missing key is not an error on the read: the catalog returns an empty list so the modal still opens, and the user is told at the send, where the409is. - A
401from either call means that firm is not provisioned on the Contents Estimation side (or their copy of the token differs from ours). That is a setup problem, not an outage, so both routes surface it as a409naming the problem rather than a generic502. It is deliberately not re-emitted as a401, because the frontend signs the user out on any401(lib/session.ts).
Every future change here must keep "no key means no send". If a team cannot send, the fix is to provision that firm on the Contents Estimation side and seed its token, never to let it borrow one. test_submit_when_team_has_no_key_409_and_never_sends exists to catch this.
core/auth_deps.py overwrites Team.contents_estimation_key from the AdjustSquare SSO JWT's current_team_ce_intake_key claim, but only when that claim is non-empty. The claim is empty today, so the seeded tokens are safe. If the SSO issuer ever ships that claim with different values, it will silently replace the seeded ones and the two systems will disagree about which token means which firm.
Seeding the per-team tokens
The tokens are not invented here: they are the same values the Contents Estimation side holds, so both systems agree on which token means which firm. They originate in the old Forge system (teams.token) and are seeded from its CSV export by backend/scripts/seed_team_ce_tokens.py, matched on Team.external_id == the CSV's id. Teams that had no Forge token were given a freshly generated one, written back into the same CSV, and the Contents Estimation team seeded the identical file on their side.
The script writes only Team.contents_estimation_key. It never touches Team.team_token (the Adjust Square bearer token, managed by SSO login), it is a dry run unless given --commit, and it only fills a key that is currently empty unless given --force.
# Production runs inside Docker, so copy the CSV in first.
docker compose -f docker-compose.prod.yml cp forge.csv backend:/tmp/forge.csv
docker compose -f docker-compose.prod.yml exec backend \
python scripts/seed_team_ce_tokens.py --csv /tmp/forge.csv # dry run
docker compose -f docker-compose.prod.yml exec backend \
python scripts/seed_team_ce_tokens.py --csv /tmp/forge.csv --commit
Back up the database file before the --commit run. A team whose token is missing on the Contents Estimation side authenticates as nobody and gets a 401; report that firm's name to them rather than re-seeding.
Sending a training claim is a dry run: it records an obviously-fake claim key (TRAINING-DRY-RUN-...) and never contacts Contents Estimation, so training needs no configured integration and spends nothing (mirroring the Adjust Square training dry run).
Error handling
Every client call converts failures into ContentsEstimationError, which carries the HTTP status and response body. Network failures (timeout, reset, DNS) are caught and reported as "unreachable" rather than bubbling up as an opaque 500.
| Situation | Result |
|---|---|
| The instance has no base URL | Send endpoint returns 409 before any network call. |
| The team has no intake key of its own | Send endpoint returns 409 before any network call. There is no fallback to borrow, by design. |
Contents Estimation rejects the key with a 401 | Both the send and the catalog read return 409 naming the real cause: that firm is not provisioned on their side. Not re-emitted as a 401, which the frontend treats as the user's own session expiring. |
| The claim has no active items, or every ticked item has already been sent | Send endpoint returns 400. |
The picked claim is gone, or belongs to another firm (their 404 on an append) | The route returns 409 naming it, and nothing is stamped, so the user can pick another and try again. On a FIRST link the same 404 is read as a Contents Estimation running a version from before the append endpoint, and says so. That copy deliberately does not describe it as a per-firm entitlement ("not switched on for your firm", the original wording): no account setting controls it, so that reading only sent people to support over a deployment state that clears itself. |
The claims picker cannot be reached (no team key, no base URL, or their 404) | GET .../contents-estimation-claims returns available: false with an empty list, and the modal offers creating a new claim. A 401 is still the 409 "not provisioned" message. |
Contents Estimation rejects the payload with a 422 (a bad enum, or a schedule/profile id that is unknown, belongs to another team, or is not bound to the chosen schedule) | The route returns 422, passing Contents Estimation's own readable detail straight through so the modal can ask the user to re-pick. Nothing is created on that side, so re-sending after a correction is safe; the modal re-reads the catalog and refreshes both dropdowns. |
| Contents Estimation rejects the payload with any other non-2xx | ContentsEstimationError with status + body; the route returns 502. |
The catalog read fails (outage, 403 not a machine credential) | The profiles route returns 502, rather than an empty list that would look like a team with no schedules. A 401 is the exception, see above. |
The external_reference is already used by another tenant | The intake returns 409; surfaced as a 502 on this side. |
Where it lives
| Layer | Location |
|---|---|
| API client | backend/app/services/contents_estimation.py |
| Send endpoint + payload builders | backend/app/api/routes/claims.py (submit_claim_to_contents_estimation, _build_contents_estimation_payload, _append_body, _items_to_send, _stamp_items_sent, _item_set_digest, _resolve_ce_key) |
| Pricing options endpoint | GET /api/claims/{id}/contents-estimation-profiles (get_contents_estimation_profiles, _ce_key_or_none, _to_ce_schedule) |
| Existing-claim picker | GET /api/claims/{id}/contents-estimation-claims (get_contents_estimation_claims, _to_remote_claim) |
| Send modal's item list | GET /api/claims/{id}/contents-estimation-items (get_contents_estimation_items, _active_claim_items) |
| Per-item send stamp | InventoryItem.contents_estimation_sent_at, plus the ce_item_sent_at_backfill data migration in core/database.py |
| Per-team key (from SSO) | Team.contents_estimation_key, captured in core/auth_deps.py from the current_team_ce_intake_key JWT claim (core/security.py) |
| Per-team key (seeding) | backend/scripts/seed_team_ce_tokens.py, seeds Team.contents_estimation_key from the Forge teams CSV export |
| Claim tracking columns | Claim.contents_estimation_claim_key / _claim_id / _submitted_at |
| Readiness surface | GET /api/auth/debug → contents_estimation block |
| Frontend | frontend/src/components/SendToContentsEstimationModal.tsx, wired on the claim detail page |
| Tests | backend/tests/test_contents_estimation*.py |
| Live wire check (opt-in) | backend/tests/test_contents_estimation_live.py, skipped unless CE_LIVE_BASE_URL + CE_LIVE_KEY are set |
The authoritative wire contract lives on the Contents Estimation side as Pydantic models (api/app/intake/schemas.py), with a human-readable companion at specs/intake-contentsvision.md.
Opening Contents Estimation already signed in
The Integrations page (/integrations) carries a ContentsEstimation card whose Open, signed in button lands the user in Contents Estimation with a session already started, so moving between the two products never shows a login form. It is the exact reverse of the SSO that signs people in here, and it reuses the same shared secret:
What the minted token carries (core/security.make_contents_estimation_sso_token):
| Claim | Value | Why |
|---|---|---|
user_id | User.external_id | The CE user UUID. CE issued it when it signed the user in here, so it is the identity CE matches back. |
team_id | Team.external_id | The integer team id CE knows as Team.legacy_id. An id CE does not recognise is not fatal there: it falls back to the user's default membership. |
email, user_name | the signed-in user | Context only; CE authorizes on user_id. |
aud / purpose | contentsestimation / cv_to_ce_login | The direction markers. One secret signs both directions, so without them a token CE minted for us could be replayed back at CE (or ours replayed here) to conjure a session. |
exp | 3 minutes | The token rides in the opened tab's URL, so it is short-lived and spent on arrival. |
Three things make this safe to leave switched on:
- No caller-supplied redirect. The destination is built from
CONTENTS_ESTIMATION_APP_URLalone, so there is noreturn_toto point at an attacker's origin (the CE -> CV direction, which does take one, has to keep an allowlist for exactly this reason). - The token is a credential, not an identity to trust. Contents Estimation re-checks the account behind it: the user must exist there, be active, and hold a live membership. Deactivating someone in CE therefore closes this door too, even while ContentsVision still has them signed in.
- It fails closed. No shared secret or no app URL means a
503and a card that says so, never a tab that opens on a login form.
A user whose account predates Contents Estimation being the login source of truth has no external_id, so there is nothing to hand over: the endpoint answers 409 and the card shows the reason. In practice only a local-development sign-in (APP_ENV=local) reaches that, since every token-authenticated user is stamped with the id from their token.
This is a second cross-app contract, and everything in the section below about mocks agreeing with each other applies to it as well. Both sides of the hand-off are exercised offline (backend/tests/test_contents_estimation_sso.py here, api/tests/test_contentsvision_sso.py there), which means neither suite would notice if the claim names drifted apart. The cheap check, from a tree that has both repos:
# mint with THIS app's real code path, verify with THEIRS
SECRET=shared-test-secret
cd contentsvision/backend && ./venv/bin/python -c "
from app.core.security import make_contents_estimation_sso_token as mint
print(mint(user_external_id='11111111-2222-3333-4444-555555555555', email='[email protected]',
name='A', team_external_id=501, secret='$SECRET', ttl_minutes=3))" > /tmp/handoff.jwt
cd ../../contentsEstimation/api && .venv/bin/python -c "
from app.auth.contentsvision_sso import verify_contentsvision_login_token as verify
print(verify(open('/tmp/handoff.jwt').read().strip(), secret='$SECRET'))"
It must print the user id and team id back. Run it whenever either side's claims change, and remember that the two apps must also be deployed with the same secret: ADJUSTSQUARE_JWT_SECRET here equals CONTENTSVISION_SSO_SECRET there.
Checking the wire for real
Every other test on both sides mocks the other app, so between them nothing proves the two agree on URLs, field names and status codes. backend/tests/test_contents_estimation_live.py does: it drives this app's own send endpoints against a real Contents Estimation instance. It is the only test here that touches the network, so it is skipped unless both env vars are set and ./run_tests.sh never runs it by itself:
CE_LIVE_BASE_URL=http://127.0.0.1:8099 \
CE_LIVE_KEY='cek_xxx.yyy' \
./run_tests.sh tests/test_contents_estimation_live.py
Point it at a local Contents Estimation (their repo's CLAUDE.md covers standing one up; mint the per-team key with python -m app.provision_intake_keys --all, or set a team bearer token, which is the credential the integration actually uses). It creates a claim over there and adds items to it, so never aim it at production.
On 2026-09-03 the "add to an existing claim" feature shipped to production against two Contents Estimation endpoints that had never been built on that side. Both suites were green, because this side mocks app.services.contents_estimation and that side mocks its caller, so the two agreed with each other about a contract neither of them was checking. The feature failed silently in the modal: a 404 on the picker is deliberately read as available: false, so the card simply greyed itself out, while the Depreciation Schedule dropdown beside it populated over the same key and made it look like a permissions problem.
This test is the only thing here that would have caught it, and it did not run, because it skips unless both env vars are set. If a run reports 2 skipped, the variables did not take and nothing has been verified. Anyone changing the hand-off contract runs it against a live instance and confirms 2 passed.
The skip is no longer silent
A count cannot carry the difference between "we checked" and "we did not". 1033 passed, 2 skipped reads like a clean run, which is precisely how the above got through. Two things now sit on top of the skip (backend/tests/wire_check.py, with the notice printed from backend/tests/conftest.py):
- Every run that skips the check says so, in a notice after the summary naming what was not proven and the command to prove it. The default suite still stays offline: nothing about the skip itself changed, only its visibility.
CE_LIVE_REQUIRED=1turns the skip into a failure, which is what makes it an enforceable gate rather than a habit. A run that cannot reach a Contents Estimation then fails, naming the variable that is missing.
CE_LIVE_REQUIRED=1 \
CE_LIVE_BASE_URL=http://127.0.0.1:8099 \
CE_LIVE_KEY='<per-team key>' \
./run_tests.sh
Neither live test skips for any other reason either. The linking test used to skip when the instance held no claims to link to, and now creates its own target, because a conditional skip inside the one file that proves the wire is the same failure wearing a different hat.
Run it with CE_LIVE_REQUIRED=1 before promoting stage to main. That is the moment the contract reaches customers, and it is the only point where a wire mismatch is still cheap. There is no CI to lean on here: this repo's Bitbucket pipeline only builds and deploys the handbook, so the suite runs where a person runs it.