# stapel-core 0.47.0

The Django substrate every Stapel module sits on: comm (Action/Function/Task/Signal/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, the serializer seam and thin-view base) 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 53 · extension points 16 · 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
- bind_trace — stapel_core.observability.context.bind_trace
  Set a trace context without a with-block, returning the reset token. The escape hatch for a framework hook that enters and exits in different callbacks; prefer start_trace().
- configure_logging — stapel_core.observability.logs.configure_logging
  logging_config() applied right now, for processes that do not go through Django settings — management commands, bus consumers, workers.
- counter — stapel_core.observability.metrics.counter
  Add to a counter without importing a metrics client. The instrumentation call every module writes; where the number lands is the deployment's answer (METRICS_BACKEND), not the library's. Never raises — a failed measurement is not a failed request.
- gauge — stapel_core.observability.metrics.gauge
  Set a level — queue depth, pool size, backlog — through the same vendor-free facade as counter().
- 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.
- histogram — stapel_core.observability.metrics.histogram
  Record one observation of a distribution (a latency in SECONDS, a payload size). Buckets come from settings unless the call site pins them.
- 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.
- report_error — stapel_core.observability.errors.report_error
  Send an exception to whatever this deployment uses for error tracking (ERROR_REPORTER), with the in-flight trace ids attached as tags. Reach for it instead of sentry_sdk.capture_exception: library code must not name a vendor, and the default reports nowhere. Never raises.
- report_message — stapel_core.observability.errors.report_message
  report_error() for a condition with no exception object — a threshold crossed, an invariant that held but should not have.
- reset_backend — stapel_core.observability.metrics.reset_backend
  Forget the memoized metrics backend so the next call rebuilds it from settings. Wired to setting_changed already; call it directly only in a test that reconfigures by other means.
- reset_error_reporter — stapel_core.observability.errors.reset_error_reporter
  Forget the memoized error reporter so the next call rebuilds it from settings.
- 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.
- set_backend — stapel_core.observability.metrics.set_backend
  Pin a metrics backend for this process, ignoring settings — for tests and for a host that builds its own pre-configured client. The normal way in is METRICS_BACKEND.
- set_error_reporter — stapel_core.observability.errors.set_error_reporter
  Pin an error reporter for this process, ignoring settings — tests, and hosts that construct their own client.
- timer — stapel_core.observability.metrics.timer
  Time a block and record it as a histogram observation. Measures in a finally, so a block that raises is still measured — the latency of failures is usually the interesting half.
### 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
- current_trace — stapel_core.observability.context.current_trace
  The TraceContext in flight. Never None — an unbound context is empty, so callers never branch on presence.
- parse_traceparent — stapel_core.observability.context.parse_traceparent
  Read a W3C traceparent header, or None if it is not one. A malformed header is a header we did not write, never a reason to fail a request.
- registered_gdpr_owners — stapel_core.gdpr.owners.registered_gdpr_owners
  Owner name → the subject types it registered in THIS process. The honest answer to 'which owners does this container actually erase for', as opposed to the host's STAPEL_GDPR['DATA_OWNERS'] declaration — the gap between the two is the silent-owner defect the probe exists to expose.
- 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.
- trace_ids — stapel_core.observability.context.trace_ids
  The five in-flight ids as a plain dict — for stamping onto a log record, an outbound header, or a payload the framework does not own.
- 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
- continue_trace — stapel_core.observability.context.continue_trace
  The subscriber side: bind the trace an incoming event carries, with causation_id set to that event's id. This is what makes a fan-out reconstructible as a tree — comm delivery and the bus consumer base already call it, so a handler inherits its cause for free.
- format_traceparent — stapel_core.observability.context.format_traceparent
  Render the in-flight context as a traceparent value for an outbound call — the join that carries one trace across a service boundary. Empty when the ids are not W3C-shaped.
- get_backend — stapel_core.observability.metrics.get_backend
  The metrics backend this process resolved. For introspection and for a custom exporter that wants the backend's exposition text; instrumentation should call counter/gauge/histogram instead.
- get_error_reporter — stapel_core.observability.errors.get_error_reporter
  The error reporter this process resolved. For introspection and for code that needs the reporter's own return value (a backend event id).
- 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.
- logging_config — stapel_core.observability.logs.logging_config
  The LOGGING dict for a Stapel service: one stdout handler, JSON objects with a mandatory field set, trace ids stamped on every record and REDACT_FIELDS blanked at the formatter. Reach for it instead of hand-writing dictConfig — a text log is grepped, a structured log is queried.
- metric_name — stapel_core.observability.metrics.metric_name
  The final, namespaced, Prometheus-legal name for a metric. Reach for it when building a metric name outside the facade (a custom exporter) so both halves of a deployment's scrape surface share one namespace.
- new_span_id — stapel_core.observability.context.new_span_id
  A fresh 16-hex-char span id (W3C shape).
- new_trace_id — stapel_core.observability.context.new_trace_id
  A fresh 32-hex-char trace id (W3C shape). For code minting a trace outside start_trace().
- pseudonymize — stapel_core.gdpr.owners.pseudonymize
  instead of: hashlib.sha256(str(user_id).encode()).hexdigest()
  The fleet's one erasure funnel for an id that must survive as a stable pseudonym: HMAC-SHA256 under the deployment's SECRET_KEY, 32 hex, `erased:`-prefixed and therefore idempotent. This is how a ledger-carrying owner (billing, agent, video) erases — the ids that NAME the person go, the economics stay, and per-subject arithmetic still works. Never a bare hash of a user id: that is a rainbow table away from being the id again.
- receipt_id — stapel_core.gdpr.owners.receipt_id
  The deterministic id of one erasure receipt, `<owner>:<subject_type>:<subject_key>:<correlation_id>`. register_gdpr_owner already stamps it; call it directly only to assert against a receipt from the other side. Derived rather than random so an at-least-once redelivery produces the SAME receipt instead of a second one the audit trail cannot follow back.
- 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_gdpr_owner — stapel_core.gdpr.owners.register_gdpr_owner
  instead of: a hand-written actions.py with gdpr.erasure.requested / gdpr.owner.probe / user.deleted handlers
  Subscribe a library as a stapel-gdpr data owner from its AppConfig.ready() — one call registers gdpr.erasure.requested, gdpr.owner.probe and (optionally) the deprecated user.deleted with exactly the fleet protocol: deterministic receipt_id, the receipt emitted inside the erase's transaction, silence for a subject type this owner does not claim, a logged drop for a malformed payload or an unparseable key, and the probe answered from the same module so gdpr.owner.alive proves the erasure path is CONSUMED. A new owner library must call this; nine libraries carry the same sixty lines by hand and migrate on their next minor.
- 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.
- sanitize_id — stapel_core.observability.context.sanitize_id
  Make an untrusted correlation id safe to log, label and forward — closed alphabet, length-capped. Apply it to any id read off the network before it reaches a log field or a metric label.
- start_trace — stapel_core.observability.context.start_trace
  Open a correlated unit of work: binds trace/span/correlation/causation ids for the block, so every log line, metric and comm envelope inside it carries them. TraceContextMiddleware does this per request; call it directly for a worker, a cron job or a management command, which otherwise run uncorrelated.

## 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) plus SIGNAL_TRANSPORT for Signal delivery — 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_OBSERVABILITY["ERROR_REPORTER"] [dotted_path]
  Where report_error() sends an exception. Sentry-shaped interface (capture_exception/capture_message with level, tags, context), no-op by DEFAULT — a framework does not decide that a host's exceptions go to a third party. SentryErrorReporter and LoggingErrorReporter ship; the in-flight trace ids ride along as tags, so an error and its log lines are joined by trace_id.
- STAPEL_OBSERVABILITY["METRICS_BACKEND"] [dotted_path]
  Where measurements land. Modules call stapel_core.observability.metrics.counter/gauge/histogram/timer and never import a client library; the deployment names a MetricsBackend subclass — PrometheusMetricsBackend (default, degrades to a no-op that check W002 reports when prometheus-client is absent), StatsdMetricsBackend, LoggingMetricsBackend, NoopMetricsBackend, or its own. A backend never raises into the caller: instrumentation that can fail a request fails exactly when the system is already unhappy.
- 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
