Skip to content

0022. The app host builds settings from declarations, checks at app loading, and boots when a handler is built

  • Status: Proposed
  • Date: 2026-11-12

0002 says the app host builds settings from declarations and boots one app or many, and that a conventional Django project is such a host. Spec 05 gives the host seven phases, and APP-1 requires that a failure in any phase before Serve stops the host. 0017 Decision 4 gives the host its roots: a request root per request and an explicit scope for commands and background work. Roadmap step 4 (#11) has to say where each of these lives in Django, and what a deployer writes. “PR 2” to “PR 9” below are the pull requests of #11’s plan.

This record relies on two other Proposed records of the step: 0021, for the form of each declaration field and for Resolve and Validate, and 0009 as amended, for database aliases and the default alias. The principal backends record (0023) adds to what this record sets up.

Django was read at 5.2.19 and 6.1.3. Paths are relative to the django package, and the cited code is the same in both versions unless a note says otherwise. “Probe” marks a fact that was also run on both versions. “Not run” marks a design point that was reasoned from the source only.

Django is built around a project, so several of its seams sit in places that matter here:

  1. System checks run only from management commands. BaseCommand.execute calls self.check() (core/management/base.py), and runserver calls it in inner_run (core/management/commands/runserver.py). get_wsgi_application() and get_asgi_application() call django.setup() and return a handler (core/wsgi.py, core/asgi.py). A host started by a WSGI or ASGI server therefore runs no system check unless the host runs it. Django’s documentation gives the reason: “For performance reasons, checks are not run as part of the WSGI stack that is used in deployment” (system check framework). BaseCommand.check drops a silenced message at every level, errors included (not e.is_silenced()).
  2. Django warns about unapplied migrations and serves. runserver calls check_migrations, which prints a notice (core/management/base.py). migrate --check exits 1 when the plan is not empty (core/management/commands/migrate.py). migrate <label> raises CommandError("App '<label>' does not have migrations.") for a label outside loader.migrated_apps (line 149 in 5.2.19, 151 in 6.1.3). migrate also runs the checks tagged database for its alias before it migrates (get_check_kwargs, same file).
  3. ready() runs in INSTALLED_APPS order, after every model module is imported and before apps.ready is set (apps/registry.py, Apps.populate). Probe: the last app’s ready() can call apps.get_app_configs() and checks.run_checks(tags=[...]).
  4. Database access before apps.ready raises a RuntimeWarning (db/backends/utils.py, APPS_NOT_READY_WARNING_MSG), which is an error under 0012’s warning filter.
  5. A handler builds its middleware in its constructor. WSGIHandler.__init__ and ASGIHandler.__init__ call load_middleware() (core/handlers/wsgi.py, core/handlers/asgi.py), which instantiates each middleware once (core/handlers/base.py). The test client builds its handler on the first request (test/client.py). Probe: an exception from a middleware’s constructor leaves get_wsgi_application(), so the server fails to load.
  6. Sync database access on a thread with a running event loop raises SynchronousOnlyOperation (utils/asyncio.py, async_unsafe), and an ASGI server may import its handler inside its loop. Connections are per thread (utils/connection.py; db/utils.py, thread_critical = True). Probe: the same read on a thread of its own works while a loop runs.
  7. Reading migration state writes nothing. MigrationRecorder.applied_migrations() returns {} when the table is missing; only record_applied and record_unapplied call ensure_schema (db/migrations/recorder.py). An app with MIGRATION_MODULES[label] = None, or with no migrations package, lands in MigrationLoader.unmigrated_apps, and its leaf_nodes(label) is empty (db/migrations/loader.py). Probe: connecting to a SQLite file that does not exist creates it, empty.
  8. Settings can be given without a module. settings.configure() admits upper-case names only and raises “Settings already configured.” on a second call (conf/__init__.py). AppConfig.create(entry) imports the app package and its apps module and reads no setting (apps/config.py). Probe: it returns the config class’s instance before settings exist.
  9. An app that reads a setting at import behaves differently in the two places a declaration is read. Probe: with no settings and no DJANGO_SETTINGS_MODULE, the read raises ImproperlyConfigured. Inside a settings module that is still being imported, the read raises nothing: LazySettings._setup runs again, builds a Settings from the half-imported module, and the app sees the names defined above that line and not those below it. In both places settings.configured is false before the import and, when a read succeeded, true after it.
  10. override_settings(INSTALLED_APPS=...) runs every ready() again, through apps.set_installed_apps (test/utils.py, apps/registry.py); modify_settings and pytest-django’s settings fixture take the same path. Probe: two calls. Django’s documentation expects it: “either write idempotent methods, or put a flag on your AppConfig classes” (AppConfig.ready()). override_settings also sends setting_changed for each name, and an exception from a receiver on entry ends the override and is raised (test/utils.py).
  11. Django changes settings.DATABASES in place. ConnectionHandler.configure_settings fills defaults into each alias’s mapping (db/utils.py), and the test runner writes the test database’s name into settings.DATABASES[<alias>]["NAME"] (db/backends/base/creation.py). A comparison of that setting therefore holds only at process start, and only for the keys the host wrote.
  12. Django ships a WSGI server, built for development. WSGIRequestHandler.get_environ drops headers with an underscore in the name, and ThreadedWSGIServer closes the thread’s database connections when a connection closes, which with keep-alive is after its last request and not after each (core/servers/basehttp.py, close_request; probe). The same module’s run(), which runserver uses, builds its server by mixing ThreadingMixIn into the plain WSGIServer, not from ThreadedWSGIServer; it sets daemon_threads = True and calls serve_forever(); runserver leaves on KeyboardInterrupt. Neither waits for a request in flight. Django’s documentation says this server is not meant for production. Django itself starts and stops the same classes under control: LiveServerThread sets server_class = ThreadedWSGIServer, and its terminate() calls httpd.shutdown() and then httpd.server_close() (test/testcases.py). It backs the documented LiveServerTestCase.
  13. Django signals the start and the end of each response. WSGIHandler.__call__ sends request_started with send, not send_robust, so a receiver that raises ends the request before any response exists; HttpResponseBase.close() sends request_finished (core/handlers/wsgi.py, http/response.py). A count kept between the two signals can therefore stay above zero for good.
  14. After shutdown() and server_close(), a kept-alive connection is still served. Probe, with ThreadedWSGIServer and a response that has Content-Length: a plain and a streamed response in flight both complete, and a new connection is refused, but a new request sent on a connection that was already open gets 200 OK. Each connection’s thread loops in WSGIRequestHandler.handle until close_connection is set.
  15. ASGIHandler accepts only http scopes (core/handlers/asgi.py), so no ASGI lifespan message reaches Django.
  16. ROOT_URLCONF has no default. get_resolver reads it at the first request (urls/resolvers.py), and the URL checks skip when it is unset (core/checks/urls.py).
  17. Contrib apps. admin, auth, contenttypes and sessions carry migrations; messages, staticfiles and humanize do not (contrib/*/migrations).
  18. manage.py goes on after a settings module fails to import. ManagementUtility.execute catches the ImproperlyConfigured that reading settings.INSTALLED_APPS raises, and calls django.setup() whenever settings.configured is true (core/management/__init__.py). After the read of fact 9, it is true: Django holds a Settings built from the half-imported module. Probe, with a settings module whose last line raises after an app read a setting: manage.py check printed “System check identified no issues” and exited 0, and a second get_wsgi_application() in the same process built a handler with an empty INSTALLED_APPS. A settings module that failed to import is not kept in sys.modules, so every later read of a setting imports it again.

Constraints from the rules: fail at startup (K2, APP-1), no new runtime dependency without an Accepted record (K5), host-owned state only (K6), secure by default (K9), and one design for new and existing projects (principles 9 and 10).

  1. The host only checks settings the deployer wrote. An existing project changes least. The declarations and the settings then state the same facts twice.
  2. One builder makes the settings a declaration determines, for a command line and for a settings module alike, and the host checks itself at app loading and before it serves. One function stands behind neolith run and a conventional project. A settings file no longer lists the installed apps.
  3. The command line is the only entry. One way to reach Django. Tools that read DJANGO_SETTINGS_MODULE would stop working.

Option 2, in the twelve decisions below. Each weighs its own alternatives, options 1 and 3 among them (Decision 1).

Every input has one form. Anything else stops the host before Serve with ImproperlyConfigured or a subclass of it, whose message names the app or setting and the input. The builder stops at the first hosted app it cannot read, since Python stops there: 0021’s DeclarationError from a class definition, or any other error from the import. The refusals it finds by comparing what it has read are reported together per phase, sorted, and a later phase does not run. Set-up reports the refusals of Resolve and Validate together, as 0021 Decision 8 asks, and then its checks. Every container in these tables is exactly the type named, a list or a dict: a tuple or a subclass is refused with <input>: expected a <type>, got <type found>.

This record uses 0009’s words for the kinds of installed app: a hosted app has a declaration, and an undeclared app is any other installed app, a battery included. Set-up is the work of Decision 5, which runs when Django loads apps.

The builder’s inputs (Decisions 1 to 4):

Input Admitted Refused, with its message
Hosted apps A list of one or more str, each the import path of an app package, such as <pkg>.<app>. No entry twice. Each package holds one declaration (0021). host: no apps named; host: app <path> named twice; app <path>: cannot be read: <error>, for any error from reading it other than 0021’s own DeclarationError, which passes through as it is; app <path>: has no declaration, when the package gives Django a plain config; app <path>: its declaration names <name>; name the package the declaration names, when the declaration’s name is not the entry, since Django would register the app under that other path; app <path>: name the app's package, not its config class; app <path>: reads Django settings at import; a declaration is read before settings exist (Decision 1); settings: a setting was read before build_settings was called, when the settings Django holds were built from the module that is calling the builder (Decision 1).
Two hosted apps Different labels. apps <path> and <path>: both have the label <label>.
A hosted app’s label Not a label the host reserves (0009). 0009’s message.
A requires entry Provided by a hosted app. Remote providers arrive in step 5 (#12). 0021’s Resolve refusal, a DeclarationError.
A batteries entry A Django app name in dotted form, as 0021 defines it. The host installs it (Decision 4). Not a hosted app. app <label>: batteries: <name> is a hosted app; a hosted app is reached through a contract.
The deployer’s mapping A dict of names to values. Names that are not upper case are ignored, as Django ignores them. settings: expected a dict of setting names.
INSTALLED_APPS, DATABASES, DATABASE_ROUTERS Absent. They are host output. settings: <NAME> is built by the host; see NEOLITH["UNDECLARED_APPS"] (or NEOLITH["DATABASES"]).
NEOLITH Present, a dict whose keys are in the host’s key table: DATABASES, REQUEST_DEADLINE and UNDECLARED_APPS here, and PRINCIPAL_BACKENDS, which 0023 defines. settings: NEOLITH is required; it is {} when nothing is set; NEOLITH: expected a dict; NEOLITH: unknown key <repr>; the keys are <the table's keys, sorted>.
A key of NEOLITH whose value is a list or a mapping Not empty. A key with nothing to say is left out, so that “nothing” has one spelling. NEOLITH["<KEY>"]: is empty; leave the key out.
NEOLITH["DATABASES"] Absent, or the mapping of 0009 as amended, whose table lists its own refusals. 0009’s messages.
NEOLITH["REQUEST_DEADLINE"] Absent (30 seconds), None (no deadline), or a duration: a finite int or float of seconds, not a bool. As a configured value it lies from 0.001 to 9999999.999, which is 1 ms to 9999999999 ms (Decision 3). NEOLITH["REQUEST_DEADLINE"]: expected a number of seconds or None, got <type>; ...: <value> is not finite; ...: <value> is outside 0.001 to 9999999.999 seconds.
NEOLITH["UNDECLARED_APPS"] Absent, or a list of one or more str, each an INSTALLED_APPS entry as Django defines one: an app’s package, or the path of a config class, which chooses that class. No entry twice, none that is a hosted app, a battery or neolith, each compared as written. NEOLITH["UNDECLARED_APPS"]: expected a list of str; ...: <entry> listed twice; ...: <entry> is a hosted app; ...: <entry> is installed as a battery of <label>; ...: neolith is installed by the host.
MIDDLEWARE A list of str whose first entry is neolith.middleware.RootMiddleware, listed once. 0023 adds its own row for the principal middleware. MIDDLEWARE: neolith.middleware.RootMiddleware must be the first entry; MIDDLEWARE: neolith.middleware.RootMiddleware is listed <n> times.
SILENCED_SYSTEM_CHECKS No id that starts with neolith.E. SILENCED_SYSTEM_CHECKS: <id> is a host error and cannot be silenced.
Any other upper-case name Passed to Django unchanged. It is Django’s input, not neolith’s, and Django’s own checks judge it. Set-up reads CACHES for 0009’s row on a database cache, and Boot reads ROOT_URLCONF. None from the builder.

0021 refuses publishes and subscribes when the declaring class is defined, so the builder never meets them.

At set-up, after app loading (Decision 5). These are system checks under the tag neolith. The ids in this record are working numbers, fixed by the PR that adds each check.

Input Admitted Refused, with its message
settings.INSTALLED_APPS, DATABASES and DATABASE_ROUTERS, at the first set-up in a process What the builder gives for the installed declarations and settings.NEOLITH: the same app entries in the same order, the same aliases, every key the host wrote with its value, and the same routers. Keys Django filled in are ignored (fact 11). neolith.E005: settings: <NAME> differs from what the host builds: <first difference>; build settings with neolith.host.build_settings, and change them through NEOLITH.
settings.MIDDLEWARE The row of the builder’s table. neolith.E006, with that row’s messages.
settings.NEOLITH, SILENCED_SYSTEM_CHECKS The rows of the builder’s table. Those rows’ messages.
An installed app with a declaration Hosted, and not a battery. neolith.E003: app <label>: has a declaration and is listed in NEOLITH["UNDECLARED_APPS"]; name it among the host's apps (or ...and is named as a battery of <label>).
An installed app with models and no declaration, a battery included Admitted when NEOLITH["DATABASES"] has a default, and its tables live there (0009). Refused in a closed host (Decision 4). neolith.E004, with 0009’s message for this row: app <label>: has models and no declaration, and this host has no default database (and ; it is a battery of <label> where that is so).
The other rows 0009 checks after app loading 0009’s table. With 0009’s messages: neolith.E014 for a hosted app with models and no database resource, neolith.E015 for a default that nothing lives on, neolith.E016 for a database cache in a closed host, neolith.E017 for a table name that default shortened.
A ModelAdmin class in no installed app Admitted, with a warning (0017 Decision 4). 0017 asks neolith check to warn about every view in no installed app; step 4 ships the admin-class part, as #11 plans, and the rest arrives with neolith check (step 7, #14). neolith.W001: <module>.<class>: admin class in no installed app; its views have no calling app, and a contract call from them is refused.
A later set-up in the same process The hosted apps of the first set-up, each still installed with the same config class, neolith installed, and no further app with a declaration. Undeclared apps may come and go, where the checks of this table still pass. host: the hosted apps changed after set-up: <difference>; a process hosts one set of apps.
setting_changed for NEOLITH, DATABASES or DATABASE_ROUTERS A NEOLITH that passes the builder’s table and differs from the checked one only in REQUEST_DEADLINE, or in a key whose record says it may change. NEOLITH["<KEY>"]: cannot change after set-up; settings: <NAME> cannot change after set-up.

At Boot, when a handler is built (Decision 7):

Input Admitted Refused, with its message
The root middleware’s own position First in the settings.MIDDLEWARE the handler was built from. The MIDDLEWARE messages above.
ROOT_URLCONF Set, and importable. settings: ROOT_URLCONF is not set; this host is about to serve requests; settings: ROOT_URLCONF <value> cannot be imported: <error>.
Every SQLite alias of NEOLITH["DATABASES"], default included, whether or not Boot reads migrations there Not a database held in memory or in a temporary file. The host asks SQLite, not the name: on a connection Boot opens to the alias, PRAGMA database_list gives an empty file for main (Decision 7). neolith.E007: database <alias>: an in-memory SQLite database is empty on a new thread, or gone once its last connection closes; a host that serves needs a file or a server.
A hosted app’s schema, on its own alias Migrations found in the app’s package, none unapplied, history consistent, no conflict. neolith.E008: app <label>: has a database and no migrations; neolith.E009: app <label>: database <alias> has <n> unapplied migrations, the first is <name>; run the migrate step; neolith.E010: app <label>: database <alias>: <Django's inconsistency message>; neolith.E011: app <label>: conflicting migrations <names>; neolith.E012: app <label>: database <alias> cannot be read: <error>.
A hosted app’s migrations No dependency on a migration of another label (0009 Decision 7). neolith.E013, with 0009’s message: app <label>: migration <name> depends on <other label>.
An app without a declaration, on a real default With migrations: none unapplied, as above, read per label. Without migrations: not read, whatever models it has. neolith.E009 to E012, with the alias default.

The command scope, the migrate step and the command line (Decisions 8 to 11):

Input Admitted Refused
command_scope(app, *, principal=None, timeout=None) app: the label of a hosted app, a str. principal: a principal value (0010 Decision 2) or None. timeout: None or a duration, a finite int or float of seconds, not a bool, of any size (Decision 8). Used once, with with. The scope is named by label, where the builder and the command line take an app’s package path. A usage error (CON-29), never a contract error: TypeError for a value of another type, ValueError for a timeout that is not finite, and neolith.UsageError (0021 Decision 9) for "<label>" is not a hosted app, the host is not set up yet and a scope entered twice.
The migrate step’s labels Each hosted app with a database and with migrations, none of which depends on another label. On a real default, each app without a declaration that has migrations. neolith.E008 for a hosted app with a database and no migrations, and neolith.E013 for one whose migrations depend on another label, each before any database is touched. An app without a declaration and without migrations is skipped, with a line that says so.
neolith run <app> [<app> ...] --settings <module> [--bind <host>:<port>] Each <app> is the import path of an app package, as in the builder’s first row. --settings is required and names an importable module that holds the deployer’s names and does not call the builder. The host it builds is closed (Decision 4). --bind defaults to 127.0.0.1:8000; an IPv6 host is written [<addr>]:<port>. An unknown option, a missing --settings or a malformed --bind: usage text and exit status 2. settings: <module> already calls build_settings; pass the module that holds the deployer's names, or serve this one through DJANGO_SETTINGS_MODULE. database default: neolith run hosts declared apps only in 0.1.
neolith migrate <app> [<app> ...] --settings <module> As above, without --bind. As above.
  1. One builder, with two callers. neolith.host.build_settings(apps, deployer) takes the hosted apps and the deployer’s mapping and returns the whole settings mapping. It runs Declare, Resolve and Configure, in that order, and reads no Django setting. Its result depends on its arguments and on what importing the apps does, and it records its first refusal (below); the rows themselves are a plain function behind it, which records nothing. Two callers reach Django with it:

    • The command line. neolith run and neolith migrate take the apps from their arguments and the deployer’s mapping from the module named by --settings, then call settings.configure(**built) (fact 8).
    • A settings module. A Django settings module ends with globals().update(build_settings([...], globals())). Every tool that expects DJANGO_SETTINGS_MODULE then works unchanged: manage.py, a WSGI or ASGI server, pytest-django, the django-stubs plugin. This is the form for an existing project, for a production deployment of one app, and for neolith’s own test host. The call shape has precedent: Mezzanine’s project template ends its settings module with set_dynamic_settings(globals()).
    • One module can feed both. The deployer’s names live in a module with no builder call, which neolith run --settings takes. A settings module for the same host imports those names and adds the call. A module of the other kind is refused by name in each place (the table’s command-line row, and settings: <NAME> is built by the host).
    • A declaration that reads settings is refused. The builder notes settings.configured before it imports each app package and again after. A change from false to true means the import read a setting, whether Django answered from a half-imported module or from the environment (fact 9), and the builder raises the table’s message. When the import itself raised, that error is the refusal, under its own row: 0021’s DeclarationError as it is, and anything else as cannot be read, with the error as the cause. That covers the read Django refuses on its own, and an apps module that imports models, which Django stops through a read of INSTALLED_APPS. This reads Django’s state and no setting.
    • A known gap: the detector sees a read that Django answered. Under the command line Django answers none, so an app that reads a setting inside its own try and falls back is admitted there and refused in a settings module (probe). Its fallback value is the app’s own, not a half-built setting, so nothing of facts 9 and 18 follows; the guide says that a declaration reads no setting.
    • A setting read before the builder is called is refused too. When the builder starts and Django already holds settings built from the module that is calling it (settings.SETTINGS_MODULE is the __name__ in the deployer’s mapping), some line above the call read a setting from the half-imported module (fact 9). The builder refuses with the table’s message. It could not tell an app’s own read from that one, and the code that read early holds values the finished module may not have.
    • A refusal leaves Django without settings, for the rest of the process. A refusal raised inside a settings module is not the end under manage.py, which catches it and goes on with whatever settings Django holds (fact 18). So the rule is one for every refusal, whatever its row, 0021’s DeclarationError from an import included. Before the builder raises, it does two things. It records the refusal, and every later call of the builder in that process raises that same refusal again, whatever its arguments. A second build could otherwise pass: an app that read a setting is already imported and reads nothing the second time. And where Django holds settings that came into being during this build, or from the calling module before it, the builder puts them back to unconfigured, by resetting settings._wrapped, the attribute Django’s own override_settings assigns (test/utils.py); the access lives in neolith._compat. Every later read of a setting then imports the settings module again and meets the recorded refusal. Settings that Django held before the builder was called, from another source, are left alone, as in a test process. Probes, on both versions, with a builder that follows this rule, for three paths: an app that reads a setting; an app whose import reads a setting and then raises a DeclarationError; and a line above the builder call that reads a setting, followed by a refusal of another row. In each, manage.py check and runserver --noreload end in the refusal with a non-zero status, and a first and a second get_wsgi_application() both raise it. Without the record, the second one built a handler in the first path; without the reset, check passed in all three. The recorded refusal is host-owned state of the kind K6 permits.
    • The rows are a plain function, and build_settings is a thin entry around it. The plain function runs Declare, Resolve and Configure and raises a refusal without recording anything or touching Django’s settings. build_settings, the entry a settings module and neolith run call, adds the three things above: the check for a setting read before the call, the record, and the reset. Tests of the rows, the Hypothesis test among them, call the plain function, so one refused example leaves nothing behind for the next; no fixture has to clear a record. Only the tests of the entry itself meet the record, and they go through manage.py or a second load, each in a process of its own. PR 7 names the plain function; it is not part of the public surface.
    • Resolve is 0021’s function. The builder calls it early, because Configure needs its result. Set-up calls it again on the registry’s classes (Decision 5), so PRs 2 to 6 have it before the builder exists.
    • Why: 0002 makes neolith run <app> the central capability and a conventional project a host. One function behind both keeps them from drifting, and a function can be tested with built arguments; what the plain function touches besides them is the apps’ imports and Django’s settings.configured, and only the entry around it keeps a record.
    • Weighed: the host only checks settings the deployer wrote. An existing project changes least, and nothing is rewritten at import. But the declaration and the settings would then state the same facts twice, and 0009 as amended already needs DATABASES built.
    • Weighed: the deployer keeps INSTALLED_APPS and the host appends to it, as Open edX does with INSTALLED_APPS.extend(get_plugin_apps(...)). The list stays readable in the file. The name would then mean one thing above the call and another below it, and the host could not place its own app last without rewriting the list anyway.
    • Weighed: no helper at all. django-oscar built the list with get_core_apps() and removed it in 2.0 in favour of plain INSTALLED_APPS entries. That is evidence that adopters value a list they can read. Here the list follows from the declarations, which Oscar’s did not, and Decision 5 admits hand-written settings that match.
    • Weighed: the hosted set as a NEOLITH key, with neolith run taking no app. It gives one place for the list. It also parts from 0002’s neolith run <app>, which is Accepted, so it would need a superseding record for a gain in tidiness only.
    • Weighed: the command line as the only entry, with manage.py and wsgi.py calling the host. One way to reach Django. Tools that read DJANGO_SETTINGS_MODULE would stop working, which principle 10 weighs against.
    • Weighed: DJANGO_SETTINGS_MODULE as the fallback for --settings, as django-admin has. That variable names a complete settings module to every Django tool. The module neolith run takes is not one until the builder has run, so one variable would carry two meanings. The flag keeps Django’s name because the module holds Django settings.
    • The cost: the hosted set reaches the builder from two call sites, and the builder imports each app package while the settings module is being imported. A settings file no longer shows the installed apps to a reader, or to a tool that reads the file without running it; manage.py diffsettings prints the built list.
  2. A setting belongs to the host or to the deployer, never to both. The host builds INSTALLED_APPS, DATABASES and DATABASE_ROUTERS, and refuses them in the deployer’s mapping. It validates NEOLITH, MIDDLEWARE and SILENCED_SYSTEM_CHECKS as the table says. It reads two more without owning them: CACHES at set-up, for 0009’s row on a database cache, and ROOT_URLCONF at Boot (Decision 7). Every other name is Django’s and passes through unchanged, SECRET_KEY, ALLOWED_HOSTS, ROOT_URLCONF and TEMPLATES among them. The host supplies no default for any of them (K9).

    • Why: a merged list has two authors and no single place to read it. The three built names are the ones a declaration determines: which apps are installed, and where each app’s data lives.
    • SILENCED_SYSTEM_CHECKS. Django lets a deployer silence errors (fact 1). A silenced neolith.E id would let a host serve past a failure APP-1 says must stop it (K2), so the setting is refused. Weighed: ignoring the silencing when set-up raises, which leaves Django’s setting untouched; manage.py check would then report nothing while the host refuses to start, and the deployer’s instruction would be dropped without a word.
    • Weighed: building MIDDLEWARE too. See Decision 6.
    • Weighed: a closed list of admitted Django settings. It would match the admitted-set rule to the letter, and it cannot be kept: third-party apps define settings the host has never heard of. The table says so openly instead: those names are not neolith’s input.
  3. settings.NEOLITH stays the deployer’s dict, and the checked view is the host’s own state. This record defines three keys, DATABASES (whose content is 0009’s), REQUEST_DEADLINE and UNDECLARED_APPS, and 0023 defines a fourth, PRINCIPAL_BACKENDS. The host keeps one key table; each later record that adds a key adds a row with its one form, as 0023 does, and the unknown-key message is built from the table. A key whose list or mapping would be empty is left out, in every row; NEOLITH itself is the one exception, written as {} so that a host cannot run on a misspelt setting name. The builder checks the dict and returns it unchanged. Set-up builds a frozen view from it, holding the hosted apps and the checked values, and every part of the host reads that view (K6).

    • REQUEST_DEADLINE is the budget of a request root, counted from the start of the request (0017 Decision 4). A timeout or deadline given as an option or a setting, to a connector call, a scope or NEOLITH, is a number of seconds: a finite int or float, not a bool. Step 3’s design gate on #10 gives a connector call’s timeout= that form, as Python does for socket.settimeout and asyncio.timeout, and Django writes its own duration settings as numbers of seconds (SESSION_COOKIE_AGE, CONN_MAX_AGE). A duration inside a contract’s records is another matter and stays as 0020 has it, neolith.Duration, a timedelta: that is a value with a wire form, where an option never crosses a call as it is written. Code that takes a duration from a record and gives it as a timeout converts it with total_seconds().
    • A configured deadline out of range is refused, as a decision of this record. 0017 Decision 3 refuses no budget: at a call, one under 1 ms fails with timeout and one above 9999999999 ms counts as that. A setting is read once, before the host serves, so a value outside that range is taken as a mistake and stops the host (K2): a deadline under 1 ms would fail every call of every request, and one above the cap would say something the host does not do.
    • Weighed: a datetime.timedelta. It carries its own unit, where a bare number leaves seconds to be learnt. It is the type 0020 gives a duration in a record, so one type would serve both. It would also be a second form for options, beside the connector’s timeout=.
    • Why a plain dict: it is the convention Django REST framework set with REST_FRAMEWORK and api_settings: the setting is what the deployer wrote, the checked view is a separate object, and a setting_changed receiver keeps the view in step. DRF refuses an unknown name only when it is read; this record refuses an unknown key when the setting is checked (K2). diffsettings and any tool that prints or serialises settings meet an ordinary value.
    • Changes after set-up. The host connects a setting_changed receiver (fact 10). An override of NEOLITH is checked by the builder’s table; it may change REQUEST_DEADLINE, and any key whose record says it may change, and the view follows for as long as the override lasts. A change to any other key, or to DATABASES or DATABASE_ROUTERS, raises by name. MIDDLEWARE needs no receiver: the root middleware checks its own position every time a handler is built (Decision 7). A value changed in place, with no signal, is not seen; the host’s view does not move with it.
    • Weighed: the builder replaces NEOLITH with a frozen type of its own, which also marks that the builder ran. It gives set-up one thing to test for. A marker says who made the settings and not whether they are still what was made, and Decision 5’s comparison answers both.
    • Weighed: a DRAIN_TIMEOUT key. Decision 10 bounds Drain by the request deadline, with a stated bound when there is none, so the key would be a second number to keep in step with the first.
  4. INSTALLED_APPS is built in a fixed order: hosted apps as given, the UNDECLARED_APPS entries as given, batteries, then neolith.

    • Batteries. A battery is a Django app name in dotted form, such as django.contrib.humanize, as 0021 proposes. The host installs it: that is what spec 05 means by “so the host can enable them”. Batteries follow the order of the hosted apps and, within one app, the order written, each installed once. Enabling a battery means installing the app and nothing more. The middleware, templates and context processors it needs stay the deployer’s settings, and an app it depends on is named as a battery too; Django’s own checks name what is missing, as admin.E408 does. There is no table of batteries in the host, so a third-party app is declared the same way as a contrib app.
    • What the host checks about a battery, and where: that it is not a hosted app (builder); that it carries no declaration (neolith.E003, at set-up, since reading a contrib app’s config before settings exist can read settings); and that it has no models in a closed host (neolith.E004). 0021 leaves these three to this record.
    • Closed hosts. A host is closed when NEOLITH["DATABASES"] has no default, the definition 0009 also uses: every installed app with models then has a declaration, because there is nowhere else for tables to live. The definition rests on the host’s content and not on how it was started. neolith run and neolith migrate build closed hosts only in 0.1 and refuse a default. A settings-module host with a default admits batteries with tables and undeclared apps with models, on default. This keeps the repository’s open question 4 open: no detached host gets its own copy of auth by default.
    • Order. Django resolves templates, static files and commands by the first app that has them. Hosted apps and UNDECLARED_APPS entries come before batteries, so a project’s overrides of contrib templates keep working, and those entries keep the order the deployer gave. neolith is last so that its ready() runs after every other (fact 3, Decision 5).
    • Weighed: a table in the host from short names such as "staticfiles" to the apps each installs. The host could install a battery’s dependencies and know ahead of app loading which have tables. 0021 weighs the same form and keeps Django’s app names; a table would also leave a third-party app without tables, such as a filter library, outside the declaration.
    • Weighed: the deployer installs batteries and the host only checks them. The declaration would then say what an app needs without the host acting on it, and neolith run <app> would need an app list from the deployer after all.
    • Weighed: the deployer’s INSTALLED_APPS as written, with hosted apps found by scanning it. An existing project keeps its list. The builder would then load every third-party app’s config during settings import to find the declarations.
    • The cost: an existing project moves its list to NEOLITH["UNDECLARED_APPS"], without the apps it now hosts and without their batteries. A project that needs a contrib app in a particular place among its own cannot say so through a battery. A battery is installed with the config class Django picks for its name; the deployer has no input to choose another for it in 0.1. A declaration that names a battery with tables is valid in a host with a default and refused when the app runs alone under neolith run.
  5. Set-up runs when Django loads apps, in every process, and can run again for the same hosted apps. The ready() of the neolith app, the last one, does this: it builds the checked view (Decision 3); it runs Resolve and Validate, which raise one DeclarationError listing every refusal (0021 Decision 8); and it runs the checks registered under the tag neolith with checks.run_checks(tags=["neolith"]). Any message at ERROR or above raises ImproperlyConfigured listing all of them. Warnings are logged once on the neolith.host logger and stay visible to manage.py check.

    • Two kinds of failure, each with one owner. Resolve and Validate refusals are DeclarationErrors and not system checks, as 0021 proposes. The host’s own static rules are system checks with ids. Both stop the host from the same place.
    • Settings are compared, not marked. The first set-up in a process runs Configure on the installed declarations and settings.NEOLITH, and compares the result with the settings Django holds (neolith.E005), then checks MIDDLEWARE (neolith.E006). Statements after the builder call in a settings module, such as an import of a base module followed by INSTALLED_APPS += [...], are caught here, and so is a settings module written by hand that installs neolith. Hand-written settings that match are admitted. Not run: the comparison of DATABASES once Django has filled defaults in.
    • Why at app loading: Django runs system checks only from commands (fact 1), so under a WSGI or ASGI server they would never run, and APP-1 would rest on the deployer remembering manage.py check. Django leaves them out of the serving path for performance. That weighs less here: only the neolith tag runs, its checks are static and read no database, and they run once per process. Under a management command they run twice, once here and once in the command, and a warning is then both logged and printed.
    • Why system checks at all: each failure gets a stable id and Django’s message format, neolith check in step 7 (#14) can list them, and a check is a plain function a test can call with built inputs.
    • A later set-up. override_settings(INSTALLED_APPS=...) and its relatives run ready() again (fact 10), and Django asks for an idempotent ready(). A later set-up with the same hosted apps is admitted: it runs Resolve, Validate and the checks again on the registry as it stands, and replaces the host’s tables and implementation instances with new ones, as 0021 requires of each set-up. The settings comparison is not repeated, because Django has changed DATABASES in place by then (fact 11). A test that adds an undeclared app therefore keeps working where those checks admit the app: one with models needs a host with a real default. A test that adds or removes an app with a declaration is refused by name, and runs in another process or tests the builder.
    • An override that leaves neolith out. Its ready() does not run. If a hosted app is still listed, that app’s own check for a missing neolith stops the override (0021 Decision 8). If none is, the host’s state is left as it was and describes the apps again when the override ends.
    • Calls before set-up ends. A connector call or a command scope in another app’s ready(), or at import, is a neolith.UsageError (0021 Decisions 9 and 10): Validate has not run yet.
    • Weighed: refuse every second ready(). One host per process is then true without exception. An existing project’s suite that overrides INSTALLED_APPS anywhere would stop on adoption, which principle 9 weighs against. Spring Modulith goes further the other way and bootstraps different module sets within one test run.
    • Weighed: opt-in verification, as Spring Modulith’s spring.modulith.runtime.verification-enabled is. A host that is not asked to check itself serves unchecked, which APP-1 rules out.
    • Weighed: a neolith get_wsgi_application() that runs the checks. It is explicit. A project that keeps Django’s function would skip every check and nothing would say so.
    • Weighed: plain exceptions with no check ids. Less machinery, and no listing for neolith check to build on.
  6. The deployer writes MIDDLEWARE, and the host checks the position of each of its classes. The root middleware is neolith.middleware.RootMiddleware, first and listed once. It is sync- and async-capable, and Django runs it in the mode of the handler inside it: under ASGI that is async, unless a sync-only middleware follows it in the list, in which case Django runs it in sync mode behind sync_to_async (core/handlers/base.py, load_middleware). It has to behave the same in both. How it opens, binds and closes a request’s root is in the Django host notes. The principal middleware is listed once too; its presence and count are checked the same way, and its place among other middleware is 0023’s rule.

    • Checked in three places, by one function: by the builder on its input, by set-up on the settings Django holds (neolith.E006, which PR 5 ships before the builder exists), and by the root middleware’s constructor on the list its handler was built from.
    • Why: middleware order in Django is a list the deployer reads top to bottom, and SecurityMiddleware and its neighbours are placed by hand in the same way. With the Django-user backend, the principal middleware has to sit after AuthenticationMiddleware, in the middle of the deployer’s list, so it could only ever be checked. One rule for both is simpler than a prepended class and a checked one.
    • Weighed: the host prepends the root middleware. The first-place rule could then not be broken. The list in the settings module would no longer be the list Django runs.
  7. Boot runs when a handler is built, from the root middleware’s constructor, once per process. The constructor checks the middleware’s own position every time, on the list its handler was built from; that check is not part of the kept result. Boot then checks that ROOT_URLCONF is set and imports, runs neolith.W001 again now that the URLconf’s modules are loaded, and checks each schema as 0009 Decision 7 asks: read per label, with nothing unapplied. A failure raises from the constructor, so the server never gets a handler (fact 5). The result is kept, a failure included: a later handler in the same process gets the same outcome without a second read, and concurrent constructions share one run behind a lock. A server that loads its handler lazily therefore keeps refusing after migrations are applied, until the process restarts.

    • What is read. For each hosted app with a database: MigrationExecutor(connections[<label>]) and the plan to leaf_nodes("<label>"). On a real default: the same, per label, for each app without a declaration that has migrations, batteries included. An app without a declaration and without migrations is not read: Django gives the host nothing to compare, and the migrate step could not repair it. A hosted app with a database and no migrations is refused, because its plan is empty whatever the database holds (fact 7). A hosted app whose migrations depend on another label is refused too (neolith.E013, 0009 Decision 7); the graph is already loaded for the plan.
    • Why there: building a handler is the one moment every way of serving HTTP passes through, and no management command does: neolith run, runserver, and any WSGI or ASGI server. migrate has to run on a schema that is not current, so the check cannot sit in ready(), where facts 4 and 6 also rule it out. Set-up has already refused settings without the root middleware in first place (Decision 5), so a host cannot serve requests without booting.
    • A process that builds no handler does not boot. A long-running command or a task worker can open a command scope and call in-host providers against a schema nobody read, although spec 05 puts Boot before Serve. This is a gap in step 4, and it is stated as one. Booting at the first scope would also stop the migrate step and data migrations, which must run on a schema that is not current, and it would make opening a scope read a database. Such a process can run migrateapps --check first (Decision 11). Open question 12 keeps it.
    • The same function, outside Boot. The schema read is a function that returns check messages with the ids in the table. Boot calls it, and migrateapps --check calls it and exits 1 on any message, so a release pipeline can run it without building a handler, as migrate --check and flyway validate allow. It is not registered under Django’s database tag in step 4: migrate runs those checks before it migrates (fact 2), and an error about unapplied migrations would block the step that applies them.
    • On a thread of its own. The reads run on a new thread, which then closes the connections it opened. That works inside an ASGI server’s loop (fact 6), and it leaves no open connection behind for a server that forks after loading. Boot refuses a SQLite database that is held in memory (neolith.E007) instead of reporting migrations the migrate step cannot apply. It does not judge by the name, which has more in-memory forms than Django’s is_in_memory_db sees (0009, fact 13). It opens a connection to every SQLite alias of NEOLITH["DATABASES"], also to a default it reads no migrations from, such as one that holds only a database cache, and runs PRAGMA database_list on it; an empty file for main means the database is in memory or in a temporary file (probe: empty for :memory: as a str and as a Path, file:, file::memory:?cache=shared, file:<x>?mode=memory&cache=shared and file:/<x>?vfs=memdb, and the file’s path for a file). The plain form is empty on that thread and on every request thread, and the shared forms are gone once the last connection closes. 0009 as amended leaves this refusal to this record.
    • Current means nothing unapplied. That is the test migrate --check uses (fact 2). A database that is ahead of the code is admitted, so a host of the previous release can restart during a rolling deployment. Flyway’s validate has the same default for the same reason: ignoreMigrationPatterns defaults to *:future.
    • Boot only reads. Several hosts can boot on one database at once (spec 05). The one write found is SQLite creating an empty file when the path does not exist (fact 7); the host then stops on unapplied migrations.
    • Tests. Django’s test runner builds each schema itself and sets up only the aliases a run uses (test/utils.py, setup_databases), so a schema read there would look at databases the run never created. neolith.testing.boot() marks the host booted without the schema function, neolith.E007 to E013, so a test host may use in-memory databases; the position and ROOT_URLCONF checks still run. A test session calls it once. It is an off switch for that read and is documented as one. No setting turns the read off, so the switch is a line of code a review and neolith check (step 7) can find.
    • Weighed: a warning, as runserver gives. It is the familiar behaviour. Spec 05 asks the host to stop, and a host that serves on an old schema fails at the first query instead of at startup.
    • Weighed: Boot in neolith’s own get_wsgi_application(). Same gap as in Decision 5.
    • The cost: a server that loads its handler lazily boots at its first request, which is that server’s choice of when to load. An adopter’s test suite needs one line of set-up. An existing project starts to stop on unapplied migrations of third-party apps on default, where Django used to warn. In a host with a real default, runserver also prints Django’s own warning at every start that the hosted apps have unapplied migrations, although they are applied on their own aliases, and advises a plain migrate (probe; check_migrations reads default with an unfiltered plan). That advice is the step Decision 11 replaces: following it records the hosted apps’ migrations on default and migrates nothing of theirs. The guide has to say so.
  8. The command scope is neolith.command_scope(app, *, principal=None, timeout=None), a context manager. It opens a root for the named hosted app (0017 Decision 4), with no principal and no deadline unless given, and a fresh trace. It closes on exit, by return or by exception, and a call through it afterwards is the usage error of 0017 Decision 5, a neolith.UsageError.

    • One form: the app is named by its label, the same name its database alias has (0009). The deadline is given as a timeout, a number of seconds as Decision 3 gives every such option, counted from entry, so no caller has to choose a clock. It is a value of running code, not of configuration, so it is treated as a call’s timeout= is in step 3: nothing is refused for its size. The root’s calls are then bounded as 0017 Decision 3 says: a timeout of 0 or less gives a root whose deadline has passed, so its calls fail with timeout, and a budget above 9999999999 ms counts as that. It is used with with, in sync and async code alike; entering sets a context variable and does no I/O, so async with would add a second spelling for nothing.
    • Nesting. A scope opened inside another root, such as a request root bound to another app, is a new root. It inherits nothing: not the principal, not the deadline, not the trace. On exit the outer root is current again. This is the case 0017 Decision 4 describes for middleware and error views. Decision 10 says what Drain does with such a scope.
    • Weighed: inheriting the outer deadline and trace when nested. It would keep a request’s calls under one deadline and one trace. A root that inherits some fields and not others is harder to state, and 0017 defines this scope as a root. Linking the two traces is left open below.
    • Weighed: an absolute deadline=. It matches 0017’s wording. It needs a clock the caller and the host agree on.
    • Weighed: a decorator form as well, as transaction.atomic and translation.override offer and as a command’s handle() would suit. A scope is used once, so a decorator would have to open a fresh one per call, and it would be a second spelling. It can be added later without changing this form.
    • The cost: any code in the process can open a scope for any hosted app. In-process there is no credential to check, so this is a matter for neolith check, as with 0009’s alias literals.
  9. neolith run serves with Django’s own development server classes, and adds no runtime dependency. It builds settings, runs django.setup(), builds a WSGIHandler, which boots (Decision 7), and serves it with subclasses of ThreadedWSGIServer and WSGIRequestHandler from django.core.servers.basehttp on the --bind address. It prints one line saying what it serves, where, and that the server is Django’s development server. There is no reloader and no --migrate; runserver’s static file handler is not used.

    • How this sits with 0002 and the vision. 0002 says a detached app is “booted alone by neolith run <app>”, and the vision says it starts “with no hand-built project around it”. Both hold from step 4: one command boots one app from its declaration. This record narrows what that command is fit for in 0.1, to development, tests and the 0.1 proof, because a server fit for production is a runtime dependency that K5 asks a record of its own for. Until that record exists, a production deployment of a detached app is a settings module of a few lines and the deployer’s own server, which is a small hand-built project. Open question 1 names the two forms that would lift the limit.
    • Why: a server in the core is a dependency of every adopter (K5), and choosing one chooses a process model for them. Django’s classes are already installed, carry Django’s own request handling (fact 12), and are enough for the step’s goal. Django starts and stops the same classes in LiveServerThread, with the shutdown() then server_close() sequence this record uses, so Django’s own test suite exercises that path on every version.
    • What it is for: development, tests and the 0.1 proof. Both paths go through the same builder, set-up and Boot, so the lifecycle does not depend on the server. Flask and Encore also name their development server run; FastAPI uses run for production and dev for development, which is the reading the printed line guards against.
    • WSGI only. Django ships no ASGI server. The ASGI rows of the request-root test plan run against ASGIHandler directly.
    • Weighed: depend on a production server (gunicorn, uvicorn, granian). neolith run would then be a production entry. It needs an Accepted record of its own, and each candidate brings limits (platforms, WSGI or ASGI only) to every adopter.
    • Weighed: an optional extra with a separate production subcommand, as FastAPI ships its server through fastapi[standard] and nanodjango has run and serve. An extra is not a dependency of every adopter, so K5 bears on it less. It still chooses a server and a process model, and it is a second serving path to test. Left to open question 1.
    • Weighed: a handler factory a server imports, taking the hosted apps and the deployer’s module from the environment, as nanodjango lets gunicorn load an app and as Django’s own deployment contract is an importable application. A host named by its apps would then run under any WSGI or ASGI server with no new dependency. It adds a third way to name the hosted apps, in environment variables, and step 4’s goal does not need it. It is the lighter of the two candidates, and open question 1 says so.
    • Weighed: the standard library’s wsgiref server. It is public API. It lacks the header handling of fact 12.
    • Weighed: call_command("runserver"). It is the documented entry, and it cannot drain (fact 12).
    • The cost: django.core.servers.basehttp is not documented API, though Django’s documented LiveServerTestCase rests on it. The CI matrix runs both ends of 0012, and a difference between versions would go in neolith._compat.
  10. Drain under neolith run: stop taking requests, give each request in flight until the request deadline, then stop. On SIGINT or SIGTERM the host stops the accept loop and closes the listening socket. It closes every kept-alive connection that is between requests, and a connection with a request in flight closes when that response ends. No request that begins after Drain does is served (fact 14). The host then waits until no request is in flight, or until the bound has passed since Drain began, whichever comes first. A second signal stops at once. The exit status is 0 when every request finished and 1 otherwise, with one line per request that was cut.

    • The bound is REQUEST_DEADLINE. When that is None it is 30 seconds, the value REQUEST_DEADLINE has when absent and the default grace period of gunicorn and Kubernetes. Go’s http.Server.Shutdown and Spring Boot’s graceful shutdown are bounded by the caller in the same way. An orchestrator’s own kill would end an unbounded wait with no exit status from the host.
    • What the bound promises. The promise is for request roots that take REQUEST_DEADLINE, which are the roots of ordinary Django requests (0017 Decision 4) and the only roots neolith run serves in step 4. Every such request in flight began before Drain did, so a call made through its request root has a deadline inside the bound and is not cut early. Two kinds of work can be cut: a request in a host with no request deadline, at 30 seconds; and work in a command scope nested in a request with a longer timeout or none (Decision 8). Spec 05’s “finish in-flight calls up to their deadlines” is met for those request roots only, and the spec delta of PR 8 has to say so in its own terms. A contract call that arrives over a transport is not such a root: its budget is the caller’s, and it has no deadline when the caller sent none (0017 Decision 3). Step 5 brings those calls, and open question 13 keeps what Drain owes them.
    • How requests are counted. The host wraps the WSGI callable it serves: the count goes up on entry and comes down in a finally, when the callable raises or when the server closes the response. It does not rest on Django’s signal pair, which can come apart (fact 13). A client that stops reading holds its request open; the bound ends that wait.
    • How connections are closed. The host’s request handler subclass knows whether its connection is between requests. This is the part of step 4 that leans most on basehttp’s internals, and PR 8 settles its form against both Django versions. A request that arrives while its idle connection is being closed is lost to the client, which HTTP clients already treat as a connection closed by the server.
    • Not waited for: a command scope that app code opened on a thread of its own. That work is the app’s to stop.
    • Under another server, Drain is that server’s graceful shutdown. Django receives no shutdown message it could act on (fact 15), so the documentation asks for a graceful timeout no shorter than REQUEST_DEADLINE.
    • Weighed: capping a nested scope’s deadline at the enclosing request’s. Drain’s promise would then cover nested scopes too. It makes the nested scope inherit one field after all, and a host cannot wait out a timeout of weeks in any case.
    • Weighed: waiting without a bound when no deadline is configured. No request is ever cut by the host. A hung view or a stalled client then holds the process until something outside kills it.
  11. The migrate step is one management command, migrateapps, and neolith migrate runs it for a host named on the command line. It first works out its labels and refuses before it touches a database (the table’s row), a hosted app whose migrations depend on another label included. It then runs Django’s migrate <label> --database=<label> for each hosted app with a database, in the order the apps were given, as 0009 Decision 7 requires, and on a real default also migrate <label> --database=default for each app without a declaration that has migrations. It stops at the first failure, says which labels were migrated and which were not, and exits 1. migrateapps --check reads and writes nothing (Decision 7). The step does not boot and does not serve. No host migrates on its own: neolith run has no option for it.

    • Why a command: a settings-module host needs the step as much as a command-line one, and a plain manage.py migrate either fails on the dummy default (0009, fact 1) or records every app’s migrations on one alias (0009, fact 3). One command serves both entries.
    • Why its own admitted set: migrate <label> fails on a label without migrations (fact 2), so passing every label through would stop the whole step on a third-party app that has none.
    • Weighed: overriding Django’s migrate. The familiar name would do the right thing. It would also change the meaning of a Django command in every host, including its --database and app-label arguments.
    • Weighed: a pass-through subcommand, such as neolith manage <app> ... --settings <module> -- <command>, with neolith migrate as its alias for migrateapps. django-admin and manage.py are one dispatcher that works once settings are configured, and nanodjango exposes it as nanodjango manage. It is likely the right shape, and neolith migrate is named so that it can become that alias. It is left out of step 4 for two reasons: #11 plans run and a migrate step only, and each Django command that defaults to default meets the dummy backend in a closed host (0009 lists them), which deserves a pass of its own. The cost until then: an app’s own commands, shell, dbshell, check and createcachetable need a settings-module host.
    • The cost: Django takes no lock while migrating. Running the step once per database, before hosts start, is the deployer’s to arrange in step 4.
  12. How a stop is reported. Every refusal of the host’s own before Serve is an ImproperlyConfigured, or the DeclarationError subclass for 0021’s rows, whose message is the one in the table. Under the command line, neolith prints neolith: <message> per refusal on standard error and exits with status 1; a mistake in the arguments exits with status 2. The package gains one console script, neolith, also reachable as python -m neolith.

Follows principles 1 (the app over the project), 3 (configuration over code changes), 6 (visible failure over transparent calls), 7 (running over linting), 9 (new and existing projects alike) and 10 (Django-native over framework-new): settings, system checks, middleware, ready(), setting_changed and the migration executor are all Django’s own.

Where each decision lands in the stack of #11

Section titled “Where each decision lands in the stack of #11”
PR What it carries from this record What it uses until a later PR
2, resolve and validate The first form of Decision 5: neolith as an installed app whose ready() runs Resolve and Validate, with the rule for a later set-up (the same hosted apps admitted, a changed set refused). The hosted apps are the installed apps with a declaration. No system check runs yet.
4, command scope Decision 8. The hosted apps come from the registry, as in PR 2.
5, request root Decision 6, with neolith.E006 and neolith.W001 as system checks; the part of Decision 5 that runs the neolith tag from ready(); the NEOLITH rows of the builder’s table for the keys that exist by then, REQUEST_DEADLINE among them, checked at set-up into the view of Decision 3. tests/settings.py is written by hand. RootMiddleware.__init__ checks its position only.
6, app database None of this record’s decisions. 0009’s router, and its checks neolith.E014 and neolith.E017, which run under Decision 5. DATABASES in tests/settings.py is written by hand.
7, host settings Decisions 1 to 4; the settings comparison of Decision 5 (neolith.E003 to E005), 0009’s checks neolith.E015 and neolith.E016, and the setting_changed receiver; Decision 7, with Boot added to RootMiddleware.__init__, neolith.testing.boot and the schema function. tests/settings.py moves to the builder here. The migrate step’s --check waits for PR 8; PR 7’s tests call the schema function directly.
8, neolith run Decisions 9 to 12. Nothing.
  • The lifecycle of spec 05 maps onto three Django moments. Declare, Resolve and Configure run in the builder, before settings exist. Resolve again, Validate and the static checks run at app loading, in every process. Boot runs when a handler is built. A management command, a task worker and any other process that builds no handler get the first two and not Boot (Decision 7).
  • A misconfigured host stops under any server, not only under manage.py. An existing project that adopts neolith also starts to stop on unapplied migrations, including those of its third-party apps on default (0009 Decision 7), where Django used to warn.
  • An existing project changes its settings module in three places: its app list moves to NEOLITH["UNDECLARED_APPS"], its DATABASES entry moves to NEOLITH["DATABASES"] (0009), and the builder call goes at the end. Its wsgi.py, asgi.py and manage.py do not change. A layered settings package (base, dev, prod) calls the builder once, in the module Django loads, after its last change; a change below the call is refused at startup.
  • An existing project’s test suite keeps override_settings(INSTALLED_APPS=...) and modify_settings for apps without a declaration, and override_settings(NEOLITH=...) for the request deadline. It cannot change the hosted apps, DATABASES or the routers within a process. neolith’s own tests that need several hosted sets test the builder as a function, call check functions with built inputs, or start a process; PR 8’s tests start neolith run as a process in any case.
  • Step 4’s public surface gains neolith.host.build_settings, neolith.middleware.RootMiddleware, neolith.command_scope, neolith.testing.boot, the migrateapps command with --check, the neolith script with run and migrate, the three NEOLITH keys, and its system checks. Their ids here, neolith.E003 to E017 and neolith.W001, are working numbers; each PR fixes the ids of the checks it adds, and 0023’s follow. neolith.UsageError and neolith.DeclarationError are 0021’s.
  • neolith run is honest about being a development server (Decision 9). The production path exists from step 4 and needs no neolith command.
  • A closed host cannot use auth, sessions, admin or any other battery with tables in step 4. An app that needs them runs alone only after the repository’s open question 4 has a record.
  • Tests: PR 4 covers the scope’s rows and a call through a closed scope. PR 2 covers a second ready() with the same and with different hosted apps. PR 6 covers a refused value for each of neolith.E014 and neolith.E017. PR 5 covers the middleware rows, neolith.W001, the REQUEST_DEADLINE row, and the request-root test plan, with an ASGI case in which a sync-only middleware follows the root middleware. PR 7 covers, through the plain function, one refused value per row of the builder’s table and a Hypothesis test that a mapping drawn from the admitted set builds and that one change outside it is refused by name. It covers, each in a process of its own because an import, the first set-up and the recorded refusal happen once per process: a settings module changed after the builder call, per host-owned name; an app that reads a setting at import, in both callers, and one that guards its read; the three fail-closed paths of Decision 1, where after the refusal manage.py check and runserver --noreload exit non-zero and a second get_wsgi_application() still raises; In the test host’s process it covers override_settings on NEOLITH, per key, and the Boot rows by calling the schema function directly, since Boot keeps its result: each in-memory form of 0009’s fact 13, on an app alias and on a default that Boot reads no migrations from, and an undeclared app with and without migrations. PR 8 covers the phases in order, a request in flight that completes after SIGTERM, a new connection refused during Drain, a request on a kept-alive connection during Drain, a request that outlasts the bound, a request_started receiver that raises, migrateapps on a label without migrations, and the exit statuses, with events in place of sleeps (T3).
  • One difference between the versions was found: on 6.1, run_checks passes every alias as databases when none is given (core/checks/registry.py), where 5.2 passes None. The host’s registered checks do not read that argument, so nothing here needs neolith._compat.
  • Spec 05 gains requirements in PRs 5, 7 and 8, written without Django’s names (P2): a host stops on an input outside its admitted set, a host checks every schema before it serves requests and writes nothing while it does, schema changes are a separate step, and Drain gives each ordinary request in flight until the request deadline or a stated bound; what it gives a contract call that arrived over a transport is left to step 5 (open question 13).
  1. A production form of neolith run. Two candidates are weighed in Decision 9: a handler factory a deployer’s server imports, which needs no dependency, and an optional extra with a production subcommand. Either needs its own record (K5 for the second). Until then the settings-module path is the production path.
  2. Contract endpoints and ROOT_URLCONF. Step 5 (#12) adds contract paths. Whether the host builds the URLconf then, or the deployer includes the host’s URLs, is that step’s decision; this record leaves ROOT_URLCONF with the deployer.
  3. A lock for the migrate step. Two migrateapps runs on one database can race. Django leaves this to the deployer. Rails takes a database advisory lock around migrations and Flyway locks its history table; a later record could borrow either form where the database has one.
  4. Other management commands for a host named on the command line. The pass-through of Decision 11 is the candidate, with neolith migrate as its alias.
  5. A database ahead of the code. Decision 7 admits it, as Flyway does by default. A host could instead warn, or refuse when an applied migration is unknown to it. Evidence from adopters would help.
  6. Trace continuity for a nested command scope. Decision 8 starts a fresh trace. A link from the new trace to the enclosing one, as W3C Trace Context and OpenTelemetry allow, would keep correlation without inheriting authority.
  7. Which app may open a scope for which. A check that flags a command_scope label other than the calling module’s own app fits neolith check (step 7, #14).
  8. What a battery needs beyond being installed. Decision 4 leaves middleware, and the apps a battery depends on, to the declaration and the deployer. If the host later carries that knowledge, Decision 6’s “the deployer writes the list” has to be revisited as a whole, not per class.
  9. Drain under ASGI. Without a lifespan message (fact 15), the host cannot tell an ASGI server’s shutdown from a quiet moment. A lifespan-aware wrapper around ASGIHandler is one way, and it would be new surface.
  10. Settings changed in place after set-up. Decision 3 covers override_settings, and Decision 5 covers statements in the settings module. A value changed in place at run time sends no signal and is not seen. For DATABASE_ROUTERS that includes a write between set-up and Django’s first routing question, which Django would honour (0009, fact 8); the comparison of Decision 5 sees it only if it came before set-up. Whether the host should hand Django copies it can compare later is undecided.
  11. What this record shares with the other step 4 records. 0021 owns the form of a declaration, DeclarationError, UsageError, and Resolve and Validate. 0009 owns the content of NEOLITH["DATABASES"], the router and the per-label read of a schema. 0023 owns the PRINCIPAL_BACKENDS key, the principal middleware’s position, and whether that key may change under override_settings. If one of them is not accepted in its present form, the rows here that lean on it have to be restated.
  12. Boot for a process that serves no HTTP. A task worker makes in-host calls without Boot (Decision 7). A public boot() such a process calls, or Boot at the first command scope with the migrate step exempt, are the candidates; spec 04’s event delivery in 0.2 will need an answer.
  13. Drain for inbound contract calls (step 5). Decision 10 bounds Drain by REQUEST_DEADLINE, which bounds ordinary requests. A contract call that arrives over HTTP brings the caller’s budget, up to 9999999999 ms or the host’s own maximum, and none when the caller sent no deadline. Drain’s bound then has to cover the host’s maximum budget, which step 5 sets. Making REQUEST_DEADLINE that maximum was weighed: one number would bound both, but a call that arrives without a deadline has none under 0017 Decision 3, so giving it one is a change to an Accepted record and needs a record of its own.