# stapel-classified 0.9.1

Composite for location-bound classified ads: the stapel-shop composite (categories + listings + reviews) plus stapel-geo, stapel-search and stapel-moderation, and the cross-domain declarations no member is allowed to write — the `listing` search source, the `listing`/`review`/`seller`/`chat_message` moderation target policies, the marketplace reason taxonomy, and (since 0.3.2) the `listing` subject type stapel-chat's registry ships empty of. Its own HTTP surface is a conversation HEADER, assembled from three modules that may not know about each other: chat says who is in the thread and what it is about, listings answers the short card (title, price, the photo gallery with CDN render metadata, and a state that says `available`, `unavailable` or `gone` — exactly the answer a public read cannot give), profiles answers the counterparty. It owns NO table: the binding it kept from 0.2.0 to 0.3.1 existed only because chat could not tell two threads about two listings apart, and it was deleted rather than kept in sync the release chat could. It enforces no block of its own: stapel-chat 0.6.1 holds both write doors (opening a direct thread, sending into one) at the one point every client passes, and this composite's whole contribution is the value `required` it sets on chat's BLOCK_ENFORCEMENT axis in the preset. The pre-creation door it kept until 0.3.x was deleted in 0.4.0, and a deployment that still declares the old STAPEL_CLASSIFIED keys is told so at boot (E003) rather than silently inheriting chat's default.

Contract: axes 3 · surface 7 · extension points 3 · operations 3 · error codes 46.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## Configuration axes — what a product switches on
Settings keys; `default` is what you get by saying nothing. Turning an axis off unmounts the operations it gates.
- CONVERSATION_PARTICIPANTS_FUNCTION [enum, default "chat.conversation_participants"] — Who is in a conversation, and what it is about
  The read that replaced this composite's own binding table in 0.3.2 (`chat.conversation_participants`, stapel-chat 0.6.0). It is the one seam here with no degraded form: an unreachable chat answers 503, because an empty page would be indistinguishable from 'you are not a party to any of these' and a reader would take an outage for a permission boundary.
- PUBLIC_PROFILE_FUNCTION [enum, default "profiles.public_cards"] — How much of a seller the card can show
  `profiles.public_cards`, served since stapel-profiles 0.16.0 — the avatar as the fleet image object, member-since, seller type. A deployment without profiles sets it empty and the counterparty card answers `partial` naming what is missing, rather than leaving a blank a client has to guess about.
- SELLER_RATING_TARGET_TYPE [enum, default ""] — Whether sellers carry a rating
  Empty in the shipped scenario, because this composite registers reviews about listings rather than about people. A deployment that adds a seller review target names it here and the stars appear in every conversation header — with no release. Empty means the card says 'no rating', never a fabricated zero.

## Usage surface — call these before writing your own
This is the answer to "does Stapel already have something for X?". `instead of` names the outside symbol this one displaces.
### gate_function
- confirm_listing_conversation — stapel_classified.services.confirm_listing_conversation
  instead of: a second block check on a thread that already exists, storing a listing_id on your own chat model, trusting the client's word for what a thread is about
  The 'write to the seller' moment, verified: the listing exists, the caller is not its seller, and chat agrees the caller is in that thread and that it really is about that listing. Answers the header the client is about to render, and records nothing. It does NOT check for a block (0.4.0): the thread it is handed already exists, which is history, and stapel-chat refuses a blocked pair the thread at creation instead.
### factory
- chat_threads — stapel_classified.services.chat_threads
  instead of: importing stapel_chat models from a composite, keeping your own copy of a conversation's participants
  Ask chat who is in these conversations and what each is about, batched — the read that makes a second copy of chat's membership unnecessary. Raises rather than answering empty when chat cannot be reached.
- conversation_context — stapel_classified.services.conversation_context
  instead of: fetching the listing and the profile separately in a client
  One conversation's header: the short listing card (including sold and deleted, which is when a buyer is most confused) and the counterparty's public seller card, from the reader's side.
- conversation_contexts — stapel_classified.services.conversation_contexts
  instead of: looping conversation_context over a conversation list
  The batch form the inbox calls: a bounded page of headers in two comm reads, instead of one round trip per row.
- listing_cards — stapel_classified.cards.listing_cards
  instead of: calling listings.search_documents and formatting a card yourself
  Keyed batch of short listing cards, gone ones included, with the CDN render metadata already merged over every image in the card's gallery (`images`; `image` is its first frame, kept for clients written before the gallery).
- party_ids — stapel_classified.services.party_ids
  instead of: reaching into the raw participants list in each caller
  The user ids in one thread answer from chat_threads, as strings — the shape every authorization check here compares against.
- seller_cards — stapel_classified.cards.seller_cards
  instead of: reading a Profile row or a User row for a chat header
  Keyed batch of PUBLIC seller cards — never more of a person than their public profile, whatever the caller holds.

## Extension points — what a product replaces, fork-free
- STAPEL_CLASSIFIED comm Function names [settings]
  Every read the conversation header makes is a comm Function NAME in this module's settings namespace — the listing documents, the display names, the public profile card, the seller rating, the CDN describe. Point one at your own provider and the answer changes with no fork and no release.
- STAPEL_MODERATION / STAPEL_SEARCH / STAPEL_REVIEWS declarations [settings]
  preset.SETTINGS_DEFAULTS is plain data: the search source, the four moderation target policies and the marketplace reason codes. All three registries merge over their built-ins, so a deployment adds a reason or removes a target type in its own settings rather than upstream.
- SerializerSeamMixin on every view [subclass]
  core's hoisted serializer seam: subclass a view, set request_serializer_class / response_serializer_class, remount the URL. No HTTP method body is ever copied.

## Fits with — fleet dependencies
- stapel-attributes (required) — L1 library dependency of stapel-categories/stapel-listings; pip dependency, not a Django app (preset.py comment)
- stapel-categories (required) — composite member, inherited from the shop scenario (preset.py INSTALLED_APPS/URL_INCLUDES)
- stapel-cdn (optional) — reached by name (`cdn.describe_many`) for the render metadata of every image on a card — the same contract stapel-chat uses for an attachment. Absent, the card keeps its image refs and says `meta_status: partial`
- stapel-chat (required) — pinned (>=0.6) but not a member and never imported — it is ASKED, by name. Since 0.3.2 chat is the source of both halves of a conversation header: `chat.conversation_participants` answers who is in a thread and what it is about (0.6.0), and `chat.moderation_content` serves a reported message (0.5.0). Where no process serves those reads, a header answers 503 rather than an empty page and a complaint about a message answers 503 rather than a reporter's word for it
- stapel-geo (required) — composite member — location binding for classified ads (preset.py INSTALLED_APPS/URL_INCLUDES)
- stapel-listings (required) — composite member — the classified ad itself (preset.py INSTALLED_APPS/URL_INCLUDES)
- stapel-moderation (required) — composite member — the cross-target queue; the composite declares its `listing`/`review`/`seller`/`chat_message` target policies and the marketplace reasons (preset.py SETTINGS_DEFAULTS). 0.3.0+ is required: a chat message is the first target here whose content is PRIVATE, and 0.3.0 is where `can_view_content` is asked on behalf of the moderator actually looking
- stapel-notifications (optional) — not a member; when co-installed it routes the moderation letters (`listing_blocked`, `moderation.*`) the target policies name — absent, no letter is sent and nothing pretends one was
- stapel-profiles (optional) — reached by name for the counterparty card (`profiles.display_names`, `profiles.public_cards`) and for block enforcement (`profiles.relationships`, served since profiles 0.16.0). Absent, the seller card is `partial` — and because BLOCK_ENFORCEMENT defaults to `required`, a deployment without it must say so with `auto` or fail its own boot check (classified.E002)
- stapel-reviews (required) — composite member, inherited from the shop scenario (preset.py INSTALLED_APPS/URL_INCLUDES)
- stapel-search (required) — composite member — the index, facets and geo radius over listings; the composite declares its `listing` source (preset.py SETTINGS_DEFAULTS, search_sources.py)

## HTTP operations (3) — call by operationId, never by a typed path
Paths are relative to `/classified/api/v1/`.
### Classified / conversations
- POST /conversations/contexts — classified_api_v1_conversations_contexts_create
- POST /conversations — classified_api_v1_conversations_create
- GET /conversations/{conversation_id} — classified_api_v1_conversations_retrieve

## Error codes (46) — the StapelError envelope
Render `t(code, params)`; branch UX on the remediation. Localized text lives in docs/errors.<lang>.md, not here.
- error.400.bad_request [400] fix_input
- error.400.captcha_invalid [400] retry
- error.400.captcha_required [400] retry
- error.400.classified_own_listing [400] fix_input
- error.400.expected_list [400] fix_input
- error.400.field.blank [400] fix_input {field}
- error.400.field.does_not_exist [400] fix_input {field}
- error.400.field.invalid [400] fix_input {field}
- error.400.field.invalid_choice [400] fix_input {field}
- error.400.field.max_length [400] fix_input {field,max_length}
- error.400.field.max_value [400] fix_input {field,max_value}
- error.400.field.min_length [400] fix_input {field,min_length}
- error.400.field.min_value [400] fix_input {field,min_value}
- error.400.field.null [400] fix_input {field}
- error.400.field.required [400] fix_input {field}
- error.400.field.unique [400] fix_input {field}
- error.400.invalid_ad_id [400] fix_input
- error.400.validation_error [400] fix_input
- error.400.verification_failed [400] verify
- error.400.verification_invalid_factor [400] verify
- error.401.unauthorized [401] reauthenticate
- error.402.payment_required [402] retry
- error.403.forbidden [403] retry
- error.403.network_blocked [403] contact_support
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.classified_conversation_not_found [404] verify
- error.404.classified_listing_not_found [404] verify
- error.404.not_found [404] retry
- error.404.verification_challenge_not_found [404] verify
- error.405.method_not_allowed [405] retry
- error.406.not_acceptable [406] retry
- error.408.request_timeout [408] retry
- error.409.conflict [409] fix_input
- error.410.gone [410] retry
- error.413.payload_too_large [413] retry
- error.415.unsupported_media_type [415] retry
- error.422.unprocessable_entity [422] wait_and_retry
- error.423.locked [423] wait_and_retry
- error.423.verification_locked [423] wait_and_retry
- error.429.rate_limit [429] wait_and_retry {retry_after_minutes}
- error.429.too_many_requests [429] wait_and_retry
- error.500.internal [500] contact_support
- error.503.classified_chat_unavailable [503] wait_and_retry
- error.503.mandate_unavailable [503] retry
