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
Context
Section titled “Context”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:
- System checks run only from management commands.
BaseCommand.executecallsself.check()(core/management/base.py), andrunservercalls it ininner_run(core/management/commands/runserver.py).get_wsgi_application()andget_asgi_application()calldjango.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.checkdrops a silenced message at every level, errors included (not e.is_silenced()). - Django warns about unapplied migrations and serves.
runservercallscheck_migrations, which prints a notice (core/management/base.py).migrate --checkexits 1 when the plan is not empty (core/management/commands/migrate.py).migrate <label>raisesCommandError("App '<label>' does not have migrations.")for a label outsideloader.migrated_apps(line 149 in 5.2.19, 151 in 6.1.3).migratealso runs the checks taggeddatabasefor its alias before it migrates (get_check_kwargs, same file). ready()runs inINSTALLED_APPSorder, after every model module is imported and beforeapps.readyis set (apps/registry.py,Apps.populate). Probe: the last app’sready()can callapps.get_app_configs()andchecks.run_checks(tags=[...]).- Database access before
apps.readyraises aRuntimeWarning(db/backends/utils.py,APPS_NOT_READY_WARNING_MSG), which is an error under 0012’s warning filter. - A handler builds its middleware in its constructor.
WSGIHandler.__init__andASGIHandler.__init__callload_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 leavesget_wsgi_application(), so the server fails to load. - 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. - Reading migration state writes nothing.
MigrationRecorder.applied_migrations()returns{}when the table is missing; onlyrecord_appliedandrecord_unappliedcallensure_schema(db/migrations/recorder.py). An app withMIGRATION_MODULES[label] = None, or with no migrations package, lands inMigrationLoader.unmigrated_apps, and itsleaf_nodes(label)is empty (db/migrations/loader.py). Probe: connecting to a SQLite file that does not exist creates it, empty. - 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 itsappsmodule and reads no setting (apps/config.py). Probe: it returns the config class’s instance before settings exist. - 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 raisesImproperlyConfigured. Inside a settings module that is still being imported, the read raises nothing:LazySettings._setupruns again, builds aSettingsfrom the half-imported module, and the app sees the names defined above that line and not those below it. In both placessettings.configuredis false before the import and, when a read succeeded, true after it. override_settings(INSTALLED_APPS=...)runs everyready()again, throughapps.set_installed_apps(test/utils.py,apps/registry.py);modify_settingsand pytest-django’ssettingsfixture 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_settingsalso sendssetting_changedfor each name, and an exception from a receiver on entry ends the override and is raised (test/utils.py).- Django changes
settings.DATABASESin place.ConnectionHandler.configure_settingsfills defaults into each alias’s mapping (db/utils.py), and the test runner writes the test database’s name intosettings.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. - Django ships a WSGI server, built for development.
WSGIRequestHandler.get_environdrops headers with an underscore in the name, andThreadedWSGIServercloses 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’srun(), whichrunserveruses, builds its server by mixingThreadingMixIninto the plainWSGIServer, not fromThreadedWSGIServer; it setsdaemon_threads = Trueand callsserve_forever();runserverleaves onKeyboardInterrupt. 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:LiveServerThreadsetsserver_class = ThreadedWSGIServer, and itsterminate()callshttpd.shutdown()and thenhttpd.server_close()(test/testcases.py). It backs the documentedLiveServerTestCase. - Django signals the start and the end of each response.
WSGIHandler.__call__sendsrequest_startedwithsend, notsend_robust, so a receiver that raises ends the request before any response exists;HttpResponseBase.close()sendsrequest_finished(core/handlers/wsgi.py,http/response.py). A count kept between the two signals can therefore stay above zero for good. - After
shutdown()andserver_close(), a kept-alive connection is still served. Probe, withThreadedWSGIServerand a response that hasContent-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 gets200 OK. Each connection’s thread loops inWSGIRequestHandler.handleuntilclose_connectionis set. ASGIHandleraccepts onlyhttpscopes (core/handlers/asgi.py), so no ASGI lifespan message reaches Django.ROOT_URLCONFhas no default.get_resolverreads it at the first request (urls/resolvers.py), and the URL checks skip when it is unset (core/checks/urls.py).- Contrib apps.
admin,auth,contenttypesandsessionscarry migrations;messages,staticfilesandhumanizedo not (contrib/*/migrations). manage.pygoes on after a settings module fails to import.ManagementUtility.executecatches theImproperlyConfiguredthat readingsettings.INSTALLED_APPSraises, and callsdjango.setup()wheneversettings.configuredis true (core/management/__init__.py). After the read of fact 9, it is true: Django holds aSettingsbuilt from the half-imported module. Probe, with a settings module whose last line raises after an app read a setting:manage.py checkprinted “System check identified no issues” and exited 0, and a secondget_wsgi_application()in the same process built a handler with an emptyINSTALLED_APPS. A settings module that failed to import is not kept insys.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).
Options
Section titled “Options”- The host only checks settings the deployer wrote. An existing project changes least. The declarations and the settings then state the same facts twice.
- 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 runand a conventional project. A settings file no longer lists the installed apps. - The command line is the only entry. One way to reach Django. Tools that read
DJANGO_SETTINGS_MODULEwould stop working.
Decision
Section titled “Decision”Option 2, in the twelve decisions below. Each weighs its own alternatives, options 1 and 3 among them (Decision 1).
The admitted set
Section titled “The admitted set”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. |
Decisions
Section titled “Decisions”-
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 runandneolith migratetake the apps from their arguments and the deployer’s mapping from the module named by--settings, then callsettings.configure(**built)(fact 8). - A settings module. A Django settings module ends with
globals().update(build_settings([...], globals())). Every tool that expectsDJANGO_SETTINGS_MODULEthen 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 withset_dynamic_settings(globals()). - One module can feed both. The deployer’s names live in a module with no builder call, which
neolith run --settingstakes. 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, andsettings: <NAME> is built by the host). - A declaration that reads settings is refused. The builder notes
settings.configuredbefore 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’sDeclarationErroras it is, and anything else ascannot be read, with the error as the cause. That covers the read Django refuses on its own, and anappsmodule that imports models, which Django stops through a read ofINSTALLED_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
tryand 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_MODULEis 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’sDeclarationErrorfrom 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 resettingsettings._wrapped, the attribute Django’s ownoverride_settingsassigns (test/utils.py); the access lives inneolith._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 aDeclarationError; and a line above the builder call that reads a setting, followed by a refusal of another row. In each,manage.py checkandrunserver --noreloadend in the refusal with a non-zero status, and a first and a secondget_wsgi_application()both raise it. Without the record, the second one built a handler in the first path; without the reset,checkpassed in all three. The recorded refusal is host-owned state of the kind K6 permits. - The rows are a plain function, and
build_settingsis 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 andneolith runcall, 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 throughmanage.pyor 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’ssettings.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
DATABASESbuilt. - Weighed: the deployer keeps
INSTALLED_APPSand the host appends to it, as Open edX does withINSTALLED_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 plainINSTALLED_APPSentries. 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
NEOLITHkey, withneolith runtaking no app. It gives one place for the list. It also parts from 0002’sneolith 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.pyandwsgi.pycalling the host. One way to reach Django. Tools that readDJANGO_SETTINGS_MODULEwould stop working, which principle 10 weighs against. - Weighed:
DJANGO_SETTINGS_MODULEas the fallback for--settings, asdjango-adminhas. That variable names a complete settings module to every Django tool. The moduleneolith runtakes 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 diffsettingsprints the built list.
- The command line.
-
A setting belongs to the host or to the deployer, never to both. The host builds
INSTALLED_APPS,DATABASESandDATABASE_ROUTERS, and refuses them in the deployer’s mapping. It validatesNEOLITH,MIDDLEWAREandSILENCED_SYSTEM_CHECKSas the table says. It reads two more without owning them:CACHESat set-up, for 0009’s row on a database cache, andROOT_URLCONFat Boot (Decision 7). Every other name is Django’s and passes through unchanged,SECRET_KEY,ALLOWED_HOSTS,ROOT_URLCONFandTEMPLATESamong 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 silencedneolith.Eid 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 checkwould then report nothing while the host refuses to start, and the deployer’s instruction would be dropped without a word.- Weighed: building
MIDDLEWAREtoo. 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.
-
settings.NEOLITHstays the deployer’sdict, and the checked view is the host’s own state. This record defines three keys,DATABASES(whose content is 0009’s),REQUEST_DEADLINEandUNDECLARED_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;NEOLITHitself is the one exception, written as{}so that a host cannot run on a misspelt setting name. The builder checks thedictand 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_DEADLINEis 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 orNEOLITH, is a number of seconds: a finiteintorfloat, not abool. Step 3’s design gate on #10 gives a connector call’stimeout=that form, as Python does forsocket.settimeoutandasyncio.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, atimedelta: 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 withtotal_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
timeoutand 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’stimeout=. - Why a plain
dict: it is the convention Django REST framework set withREST_FRAMEWORKandapi_settings: the setting is what the deployer wrote, the checked view is a separate object, and asetting_changedreceiver 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).diffsettingsand any tool that prints or serialises settings meet an ordinary value. - Changes after set-up. The host connects a
setting_changedreceiver (fact 10). An override ofNEOLITHis checked by the builder’s table; it may changeREQUEST_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 toDATABASESorDATABASE_ROUTERS, raises by name.MIDDLEWAREneeds 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
NEOLITHwith 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_TIMEOUTkey. 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.
-
INSTALLED_APPSis built in a fixed order: hosted apps as given, theUNDECLARED_APPSentries as given, batteries, thenneolith.- 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, asadmin.E408does. 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 nodefault, 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 runandneolith migratebuild closed hosts only in 0.1 and refuse adefault. A settings-module host with adefaultadmits batteries with tables and undeclared apps with models, ondefault. This keeps the repository’s open question 4 open: no detached host gets its own copy ofauthby default. - Order. Django resolves templates, static files and commands by the first app that has them. Hosted apps and
UNDECLARED_APPSentries come before batteries, so a project’s overrides of contrib templates keep working, and those entries keep the order the deployer gave.neolithis last so that itsready()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_APPSas 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 adefaultand refused when the app runs alone underneolith run.
- Batteries. A battery is a Django app name in dotted form, such as
-
Set-up runs when Django loads apps, in every process, and can run again for the same hosted apps. The
ready()of theneolithapp, the last one, does this: it builds the checked view (Decision 3); it runs Resolve and Validate, which raise oneDeclarationErrorlisting every refusal (0021 Decision 8); and it runs the checks registered under the tagneolithwithchecks.run_checks(tags=["neolith"]). Any message atERRORor above raisesImproperlyConfiguredlisting all of them. Warnings are logged once on theneolith.hostlogger and stay visible tomanage.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 checksMIDDLEWARE(neolith.E006). Statements after the builder call in a settings module, such as an import of a base module followed byINSTALLED_APPS += [...], are caught here, and so is a settings module written by hand that installsneolith. Hand-written settings that match are admitted. Not run: the comparison ofDATABASESonce 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 theneolithtag 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 checkin 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 runready()again (fact 10), and Django asks for an idempotentready(). 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 changedDATABASESin 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 realdefault. 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
neolithout. Itsready()does not run. If a hosted app is still listed, that app’s own check for a missingneolithstops 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 aneolith.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 overridesINSTALLED_APPSanywhere 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-enabledis. 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 checkto build on.
- Two kinds of failure, each with one owner. Resolve and Validate refusals are
-
The deployer writes
MIDDLEWARE, and the host checks the position of each of its classes. The root middleware isneolith.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 behindsync_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
SecurityMiddlewareand its neighbours are placed by hand in the same way. With the Django-user backend, the principal middleware has to sit afterAuthenticationMiddleware, 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.
- Checked in three places, by one function: by the builder on its input, by set-up on the settings Django holds (
-
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_URLCONFis set and imports, runsneolith.W001again 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 toleaf_nodes("<label>"). On a realdefault: 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.migratehas to run on a schema that is not current, so the check cannot sit inready(), 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 --checkfirst (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 --checkcalls it and exits 1 on any message, so a release pipeline can run it without building a handler, asmigrate --checkandflyway validateallow. It is not registered under Django’sdatabasetag in step 4:migrateruns 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’sis_in_memory_dbsees (0009, fact 13). It opens a connection to every SQLite alias ofNEOLITH["DATABASES"], also to adefaultit reads no migrations from, such as one that holds only a database cache, and runsPRAGMA database_liston it; an empty file formainmeans the database is in memory or in a temporary file (probe: empty for:memory:as astrand as aPath,file:,file::memory:?cache=shared,file:<x>?mode=memory&cache=sharedandfile:/<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 --checkuses (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’svalidatehas the same default for the same reason:ignoreMigrationPatternsdefaults 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.E007toE013, so a test host may use in-memory databases; the position andROOT_URLCONFchecks 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 andneolith check(step 7) can find. - Weighed: a warning, as
runservergives. 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 realdefault,runserveralso 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 plainmigrate(probe;check_migrationsreadsdefaultwith an unfiltered plan). That advice is the step Decision 11 replaces: following it records the hosted apps’ migrations ondefaultand migrates nothing of theirs. The guide has to say so.
- What is read. For each hosted app with a database:
-
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, aneolith.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’stimeout=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 withtimeout, and a budget above 9999999999 ms counts as that. It is used withwith, in sync and async code alike; entering sets a context variable and does no I/O, soasync withwould 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.atomicandtranslation.overrideoffer and as a command’shandle()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.
- One form: the app is named by its label, the same name its database alias has (0009). The deadline is given as a
-
neolith runserves with Django’s own development server classes, and adds no runtime dependency. It builds settings, runsdjango.setup(), builds aWSGIHandler, which boots (Decision 7), and serves it with subclasses ofThreadedWSGIServerandWSGIRequestHandlerfromdjango.core.servers.basehttpon the--bindaddress. 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 theshutdown()thenserver_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 usesrunfor production anddevfor 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
ASGIHandlerdirectly. - Weighed: depend on a production server (gunicorn, uvicorn, granian).
neolith runwould 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 hasrunandserve. 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
gunicornload an app and as Django’s own deployment contract is an importableapplication. 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
wsgirefserver. 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.basehttpis not documented API, though Django’s documentedLiveServerTestCaserests on it. The CI matrix runs both ends of 0012, and a difference between versions would go inneolith._compat.
- How this sits with 0002 and the vision. 0002 says a detached app is “booted alone by
-
Drain under
neolith run: stop taking requests, give each request in flight until the request deadline, then stop. OnSIGINTorSIGTERMthe 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 isNoneit is 30 seconds, the valueREQUEST_DEADLINEhas when absent and the default grace period of gunicorn and Kubernetes. Go’shttp.Server.Shutdownand 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 rootsneolith runserves 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 longertimeoutor 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.
- The bound is
-
The migrate step is one management command,
migrateapps, andneolith migrateruns 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’smigrate <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 realdefaultalsomigrate <label> --database=defaultfor 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 --checkreads and writes nothing (Decision 7). The step does not boot and does not serve. No host migrates on its own:neolith runhas 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 migrateeither fails on the dummydefault(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--databaseand app-label arguments. - Weighed: a pass-through subcommand, such as
neolith manage <app> ... --settings <module> -- <command>, withneolith migrateas its alias formigrateapps.django-adminandmanage.pyare one dispatcher that works once settings are configured, and nanodjango exposes it asnanodjango manage. It is likely the right shape, andneolith migrateis named so that it can become that alias. It is left out of step 4 for two reasons: #11 plansrunand a migrate step only, and each Django command that defaults todefaultmeets 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,checkandcreatecachetableneed 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.
- Why a command: a settings-module host needs the step as much as a command-line one, and a plain
-
How a stop is reported. Every refusal of the host’s own before Serve is an
ImproperlyConfigured, or theDeclarationErrorsubclass for 0021’s rows, whose message is the one in the table. Under the command line,neolithprintsneolith: <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 aspython -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. |
Consequences
Section titled “Consequences”- 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 ondefault(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"], itsDATABASESentry moves toNEOLITH["DATABASES"](0009), and the builder call goes at the end. Itswsgi.py,asgi.pyandmanage.pydo 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=...)andmodify_settingsfor apps without a declaration, andoverride_settings(NEOLITH=...)for the request deadline. It cannot change the hosted apps,DATABASESor 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 startneolith runas a process in any case. - Step 4’s public surface gains
neolith.host.build_settings,neolith.middleware.RootMiddleware,neolith.command_scope,neolith.testing.boot, themigrateappscommand with--check, theneolithscript withrunandmigrate, the threeNEOLITHkeys, and its system checks. Their ids here,neolith.E003toE017andneolith.W001, are working numbers; each PR fixes the ids of the checks it adds, and 0023’s follow.neolith.UsageErrorandneolith.DeclarationErrorare 0021’s. neolith runis 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,adminor 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 ofneolith.E014andneolith.E017. PR 5 covers the middleware rows,neolith.W001, theREQUEST_DEADLINErow, 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 refusalmanage.py checkandrunserver --noreloadexit non-zero and a secondget_wsgi_application()still raises; In the test host’s process it coversoverride_settingsonNEOLITH, 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 adefaultthat 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 afterSIGTERM, a new connection refused during Drain, a request on a kept-alive connection during Drain, a request that outlasts the bound, arequest_startedreceiver that raises,migrateappson 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_checkspasses every alias asdatabaseswhen none is given (core/checks/registry.py), where 5.2 passesNone. The host’s registered checks do not read that argument, so nothing here needsneolith._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).
Open questions
Section titled “Open questions”- 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. - 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 leavesROOT_URLCONFwith the deployer. - A lock for the migrate step. Two
migrateappsruns 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. - Other management commands for a host named on the command line. The pass-through of Decision 11 is the candidate, with
neolith migrateas its alias. - 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.
- 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.
- Which app may open a scope for which. A check that flags a
command_scopelabel other than the calling module’s own app fitsneolith check(step 7, #14). - 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.
- 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
ASGIHandleris one way, and it would be new surface. - 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. ForDATABASE_ROUTERSthat 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. - 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 ofNEOLITH["DATABASES"], the router and the per-label read of a schema. 0023 owns thePRINCIPAL_BACKENDSkey, the principal middleware’s position, and whether that key may change underoverride_settings. If one of them is not accepted in its present form, the rows here that lean on it have to be restated. - 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. - 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. MakingREQUEST_DEADLINEthat 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.