# stapel-core 0.28.0

The Django substrate every Stapel module sits on: comm (Action/Function/Task/Projection inter-module communication over a transactional outbox), the transport-agnostic bus, AppSettings namespaces, step-up verification, self-documenting flows, i18n catalogs, the media/netintel/eventstore/captcha/secrets seams, the privilege gateway, the staff mandate, DRF API conventions (StapelResponse, error registry, permission classes, presenters) and the URL-mount + cross-service navigation registries. No HTTP surface of its own worth cataloguing and no CTO-facing feature axes — the core is what the feature modules are made of.

Contract: surface 24 · extension points 14 · error codes 42.
Generated from docs/capabilities.json by `stapel-llms-txt` — do not edit; drift-gated by `make contract-check`.

## 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.
### permission_class
- HasWorkspaceMandate — stapel_core.django.api.permissions.HasWorkspaceMandate
  instead of: rest_framework.permissions.IsAuthenticated, stapel_core.django.api.permissions.IsNotAnonymousUser
  The gate for the THIRD principal state: passes only a caller who holds an active mandate (an accepted, unsuspended workspace membership) somewhere. IsAuthenticated admits any session and IsNotAnonymousUser admits any real account — including a registered user who belongs to no workspace at all, which is exactly stapel-workspaces' guest. Reach for it wherever a view meant 'is this person part of an organization' and settled for 'is this person logged in'. A lookup that cannot be answered raises 503, never a 403: an unanswerable authorization question degrades to refusal, not to a verdict about the user.
- HasWorkspaceMandateIfScoped — stapel_core.django.api.permissions.HasWorkspaceMandateIfScoped
  instead of: rest_framework.permissions.IsAuthenticated, stapel_core.django.api.permissions.IsNotAnonymousUser
  HasWorkspaceMandate for a LIBRARY view that a single-tenant host also runs. Same three answers, one difference: where nothing can answer the mandate question at all, nobody holds one, so the guest state does not exist and this admits — the strict class 503s everyone there. A seam that IS wired and then fails still raises 503.
- IsNotAnonymousUser — stapel_core.django.api.permissions.IsNotAnonymousUser
  instead of: rest_framework.permissions.IsAuthenticated
  The write-gate for any endpoint that needs a REAL account: rejects the anonymous/guest sessions that stapel-auth's AUTH_ANONYMOUS axis issues. Reach for this, not DRF's IsAuthenticated, on anything that creates or owns user content — an anonymous session IS authenticated and sails straight through IsAuthenticated.
- IsServiceRequest — stapel_core.django.api.permissions.IsServiceRequest
  Marks an endpoint as internal service-to-service only: passes exactly when ServiceAPIKeyMiddleware recognised the X-API-KEY. Reach for it instead of comparing the header in the view — the middleware already resolved it, and a second reading of a secret is a second place to get it wrong.
- IsStaffUser — stapel_core.django.api.permissions.IsStaffUser
  Staff-or-superuser gate — the one to put on the browsable API, Swagger and any back-office endpoint; also the intended DEFAULT_PERMISSION_CLASSES for a service whose API is internal.
- IsSuperUser — stapel_core.django.api.permissions.IsSuperUser
  Superuser-only gate for destructive or global-configuration endpoints, where 'staff' is too wide.
- ReadOnlyOrStaff — stapel_core.django.api.permissions.ReadOnlyOrStaff
  instead of: rest_framework.permissions.IsAuthenticatedOrReadOnly
  Public catalogue shape: anyone (including anonymous) may read, only staff may write. Use it instead of hand-rolling a SAFE_METHODS branch inside a viewset's get_permissions().
- ReadOnlyOrSuperUser — stapel_core.django.api.permissions.ReadOnlyOrSuperUser
  Same public-read shape as ReadOnlyOrStaff, tightened so only a superuser may write — for reference data a staff member must not edit.
### gate_function
- health_check — stapel_core.django.monitoring.health.health_check
  The human/dashboard-facing health view: overall status plus a checks map with one entry per registered dependency. This is the endpoint an uptime monitor should watch — it is where register_dependency_check results become visible.
- liveness_probe — stapel_core.django.monitoring.health.liveness_probe
  Is the process alive at all — deliberately dependency-free, so an orchestrator does NOT restart a healthy container just because an outbound dependency is down. Pair with readiness_probe; do not point a liveness probe at health_check.
- prometheus_metrics — stapel_core.django.monitoring.health.prometheus_metrics
  The service's single Prometheus scrape endpoint, including stapel_dependency_probe_ok and stapel_dependency_up per registered dependency plus whatever register_metrics_exporter contributed. One scrape target per service is the contract; do not stand up a second.
- readiness_probe — stapel_core.django.monitoring.health.readiness_probe
  Should this instance receive traffic — checks the dependencies it cannot serve without. This is the one an orchestrator uses to pull an instance out of rotation, as opposed to killing it. Only a DETERMINED critical failure pulls it: a probe that could not ask leaves the instance in rotation, because every replica loses the same probe at the same moment and a 503 would turn a blip into a full outage.
- reset_schema_state — stapel_core.django.monitoring.schema_health.reset_schema_state
  Drop the cached schema verdict. For tests, and for a post-migrate hook in a process that migrates itself and should stop reporting the pre-migration answer for the rest of the TTL.
### template
- admin/base_site.html — django/templates/admin/base_site.html
  The admin shell that renders the cross-service navigation: STAPEL_SERVICES, the NAV_LINKS sections and the introspection-gated Swagger link. A project that ships its own admin/base_site.html instead of extending this one silently loses all three and gets no error — override blocks, do not replace the file.
### predicate
- schema_probe — stapel_core.django.monitoring.schema_health.schema_probe
  The register_dependency_check-shaped view of schema_state: True at head, False behind, None when the probe could not ask. This is what the framework registers; call it directly only if you are mounting the probe somewhere else.
- schema_state — stapel_core.django.monitoring.schema_health.schema_state
  AT_HEAD, BEHIND or UNKNOWN for the running code's schema — three states deliberately, because the two-valued predecessor mapped 'could not reach the database' onto 'the schema is behind' and made every database restart look like drift. Read this if you need the verdict in your own code; a determined verdict is cached for 30s and a non-answer is never cached.
- strong_factors — stapel_core.verification.factors.strong_factors
  The strict 'does this user really have 2FA' predicate — ids of STRONG factors the user can actually complete. Any require_mfa policy or mfa_status endpoint must branch on this and not on 'has any factor': an email code alone is not a second factor, it only proves reach to the channel that resets the password.
- unapplied_migrations — stapel_core.django.monitoring.schema_health.unapplied_migrations
  Migrations on disk the database has not applied — the same definition as `manage.py migrate --check`, deliberately, so the boot gate, the deploy gate and the health probe cannot disagree about what 'behind' means. Use it in a management command or a boot gate; do not re-derive the migration plan yourself.
### factory
- get_health_urls — stapel_core.django.monitoring.health.get_health_urls
  The urlpatterns for the whole health/metrics family — include() this in a service's root urls.py instead of wiring the four views by hand, so every service in the fleet answers the same paths and a deployment's probes are portable between them.
- load_configured_factors — stapel_core.verification.factors.load_configured_factors
  Boot-time loader that turns STAPEL_VERIFICATION['EXTRA_FACTORS'] from a declaration into registrations (pinned, so host ids beat library ones whatever the INSTALLED_APPS order). CommonDjangoConfig.ready() calls it since 0.16.1 — a host app must NOT call it from its own AppConfig any more; before 0.16.1 it had no caller anywhere in the framework and the setting was decorative, which is exactly the failure this whole section exists to make impossible.
- register_dependency_check — stapel_core.django.monitoring.health.register_dependency_check
  Register a probe for an OUTBOUND dependency (LiveKit, an STT provider, a payment gateway) so its state shows up as checks.<name> on /api/health/ and stapel_dependency_up{dependency="<name>"} on /api/metrics/. Reach for this whenever you wrap a network call in a best-effort try/except: the wrapper is fine, the wrapper WITHOUT a registered check is how meettoday's host-kick and room-PIN silently did nothing in production for a day. Canon: swallowed exception + logger.error + register_dependency_check, never the first alone. The probe has THREE answers, not two — True, False, and None for 'I could not ask'. Return None rather than a guess: an undetermined dependency renders as checks.<name>="unknown", omits its stapel_dependency_up sample instead of dropping it to 0, and never takes the process out of rotation.
- register_factor — stapel_core.verification.factors.register_factor
  Register a step-up verification factor (instance or dotted path) from an AppConfig.ready() — the fork-free way for a library or a host to add OTP/TOTP/passkey-shaped proof of presence. A host that only has a dotted path should use the EXTRA_FACTORS setting instead and let the boot loader do this.
- register_metrics_exporter — stapel_core.django.monitoring.health.register_metrics_exporter
  Contribute additional Prometheus lines to /api/metrics/ from a module or a product, without forking the view. Use it instead of standing up a second metrics endpoint — one scrape target per service is the contract deployments are built against.
- register_schema_check — stapel_core.django.monitoring.schema_health.register_schema_check
  instead of: a product-local schema_health.py copied into every service directory
  Put the schema-drift probe on /api/health/ and /api/metrics/. CommonDjangoConfig.ready() already calls it, so a service that installs stapel_core.django gets it without wiring anything — call it yourself ONLY from a process that does not install that app config. A product carrying its own copy of this module (ironmemo's iron-*/core/schema_health.py) should delete the copy and rely on this: the answer is the same in every Django service, and a per-service duplicate is a per-service place to drift.

## Extension points — what a product replaces, fork-free
- AUTH_USER_MODEL [swappable_model]
  Standard Django user swap — subclass AbstractStapelUser; core itself only ever goes through get_user_model().
- STAPEL_ADMIN["NAV_LINKS"] [merge_registry]
  Two-channel admin/Swagger navigation registry: a module registers its dashboard in AppConfig.ready() via register_nav_link(), the project adds/patches/removes via the setting (partial dict patches, None removes). Sections are fixed by the mechanism, contents are policy.
- STAPEL_BUS_BACKEND [dotted_path]
  Bus backend behind publish()/get_bus(): in-memory, Kafka, NATS JetStream or a per-topic router.
- STAPEL_CAPTCHA [dotted_path]
  CaptchaVerifier backend (turnstile / recaptcha / hcaptcha / noop) plus the tiered challenge policy driven by the client's network class.
- STAPEL_COMM [transport_map]
  Per-name transport for Actions/Functions/Tasks (in-process, bus, HTTP) — the seam that makes 'monolith or microservices' deployment configuration rather than code.
- STAPEL_EVENTSTORE["BACKEND"] [dotted_path]
  Append-only stream backend behind append/query/rollup/purge — Postgres with time partitions by default, ClickHouse the documented scale-out point.
- STAPEL_GATEWAY [merge_registry]
  Deny-by-default verb registry of the privilege gateway (name + JSON schema + policy + handler) — capability without credentials.
- STAPEL_MEDIA_BACKEND [enum]
  media.describe() source: the zero-infrastructure PIL/ImageField path, or the stapel-cdn service via the cdn.describe comm Function.
- STAPEL_MOUNTS [url_registry]
  Where each local/external mount lives; LOGIN_URL and friends derive from it lazily, so a module never emits an absolute URL path and works at the root, under a service prefix and in a monolith alike.
- STAPEL_NETINTEL["PROVIDER"] [dotted_path]
  IP-intelligence provider behind classify_ip()/country_of(): MaxMind mmdb, generic HTTP JSON, or null. Cached and fail-open.
- STAPEL_SECRETS [dotted_path]
  Secret provider; a missing production secret is a loud boot failure (SecretUnavailable), never a silent empty string.
- STAPEL_SERVICES [deploy_registry]
  The sibling services of this deployment (env-JSON or setting), seeded by stapel-create-project and appended by stapel-new-service; a monolith leaves it unset and one implicit service is derived.
- STAPEL_VERIFICATION["EXTRA_FACTORS"] [dotted_path_list]
  Register host-owned verification factors (subclass VerificationFactor) by dotted path — declaring is enough, CommonDjangoConfig.ready() calls load_configured_factors() at boot and pins the host's id over any library registration of the same name.
- gdpr_registry [in_process_registry]
  GDPRProvider implementations a module registers for export/erasure; microservices mode consumes the same providers through GDPRServiceConsumerCommand.

## Error codes (42) — 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.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.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.mandate_unavailable [503] retry
