This document aims to provide a high-level overview over the architecture of the backend.
The backend currently consists of the following components:
- The Rust backend monolith found in this repository
- The old Python/Rust microservices (soon to be integrated into the new backend): skills-ms, jobs-ms, events-ms, challenges-ms
- A PostgreSQL database for persistence
- A Valkey/Redis server for caching
- External services/APIs:
- An SMTP server for sending emails
- Google reCAPTCHA (optional, switched off with
recaptcha.enable = false) - Various OAuth2 providers like GitHub, Discord, Google, ...
- Vies on-the-Web for VAT validation
- PayPal
- GlitchTip/Sentry for error tracking and monitoring
- Async runtime:
tokio - Error handling:
anyhow,thiserror - CLI:
clap - Tracing:
tracing - Date and time:
chrono - Newtypes:
nutype - Serialization and deserialization:
serde - Mocking for unit tests:
mockall - HTTP client:
reqwest - HTTP server:
axum - REST API documentation:
aide - Postgres client:
bb8-postgres/tokio-postgres - Valkey/Redis client:
bb8-redis/redis - Email:
lettre
The main purpose of the backend is to serve the REST API used by the frontend and potentially other systems integrating with Bootstrap Academy.
To start the API server, the academy serve command is used, which causes the backend to bind a TCP listener to the configured address and respond to HTTP requests.
In a production environment it is expected to deploy the REST API server behind a reverse proxy like nginx which handles TLS termination.
An OpenAPI specification is automatically generated and served at /openapi.json.
In addition, both Swagger UI and Redoc are available on /docs and /redoc respectively.
Clients are mostly authenticated using JWTs:
- Normal users logging in with their account credentials receive an access token (JWT) and a refresh token (random opaque secret) and use the access token to authenticate all subsequent requests. When the access token expires (or is invalidated) the client uses the refresh token to request a new access/refresh token pair which replaces the current one.
- Services (esp. the old Python/Rust microservices) authenticate each request by issuing a very short-lived JWT which includes the target audience (the recipient of the request).
- An account can also be signed in through an OAuth2 provider.
POST /auth/oauth/authorizestarts the flow: it accepts only aredirect_urifromoauth2.redirect_uris(exact match), stores the provider, the redirect uri and the PKCE code verifier under an unguessable single usestate, and returns the authorize url together with thatstate. The callback is redeemed exactly once, and the provider and the redirect uri of the exchange are taken from the stored entry and never from the callback body. The endpoint takes an optional access token, and the stored entry remembers which account started the flow. A flow started while signed in can only be completed as a link for that account (POST /auth/oauth/links/{user_id}); a flow started without a usable token only as a login (POST /auth/sessions/oauth). That is what stops a callback of somebody else's flow from being submitted to a victim's client to make the attacker's provider account a login method for the victim.
MFA is optional for ordinary accounts, but administrative privileges are only granted to sessions that were established with a verified TOTP code.
Sessions carry an mfa_verified flag (column on sessions, mfa claim in the access token, mfa_verified in the API session object) that is set only when the second factor was checked during login.
A recovery code disables MFA instead of proving possession of the second factor, and neither an OAuth2 login nor impersonation involves it, so none of them set the flag.
Authentication::ensure_admin answers 403 Admin MFA required for an administrator whose session does not have the flag.
Endpoints that only need the account itself (including the MFA setup endpoints under /auth/users/{user_id}/mfa) are unaffected, so an administrator without an authenticator can still log in, set one up and then log in again with a code.
The authority ends with the second factor: removing it (DELETE /auth/users/{user_id}/mfa, and a login with a recovery code, which does the same) clears mfa_verified on every session of that account and invalidates its access tokens, so the next refresh issues a token without the flag.
POST /auth/users/{user_id}/email sends a verification code to the address of an account, and POST /auth/password_reset sends a password reset code to the address named in the request if an account has it.
Anybody can point either address at somebody else's mailbox, so UserEmailLimitService counts both in buckets, each kind of code on its own ([user] in docs/config.md).
- The buckets live in the cache and are updated with compare-and-set, so concurrent requests cannot pass the limit together. A bucket checked earlier keeps its count when a later one refuses, so the buckets of the requester come first.
- A verification first looks at the mailbox across all accounts without counting, and is refused before anything is counted once that budget is used up. It is then checked against the account together with the mailbox (one email per minute, five per hour by default), the account (five per hour) and the mailbox across all accounts (twenty per hour). A refusal only costs the requesting account. One account can put at most nine emails into one window of a mailbox, and the configuration has to keep the shared budget at least twice the per-account budget, so one account alone cannot lock anybody out of the confirmation.
- A password reset counts the address exactly as requested, before it is looked up, and then the account it found. The code only goes out when the requested address equals the stored one exactly, so any other spelling sends nothing and leaves the owner's bucket alone. The limit only tells whether an account has the address when that account changed its address within the window and runs out of its own budget first.
- A refusal is
429 Too many requestswith aRetry-Afterheader. A refusal leaves the codes that were already sent valid, so whoever is refused can still use the code they received.
Each incoming request is assigned a unique request id (Base64 encoded UUIDv7).
This id is automatically attached to any logs associated with the corresponding request and is also returned to the client in the X-Request-Id response header.
#[trace_instrument] opens a span at INFO and records the arguments of the function in it, and the default formatter prints the fields of the enclosing spans with every event inside them.
Arguments that carry personal data — a User, a request body, an email address, a name, an invoice address, a password, a search term an administrator typed, the login identifier and the user agent a login was made with — are therefore skipped, with fields(user_id = …) or another identifier added where a correlation id is useful.
Return values are only recorded at TRACE, which no deployment runs at.
Every POST, PUT, PATCH and DELETE request that is authenticated with an administrator's access token is recorded in the admin_audit_log table, including requests that were rejected.
Reads are not recorded, with the exceptions listed in AUDITED_READ_ROUTES, the four administrative reads that hand out more than the endpoints an ordinary user can reach:
GET /auth/users/{user_id}/export(see Data Export), because it hands out everything the platform stores about a user,GET /finance/documents(see Financial Documents), because it is searchable by name and email address and still names accounts that have been deleted,GET /auth/users, because it carries the full invoice address of every account and is searchable by name and email address,GET /contracts/declarations(see Contract Declarations), because it carries the name, the email address and the free text of every declaration.
Each entry uses the route constant the router registers, so the recorded routes and the registered ones cannot drift apart.
The middleware in academy_api/rest/src/middlewares/admin_audit.rs runs after routing and hands the request to the AdminAuditFeatureService, which authenticates the token and writes the entry.
An entry holds the time, the acting administrator, the method, the path without its query string, the affected user, the status code and the request id.
The affected user is taken from the {user_id} parameter of the matched route (me and self resolve to the acting administrator), so routes with a different first path parameter do not produce a false attribution.
Request bodies are never stored.
Neither user id references the users table: the log has to stay complete and attributable after the acting or the affected account has been deleted.
GET /admin/audit-log returns the entries, most recent first, paginated and filterable by admin_user_id and target_user_id. It requires admin privileges.
The version of the terms and conditions a user accepted is stored on the user (terms_version, terms_accepted_at), together with the time at which the user confirmed to meet the minimum age (age_confirmed_at).
POST /auth/users requires both terms_version and age_confirmed when an account is created.
Existing users are asked again when a new version is published:
POST /auth/users/{user_id}/termsrecords the acceptance of the version that is currently in force (user.terms_version) and requiresage_confirmedto betrue.terms_versionin the request has to be that version and is only checked; the value that is recorded is the server's.user_idhas to bemeorself.POST /auth/users/{user_id}/terms/declinerecords that the user does not accept the version the platform currently asks for (terms_declined_at). The previously accepted version stays in place. Accepting later clears the refusal.
terms_version, terms_accepted_at and terms_declined_at are read-only fields on the user object; age_confirmed_at is stored but not returned by the API.
Consumers can declare the cancellation of or the withdrawal from a contract without logging in:
POST /contracts/cancellationsPOST /contracts/withdrawals
Both endpoints are public, rate limited per client ip and per email address ([contract] in docs/config.md; 60 and 5 declarations per hour by default, because an ip address is shared by everybody behind the same NAT), and store the declaration in the contract_declarations table with the time it was received.
The declaration is committed before any email is sent, so a failing mail server cannot lose it; the response reports in confirmation_email_sent whether the confirmation reached the declarant.
Two emails are sent per declaration: a confirmation to the declarant (contract_cancellation_confirmation.html / contract_withdrawal_confirmation.html) and a plain-text notification to contact.email.
Both endpoints also take a free text contract_designation, the contract as the consumer names it (§ 312k Abs. 2 S. 2 Nr. 2 BGB), for every kind of contract. It is stored as it was written and appears on the confirmation and on the notification.
An ordinary cancellation that names a premium membership and an email address belonging to an account switches the automatic renewal off and returns the end of the paid period in effective_end.
An extraordinary cancellation switches the automatic renewal off as well, but gets no effective_end: whether the claim holds is a question for a person, so the confirmation says that the declaration is examined and its end date confirmed separately in Textform, and the notification to contact.email is marked DRINGEND in its subject and in its first line.
GET /contracts/declarations lists the recorded declarations and requires admin privileges. Because the listing carries the name, the email address and the free text of every declaration, reading it is written to the administrative audit log.
PATCH /contracts/declarations/{declaration_id} records that a declaration has been dealt with: it sets processed_at to the time of the request and stores effective_end and a free text note when they are given, leaving what is not given alone. It requires admin privileges — which means an MFA verified session — and is written to the administrative audit log like every other state changing administrative request. The note is part of the data export of the person the declaration belongs to, because it is a note about them.
Before a paid order is placed, the consumer gives the declarations that are shown next to the order button.
Endpoints that complete a purchase themselves (POST /shop/coins/paypal/orders, POST /shop/premium, PUT /shop/hearts) take them as part of the request body (withdrawal_consent, withdrawal_text_version) and reject the order without them.
For purchases that are completed by one of the microservices (unlocking a course, booking a webinar or a coaching) the frontend records the declarations first through POST /shop/consents.
The wording per subject lives in academy_models::withdrawal and is repeated in the purchase confirmation email, together with the version of the withdrawal instruction it was taken from (academy_models::withdrawal::WITHDRAWAL_TEXT_VERSION).
withdrawal_text_version in the request has to be that version; an order that states another one is rejected like an order without the declarations, so a recorded consent can never name an instruction that was never published.
The consents in withdrawal_consents and the copy on the coin order row are deleted with the account, so the invoice record keeps its own copy of the declarations of the order it documents (financial_documents.withdrawal_consent_at and withdrawal_text_version), which lives as long as the invoice does.
Deleting a user (DELETE /auth/users/{user_id}) removes the account from the backend database and then notifies the microservices so they can delete the rows that belong to that user.
The fan-out is implemented in academy_extern (MicroservicesApiService): for every microservice that has a base url in the [microservices] config section, the backend issues a short-lived internal JWT for that service and sends DELETE <base_url>_internal/users/<user_id>.
The requests run concurrently, each with the configured microservices.timeout, and any failure is logged and swallowed — a microservice that is unavailable must not prevent an account from being deleted.
A microservice without a base url is not notified at all; the backend logs at startup which of the three are configured.
Each microservice additionally runs a periodic sweep that removes data of users the backend no longer knows, which catches the deletions that were lost this way.
Invoices and credit notes are the exception: they have to be kept for finance.retention_years years and are therefore not deleted with the account.
Instead, delete_user first replaces the customer details of their records in financial_documents by academy_models::finance::RETENTION_MARKER, and deleting the account row drops the account reference (on delete set null).
Document number, issue date and amounts stay on the record and the archived pdf stays in place, until academy task prune-documents deletes both.
Before that, delete_user asks FinanceInvoiceService::create_final_statement to issue the final statement of the account (AGB Ziffer 6.7).
It records the unused share of the purchased Morphcoins — min(balance, purchased), because reward coins count as consumed first — together with the name and the email address a later refund has to be offered to, so it is the one kind of document that is not pseudonymized.
It is only issued when there is actually something left to refund, that is for an account that bought Morphcoins and has not spent all of them; in every other case there is nothing to refund and therefore no reason to keep anything that names the account.
The record is written inside the transaction that deletes the account, because it carries everything a later refund needs; its pdf is rendered afterwards, so that no database transaction is held open across the http call to the render daemon.
A render daemon that is unavailable therefore only costs the pdf, never the deletion, and the failure is logged with the document number alone.
GET /auth/users/{user_id}/export returns everything the platform stores about one user as a single JSON document (Art. 15 and 20 GDPR); a user can export themselves, an administrator can export anybody.
The account object is assembled by UserExportService from the backend database — the account, the sessions, the linked OAuth2 accounts, the Morphcoin balance and transactions, the hearts, the premium membership, the coin orders, the issued financial documents, the contract declarations and the withdrawal declarations.
The services object is assembled by the same MicroservicesApiService that the account deletion uses: for every configured microservice the backend issues a short-lived internal JWT and reads GET <base_url>_internal/users/<user_id>/export.
Like the deletion fan-out, a microservice that cannot be read does not fail the request; it is listed in services with available: false and no data, and the top level complete is then false, so that an incomplete export is never handed out as if it were complete while the rest of the data still reaches the user.
This covers the configured microservices only. A microservice without a base url is not part of the deployment at all: it is left out of the export silently, is not listed in services, and does not make complete false. Which services are configured is logged when the backend starts.
Each response is limited to microservices.max_export_size bytes.
The database transaction is dropped before the fan-out, exports are limited to one per user per user.export_rate_limit (administrators are exempt), and neither the logs, the error messages nor the export itself contain any of the exported data or the internal urls.
An export an administrator runs on somebody else is written to the administrative audit log, because it hands out more than any endpoint an ordinary user can reach; the same holds for the three other reads listed in Administrative Audit Log.
Every invoice, credit note and final statement that is issued is recorded in financial_documents, keyed by its number (R0000042, G202402-7, S1337), which is also the name of its pdf file in finance.invoices_archive / finance.credit_notes_archive / finance.final_statements_archive.
The record keeps the address block that was printed on the document, so re-rendering it does not pick up later changes to the user's invoice information, and it keeps the totals in cents as they were printed.
An invoice is dated with the time the coin order was captured, because that is when it was paid; a document that has already been recorded keeps the date it was recorded with, so neither the date printed on it nor its retention period ever moves.
Dates are printed, and the calendar year of the retention period is determined, in Europe/Berlin.
Records that were created by the migration for the captured coin orders that predate this table carry only number, date, user and Morphcoin amount; the remaining values are filled in the next time the document is rendered.
Once the retention period has expired, get_invoice_pdf and get_credit_note stop recreating the document.
GET /finance/documents lists the records for administrators, filtered by kind and searched by document number or customer details; documents of deleted accounts have no user_id, and a final statement is found by the email address it still carries.
An invoice also records when the consumer gave the declarations under § 356 Abs. 6 Nr. 2 BGB for the order it documents and which version of the withdrawal instruction they were taken from; the listing shows both. The declarations are also recorded next to the order, but only for as long as the account exists, so this copy is the evidence that is kept together with the document.
A final statement is the only document that records a claim, namely the unused share of the purchased Morphcoins, which is refunded on request. The refund is made by hand, and after the deletion there is no balance left that would show it, so the record is stamped by hand with academy admin finance settle <number> once the money has been sent; settled_at is then set and every listing shows that the claim is closed. Without it the same statement could be handed in twice.
The documents of an account are also part of its data export.
There are some tasks that need to run on a regular basis (e.g. removing expired sessions from the database).
Instead of implementing a scheduler directly in the backend daemon, we rely on external schedulers (e.g. systemd timers or cron jobs) that invoke subcommands of academy task to start the corresponding tasks (e.g. academy task prune-database).
The following tasks exist:
academy task prune-database: Deletes sessions that have not been refreshed withinsession.refresh_token_ttl, administrative audit log entries older than twelve months (academy_models::admin_audit::ADMIN_AUDIT_LOG_RETENTION_MONTHS) and cancellation and withdrawal declarations that are older thancontract.retention_yearsyears, counted from the end of the calendar year in which they were received (§ 195, § 199 Abs. 1 BGB).academy task prune-documents: Deletes the invoices, credit notes and final statements whose retention period has expired, both the records infinancial_documentsand the archived pdf files. The period isfinance.retention_yearsyears, counted from the end of the calendar year in which the document was issued (§ 147 Abs. 3 Satz 1 und Abs. 4 AO). Credit notes that were archived before they were recorded in the database are recognised by the month in their file name.academy task list-orphan-documents: Reports the archived pdf files that no record refers to — documents of accounts that were deleted beforefinancial_documentsexisted are not picked up byprune-documents— and the records whose pdf is missing. It only prints them; nothing is deleted.academy task refresh-premium: Renews the premium memberships of all users who have automatic renewal switched on and whose current period has ended. A renewal always buys a monthly period atpremium.monthly_price, no matter which plan the membership was booked with; a subscription that was booked yearly is rewritten to the monthly plan on its first renewal. If the user does not have enough Morphcoins, the automatic renewal is switched off instead.
The NixOS module in nix/module.nix defines a systemd timer per task (prune-database hourly, prune-documents monthly, refresh-premium daily by default).
The academy executable also provides some other useful commands e.g. for administration, debugging and testing purposes.
academy admin finance settle <number> records that the claim of a final statement has been paid out; it refuses a number that does not exist, a document of another kind and a statement that has already been settled.
The backend is configured using one or more TOML files specified in the ACADEMY_CONFIG environment variable.
This environment variable contains a :-separated list of paths to config files with decreasing priority (i.e. properties set by the first file override those of all other files).
The default configuration found in config.toml is always loaded implicitly with minimum priority.
Usually defaults should be set for all properties except for those that depend on the deployment environment (e.g. database url) or that contain secrets or other sensitive information.
Inside the development environment the ACADEMY_CONFIG environment variable is automatically set to point to the config.dev.toml config file.
academy check-config loads the configuration and reports errors without starting the server.
All properties, their defaults and the ones that have to be set by the deployment are listed in docs/config.md.
The Bootstrap Academy backend follows the Hexagonal Architecture approach. Each component is contained within its own crate, for example:
academy_core: The application core. Each sub-crate contains one feature (collection of related use cases).academy_models: Entities and newtypes used throughout the application.academy_shared: Various helper services that are not directly related to any feature (e.g. id service, time service, ...).academy_persistence: Database adapters and repositories.academy_extern: Adapters for external APIs.academy_api: The API server implementation.academy: TheacademyCLI.
A service refers to both a trait that declares methods for some functionality and an implementation of this trait.
Service traits always live in *_contracts crates, and implementations live in *_impl crates (if there is only one real implementation).
For example in academy_shared_contracts there is an IdService trait which is implemented for IdServiceImpl in academy_shared_impl.
Whenever this service is needed, only a dependency on the IdService trait is required (Dependency Inversion).
This approach allows easily swapping out the implementation for some service which is especially useful for unit testing where dependencies are usually replaced by mocks.
Service traits usually have Send + Sync + 'static as a supertrait bound so they can be used in a multi-threaded async executor like tokio.
Additionally, any async method within these traits needs a Send bound on the returned Future:
trait FooService {
fn foo(&self, x: i32) -> impl Future<Output = i32> + Send;
// instead of
// async fn foo(&self, x: i32) -> i32;
}If a service needs to be mocked within a unit test, the automock macro from mockall can be used on the service trait:
#[cfg_attr(feature = "mock", mockall::automock)]
trait FooService {
fn foo(&self, x: i32) -> impl Future<Output = i32> + Send;
}
// Helper methods to make mock construction in unit tests more readable
#[cfg(feature = "mock")]
impl MockFooService {
pub fn with_foo(mut self, x: i32, result: i32) -> Self {
self.expect_foo()
.once()
.with(mockall::predicate::eq(x))
.return_once(move || result;
self
}
}Service implementors are usually structs which contain all services they depend on plus optionally some configuration and state. Because service implementors are only allowed to depend on service traits (and not their implementations), a type parameter has to be introduced for each dependency on a service:
// the service implementor contains its dependencies (in this case only the `IdService`) as generics
struct FooServiceImpl<Id> {
id: Id,
}
impl<Id> FooService for FooServiceImpl<Id>
where
// in the implementation for `FooService` the type parameters are constrained to their corresponding service traits
Id: IdService,
{
async fn foo(&self) {
// use the id service dependency
let id = self.id.generate();
// ...
}
}For each service its default implementor is set as a type alias in academy/src/environment/types.rs:
pub type Id = IdServiceImpl; // no dependencies
pub type Foo = FooServiceImpl<Id>; // `Id` here is a concrete type instead of a type parameterThe academy_di crate implements the basics of Dependency Injection.
Notably, it provides the Build and Provider traits and some macros to implement these traits.
The Build trait looks like this:
pub trait Build<P: Provider>: Clone + 'static {
fn build(provider: &mut P) -> Self;
}Given a provider P, the build method is expected to construct an instance of the type for which this trait is implemented.
For example, the Build implementations of IdServiceImpl and FooServiceImpl from above could look like this:
impl<P> Build<P> for IdServiceImpl
where
P: Provider
{
fn build(_provider: &mut P) -> Self {
// no dependencies, so we simply return the unit struct
IdServiceImpl
}
}
impl<P, Id> Build<P> for FooServiceImpl<Id>
where
P: Provider,
Id: Build<P>,
{
fn build(provider: &mut P) -> Self {
// first build all the dependencies
let id = <Id as Build<P>>::build(provider);
// then construct Self
FooServiceImpl { id }
}
}In practice, most of these implementations don't have to be written by hand but can be produced automatically by the Build derive macro:
#[derive(Debug, Clone, Build)]
struct IdServiceImpl;
#[derive(Debug, Clone, Build)]
struct FooServiceImpl<Id> {
id: Id,
}The real implementations generated by the derive macro also implement caching to avoid building the same service twice.
In most cases this should make no difference, but some services store some local state which should be created only once.
The cache is provided as a TypeMap by the Provider trait:
pub trait Provider: Sized {
fn cache(&mut self) -> &mut TypeMap;
}This trait is usually implemented by the provider! macro which defines a new struct, implements Provider for it and implements Build for all fields of it:
provider! {
Provider {
num: i32,
}
}
// create the provider
let mut provider = Provider {
_cache: Default::default(),
num: 42,
};
// provide the i32
let num: i32 = provider.provide();
// provide Foo (type alias for FooServiceImpl<IdServiceImpl>)
let foo: Foo = provider.provide();The main providers are defined in academy/src/environment/mod.rs.
The default [learning_policy] mode = "legacy" preserves existing hearts, purchases,
and agreements. shadow reports a measurement mode to skills without changing
these rules. Skills owns lesson starts and daily counts; backend owns the user
policy and paid Premium status.
GET /shop/_internal/learning-policy/{user_id} uses the existing shop internal
token. GET /shop/learning/policy uses the caller's normal access token. Both
return {mode, premium, single_course_sales, heart_sales}; policy GETs only read
paid periods and never renew or debit coins. Funded, confirmed opt-in renewals
awaiting settlement return an error, not a guessed free status. Known insufficient
coins or a cancelled renewal do not create indefinite unavailability. Existing
Premium status reads and the refresh-premium task settle renewal; the task
remains daily by default. Database/auth failures remain
errors. Existing learning-resources responses also include learning_policy,
with their previous resource fields retained.
Daily mode requires the explicit configuration terms_version, accepted_since
(RFC3339), and either user_ids or registered_since. A user must belong to the
selected cohort and have recorded acceptance of exactly that terms version at
or after the configured date. No migration edits acceptance, paid periods,
balances, orders, or original documents. No registration terms version changes
implicitly. Old-contract users outside that intersection remain on legacy rules.
Before daily mode can start, [learning_policy.daily_documents] must bind the
same terms_version and absolute terms_pdf_path / withdrawal_pdf_path, each
with its exact approved terms_sha256 / withdrawal_sha256 (lowercase SHA-256).
The application reads and validates these PDFs once at startup. Missing,
mismatched, oversized, non-PDF or legacy-r4 daily terms fail startup. New daily
orders store these exact bytes and their combined document hash; legacy orders
continue to bind the existing r4/r1 originals. A document change invalidates
unaccepted offers, while accepted orders keep their stored documents and recovery.
This mechanism does not approve or publish any new terms.
In daily mode, new course and heart offers and their first acceptance are rejected server-side, including offers created before the transition. Accepted historical orders remain fulfillable and replayable. Premium keeps the configured coin prices, calendar durations and existing opt-in renewal arrangements; its new daily offer copy describes unlimited new lesson starts.
New incorrect-attempt operations under daily policy finish with
outcome: "daily_learning", charged_half_hearts: 0 and the unchanged effective
heart balance. Already completed IDs replay their immutable original receipt.
Unapplied old outbox operations do not become delayed charges after a transition.
Apply the additive receipt migration before deploying code that can emit the new
outcome. The old receipt outcomes and historical records remain valid.
Rollout and recovery: ship with legacy policy, optionally measure with shadow,
then enable a specifically approved cohort only after its documents and transition
are approved. Once users have adopted daily terms, pause enforcement in skills
while retaining their backend policy. Do not roll back their agreed access by
resetting the backend policy to legacy. A recovery generation must understand
daily_learning receipts and retain the same policy/documents configuration.
The existing anonymous GET /shop/premium/renewal-offer remains the legacy
monthly offer. Authenticated clients use GET /shop/premium/renewal-offer/me
with the same {id, monthly_price, terms_version, text} shape. It selects the
user's policy and the same approved document bundle as ordinary purchases.
PUT /shop/premium/autopay retains its existing request shape and validates
that exact offer before recording a new agreement. Existing accepted agreements
replay their original offer identity; their paid periods, fixed coin price,
cancellation and delivery evidence remain unchanged. Daily renewal enrolment
never binds r4, never charges coins on enrolment and creates no PayPal mandate.
Selective signup uses optional user.registration_terms_version (default absent:
use user.terms_version). Only new registration validates and records that
selection. Keep ordinary user.terms_version at r4 for existing accounts, and
retain the signup selector after adoption to protect that version from an old
client's r4 acceptance form. Signup selection offers no existing-account opt-in;
that transition still needs a separately approved mechanism and decision.
Frontend signup document/version controls must match the selected registration
version; valid r4 and selected signup-version users must not receive a global
forced conversion gate. No defaults or shipped documents change here.
Authenticated monthly renewal offers use /shop/premium/renewal-offer/me. Their
exact pre-acceptance PDFs are downloadable at
/shop/premium/renewal-offer/me/{offer_id}/documents/{kind} (terms|withdrawal).
The same builder chooses and hashes the selected documents. Stale offer IDs,
including after a policy or bundle change, return 409 with no PDF. These read-only
downloads are private/no-store; saved accepted agreements keep their originals.