# stapel-reviews 0.3.0

Target-generic reviews and ratings: an author rates and reviews an opaque host-defined target (target_type + target_key), driven by a per-target-type policy registry (who may review, pre/post moderation, one-review-per-author, owner responses) whose authority questions are answered by host comm callbacks. The module owns the per-target aggregate (avg/count over published reviews) and emits a generic visibility-change fact carrying it, so a host catalog maintains its own rating projection without calling back.

Contract: axes 2 · surface 15 · extension points 9 · operations 5 · error codes 51.
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.
- MODERATION_DEFAULT [enum, default "post"] — When reviews go live
  The default moderation mode for target types that do not override it. "post" (default): a review is published the moment it is written and a moderator may hide it after the fact — the fast, open model. "pre": a review is held pending and stays invisible until a moderator publishes it — the curated model. A target type may override this per type. Values: post, pre.
- RESPONSES [bool, default true] — Owner replies to reviews
  Whether the target owner may attach a single public reply to a review by default. true (default): owner responses are enabled; false: reviews carry no owner reply. A target type may override this per type. Values: true, false.

## 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
- apply_verdict — stapel_reviews.services.apply_verdict
  instead of: stapel_reviews.models.Review.status
  Apply an external moderation module's verdict as the SYSTEM actor: approved publishes, rejected hides, needs_review and dismissed move nothing, an unknown word raises. The one caller allowed past the fail-closed can_moderate gate — authorization already happened where the verdict was made. Idempotent by state: a redelivered verdict writes and emits nothing.
- moderate_review — stapel_reviews.services.moderate_review
  instead of: stapel_reviews.models.Review.status
  Hide or publish a review as a moderator: the type's can_moderate callback is asked first (fail-closed), the status flips only when it actually changes, and the matching visibility fact goes out with the recomputed aggregate in the same transaction. Flipping Review.status by hand skips the authorization AND leaves every host catalog holding the previous average forever — the fact is the only thing that tells a projection to move.
### predicate
- check_can_moderate — stapel_reviews.registry.check_can_moderate
  instead of: stapel_core.comm.call
  "May this actor moderate or respond to reviews of this target" — the same host-callback dispatch as check_can_review, but fail-CLOSED: a policy naming no can_moderate callback denies everyone rather than quietly opening the moderation queue. Ask it before rendering any moderation or owner-reply affordance, and do not re-derive ownership from your own models — the module authorized the write off this exact answer, so a second opinion only produces a button that 403s.
- check_can_review — stapel_reviews.registry.check_can_review
  instead of: stapel_core.comm.call
  "May this author review this target right now" — resolves the policy's can_review callback name and asks the host over comm, where no callback means unrestricted. This is the call for a view or a template that wants to show or hide the review form before anyone types anything; use it rather than dispatching the callback name yourself, because the None-means-open convention and the deliberate refusal to fail open on a broken gate both live here, and a second implementation of them will not agree with the one guarding the write.
### factory
- aggregate — stapel_reviews.services.aggregate
  instead of: stapel_reviews.models.Review.objects.aggregate
  The module-owned rating aggregate of a target — mean and count over PUBLISHED reviews, avg 0.0 at count 0. It is the same value the reviews.aggregate comm Function returns and the same one every reviews.review.published/hidden fact carries, so a fresh in-process read and the last projection event cannot disagree. Do not average Review rows yourself: visibility is a status SET, and an Avg() that treats "not hidden" as the complement publishes a rating no reader can see.
- aggregates_by_keys — stapel_reviews.services.aggregates_by_keys
  instead of: stapel_reviews.models.Review.objects.aggregate
  The batch aggregate: one query for many target keys, answering {key: {avg, count}} with unreviewed keys ABSENT rather than zeroed — "nobody rated it" is not "everyone rated it 0". Looping aggregate() per row is the N+1 it removes. Exposed as reviews.aggregates_by_keys, the live_query a host Projection reads through.
- aggregates_export — stapel_reviews.services.aggregates_export
  instead of: stapel_reviews.models.Review.objects.values
  Every target's aggregate as a keyset-paged snapshot — the source_of_truth stapel-core's rebuild()/drift_check() page through as reviews.aggregates_export. Rows carry a seq on an Event's clock, so a fact arriving mid-rebuild outranks the snapshot row. Paging Review rows yourself re-implements the visibility rule and yields no ordering token.
- create_review — stapel_reviews.services.create_review
  instead of: stapel_reviews.models.Review.objects.create
  The whole write path in one call: the type must be registered, the rating must sit inside [RATING_MIN, RATING_MAX], one_per_author is enforced, the host's can_review callback is asked, and the review lands published (post-moderation) or pending (pre-moderation) with the published fact emitted inside the same transaction. A row created around this is unauthorized, unmoderated and invisible to every projection subscribed to the aggregate.
- get_target_types — stapel_reviews.registry.get_target_types
  instead of: django.conf.settings.STAPEL_REVIEWS["TARGET_TYPES"], stapel_reviews.registry.BUILTIN_TARGET_TYPES
  The effective registry — built-ins under the TARGET_TYPES setting under the runtime registrations, with a None entry meaning "removed" and not "present but empty". Read this, not the setting: a host that indexes STAPEL_REVIEWS["TARGET_TYPES"] directly sees neither the runtime registrations nor the removals, so its "can this be reviewed" picker disagrees with what the API accepts.
- list_reviews — stapel_reviews.services.list_reviews
  instead of: stapel_reviews.models.Review.objects.filter
  A target's reviews, newest first, with the visibility rule already applied: published only by default, and pending/hidden as well under include_all for a caller you have separately authorized as a moderator. VISIBLE_STATUSES is this module's definition of "visible"; a host filter spelled status == "published" agrees with it today and diverges the day another visible status exists.
- moderation_content — stapel_reviews.services.moderation_content
  instead of: stapel_reviews.models.Review.objects.get
  A review's content in the shape a moderation module screens and renders a case card from. Reach for it (as the reviews.moderation_content Function) instead of putting review bodies on the bus: content read when it is looked at has not gone stale, and does not sit in a retention-free outbox.
- register_target_type — stapel_reviews.registry.register_target_type
  Declare what may be reviewed — a type name plus its policy (who may review, pre/post moderation, one-per-author, owner responses, the two host callback names) — from an AppConfig.ready() or a test. The registry ships EMPTY, so until something registers a type every write is a 400; this and the STAPEL_REVIEWS["TARGET_TYPES"] setting (which this layer merges over) are the only two ways in, and both are the alternative to hand-rolling a "what is reviewable" branch in your own catalog. policy=None removes a type a lower layer provided.
- reset_target_types — stapel_reviews.registry.reset_target_types
  Drop the runtime registrations, leaving the settings layer untouched — a TEST lever, and the reason the runtime overrides are kept in their own dict: reviews' own conftest calls it between cases so a type registered by one test cannot leak into the next. Product code that finds itself calling this is undoing a registration that belongs in STAPEL_REVIEWS["TARGET_TYPES"], where it is declarative and survives a process reload.
- resolve_policy — stapel_reviews.registry.resolve_policy
  instead of: django.conf.settings.STAPEL_REVIEWS["TARGET_TYPES"]
  One type's fully-resolved policy: every key the engine relies on is present, MODERATION_DEFAULT and RESPONSES already folded in, and an unregistered type raises UnknownTargetType rather than answering an empty dict. Reach for this instead of a raw TARGET_TYPES entry — a policy that omits "moderation" is not a type with no moderation, it is one inheriting the axis, and reading it raw is exactly how a type silently loses pre-moderation.
- respond — stapel_reviews.services.respond
  instead of: stapel_reviews.models.Response.objects.create
  Attach the target owner's single public reply to a review — honours the type's allow_response (the RESPONSES axis, overridable per type), authorizes the author through the same can_moderate ownership gate as moderation, and refuses a second response. Both of those questions are easy to forget at a bare row create, and the second one is the difference between an owner reply and anyone replying as the owner.

## Extension points — what a product replaces, fork-free
- TARGET_TYPES [merge_registry]
  The flagship seam: the module ships knowing NO target types. A host registers what may be reviewed ({type_name: policy}, merged over the empty built-ins; None removes a type). A policy sets moderation (pre/post), one-review-per-author, whether owner responses are allowed, and — crucially — the comm Function names the module calls to ask the host 'may this author review?' (can_review) and 'may this actor moderate/respond?' (can_moderate), so the module never imports a host model.
- moderation.completed [comm_event]
  The verdict this module CONSUMES: a resolved case about a review hides or publishes it, applied as the system actor — the one bypass of the fail-closed can_moderate gate, since authorization already happened in the moderation module. MODERATION_TARGET_TYPE names which target_type is ours.
- reviews.aggregate [comm_function]
  The module-owned aggregate (avg/count over published reviews) as a synchronous read primitive other services can call by name — the host projection is a cache of exactly this.
- reviews.aggregates_by_keys [comm_function]
  The batch aggregate: many target keys in one call, omitting keys nobody has reviewed. The live_query half of a host rating Projection — in local mode stapel-core reads ratings straight through it, so the host keeps no table and cannot go stale.
- reviews.aggregates_export [comm_function]
  The cursor-paged snapshot of every target's aggregate — the source_of_truth half of that same Projection, read by rebuild() and drift_check(). Rows carry a seq on an Event's clock, so a rebuild can run while facts keep arriving without overwriting fresher state.
- reviews.moderation_content [comm_function]
  A review's content for an external moderation module's screening and moderator card. Identifiers travel on the bus and content is read when it is looked at, so a case opened hours ago shows the review as it is now.
- reviews.review.hidden [comm_event]
  The counterpart fact: when a review leaves the visible set (moderated out) the module emits it with the updated aggregate, so the host projection stays consistent.
- reviews.review.published [comm_event]
  The projection hook: when a review becomes visible the module emits this fact carrying the freshly recomputed target aggregate {avg, count}; a host catalog subscribes to maintain its own avg_rating projection per (target_type, target_key) without calling back (§10 projection pattern).
- serializer_seams [class_override]
  Every view declares request/response serializer seams (SerializerSeamMixin) — subclass the view, override the attribute, remount the URL.

## Fits with — fleet dependencies
- stapel-auth (optional) — every endpoint requires an authenticated user (IsAuthenticated); stapel-auth is the shelf's session issuer — any stapel-core-compatible JWT issuer satisfies the check
- stapel-core (required) — comm bus (review.published / review.hidden emits, reviews.aggregate Function, host policy callbacks), JWT authentication, GDPR provider registry, AppSettings config layer

## HTTP operations (5) — call by operationId, never by a typed path
Paths are relative to `/reviews/api/v1/`.
### Reviews
- GET /reviews/aggregate — reviews_api_v1_reviews_aggregate_retrieve
- POST /reviews — reviews_api_v1_reviews_create
- POST /reviews/{review_id}/moderate — reviews_api_v1_reviews_moderate_create
- POST /reviews/{review_id}/response — reviews_api_v1_reviews_response_create
- GET /reviews — reviews_api_v1_reviews_retrieve

## Error codes (51) — 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.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.reviews_duplicate_review [400] fix_input
- error.400.reviews_invalid_moderation_action [400] fix_input
- error.400.reviews_invalid_rating [400] fix_input
- error.400.reviews_response_not_allowed [400] fix_input
- error.400.reviews_unknown_target_type [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.reviews_cannot_moderate [403] retry
- error.403.reviews_cannot_review [403] retry
- error.403.verification_enrollment_required [403] verify
- error.403.verification_required [403] verify
- error.404.ad_not_found [404] retry
- error.404.not_found [404] retry
- error.404.reviews_review_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.409.reviews_already_responded [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.mandate_unavailable [503] retry
