Metadata-Version: 2.1
Name: create-star-app
Version: 1.35.0
Summary: Flask architecture scaffolding tool — generates core server files for the star app architecture.
Author-email: "Adrian Anton D. Ladia" <ladiaadrian@gmail.com>
License: MIT
Project-URL: Homepage, https://github.com/AALadia/create-star-app
Project-URL: Repository, https://github.com/AALadia/create-star-app
Project-URL: Issues, https://github.com/AALadia/create-star-app/issues
Keywords: flask,scaffolding,boilerplate,code-generator
Classifier: Development Status :: 4 - Beta
Classifier: Environment :: Console
Classifier: Framework :: Flask
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Topic :: Software Development :: Code Generators
Requires-Python: >=3.10
Description-Content-Type: text/markdown
License-File: LICENSE

# create-star-app

Flask architecture scaffolding tool that generates core server files for the star app architecture.

## What it does

`create-star-app` is an interactive CLI that prompts for project configuration (name, port, timezone, GCP region) and generates a ready-to-use Flask server and Next.js client with all the boilerplate files pre-configured.

## Installation

```bash
pip install -e .
```

## Usage

Run the CLI from the directory where you want to create your project:

```bash
create-star-app
```

You will be prompted for:

- **Project name** (required)
- **Port** (default: 5000)
- **Timezone offset** (default: 8)
- **GCP region** (default: asia-east1)

The tool generates `server/` (Flask, MongoDB, Pub/Sub, pydantic models), `client/` (Next.js via create-next-app, with MUI and Firebase), `.vscode/`, `CLAUDE.md` and `.claude/`, then runs `AppCreator.py` once (with `ENVIRONMENT=localdev`).

Prerequisites for that first `AppCreator.py` run: the server requirements installed in the active Python, and `json2ts` on PATH (`npm install -g json-schema-to-typescript`).

## Development

```bash
pip install -e .
```

Then run directly:

```bash
python -m create_star_app.cli
```

## Shared files

This repo is the source of truth for the server files every Star App project keeps identical: the generic tests (`test_file_sizes.py`, `test_route_coverage.py`, `test_mock_drift.py`, ...), `servicePortCheck.py`, `responseModelCheck.py`, `sharedConstantsTS.py`, the Pub/Sub map tools, `sharedFileCheck.py` itself, the `tools/` job scripts, the deploy build (`cloudbuild.yaml`, `tools/updateJobsFromService.py`, `test_cloudbuild.py`) and the test-run tooling (`testRamGate.py`, `testServices.py`, `services/__request.py`, `test_xdist_workers.py`, `test_testServices.py`). `fleet/sharedFiles.json` lists them, each `exact` or `regions` (only the `# >>> star-app shared: <name>` ... `# <<< star-app shared: <name>` regions are shared). A `# >>> star-app per-repo: <name>` block inside either keeps each project's own content. A project's `server/sharedFileCheck.py` and `test_sharedFiles.py` compare its copies with `../create-star-app` at its committed HEAD, rendered as scaffold.py renders them; with no checkout there, they skip with a reason.

To change a shared file, or fold back a fix made in one project:

1. Edit the template's `.tmpl` (a literal `$` is `$$`) or the `fleet/` file. `cd server && python sharedFileCheck.py --template-worktree` in a project previews the result.
2. Commit it here.
3. Run `cd server && python sharedFileCheck.py --write` in every fleet project and commit there.

Never edit one project's copy alone. `fleet/` sits outside the `create_star_app` package (package data is `templates/*`), so the manifest and the fleet-only files (`apiNamespace.py`, `test_objects_package_exports.py`) never ship to PyPI.

Not enforced yet, because the copies have diverged (converge one, then add it to the manifest; until then fold back by hand): `AppCreator.py`, `conftest.py`, `route_config.py`, `pubSub.py`, `pubSubAdmin.py`, `mongoDb.py`, `AppConfig.py`, `roles.py`, `AuthHandler.py`, `utils.py`, `services/__abstractService.py`, `services/__getIsProductionEnv.py` and `services/AllServices.py`, `checkPubSubTopics.py`, the typed-response test files, `Dockerfile` and `requirements.txt`.

## Changelog

### 1.35.0 — parallel test workers, a RAM gate, sibling servers started by the tests

- **`pytest -n auto`** (pytest-xdist 3.8.0, now in `requirements.txt`): `conftest.py` gives every xdist worker its own database, the worker id before `Scratch` (`test<project>_gw0Scratch`; the scratch guard is unchanged), and sends a test file to one worker (`--dist loadfile`, unless `--dist` is passed). Plain `pytest` stays one process.
- **`testRamGate.py`** (new shared file, stdlib only): `-n auto` takes as many workers as the available memory holds, and every run, serial too, is refused before any test starts (exit code 75) when its workers do not fit, naming the other pytest and `pubSubMockDataGenerator` processes that hold the memory. Formula, measured numbers and knobs (`PYTEST_WORKER_GB`, `PYTEST_RAM_RESERVE_GB`, `PYTEST_MAX_WORKERS`, `PYTEST_WAIT_FOR_RAM`, `PYTEST_SKIP_RAM_CHECK`) in its docstring; a repo whose workers take more sets its own `TEST_WORKER_GB` in conftest's per-repo block.
- **`testServices.py`** (new shared file) and **`services/__request.py`**: in a localdev test run, the first call to a sibling's dev URL starts that sibling's server when nothing answers there, from `../<localRepoDirName>` with its `server/venv` python, on database `test<its projectName>For<ours>Scratch` (or conftest's per-repo `TEST_SERVICE_DATABASES`), and the run kills it when it ends. Nothing is declared per service. A per-port lock lets one worker start it; on Windows it sits in the run's kill-on-close Job Object, so it dies with a crashed or killed run too; a recorded server whose run is gone is killed at the next start, only while its PID still has the recorded creation time. A server already up is used and left running; a sibling with no checkout or python is left down with one warning. Knobs: `PYTEST_NO_AUTOSTART_SERVICES=1`, `PYTEST_SERVICE_ROOT_<NAME>`, `PYTEST_SERVICE_PYTHON_<NAME>`, `PYTEST_SERVER_GB`.
- **`test_xdist_workers.py`** and **`test_testServices.py`** (new shared files) pin the worker databases, the gate's decision and refusal, the server start/reuse/sweep decisions, the hook's conditions and that sibling calls go only through `services/__request.py`.

### Upgrading a 1.34.0 project to 1.35.0

1. Take the shared files (`cd server && python sharedFileCheck.py --write`) and `pip install pytest-xdist==3.8.0` (add it to `requirements.txt`).
2. Port the three `conftest.py` blocks from a fresh scaffold into yours: "One database per xdist worker" before the scratch guard, the `STAR_TEST_SHELL_ENV_KEYS` record after the first pop of the cloud database variables, and the RAM refusal plus the "Parallel workers (pytest-xdist), the RAM gate and the test servers" section after `pytest_configure`. Set the per-repo `TEST_SERVICE_DATABASES` and `TEST_WORKER_GB`.
3. Run the suite serially and with `-n auto`; a test that fails only in parallel wrote into the repo, used a fixed port or shared module state: fix its setup.

### 1.34.0 — Cloud Run jobs follow the service

- **`cloudbuild.yaml`** (new shared file, repo root): the push-to-`main` build, in the repo instead of inline in the Cloud Build trigger. Steps `build` (`docker build server --build-arg BUILD_CONFIG=cloudbuild.yaml`), `push`, `deploy-service` (the same `gcloud run services update` the Cloud Run wizard's inline build ran) and `update-jobs`. Identical in every repo: every repo-specific value is a trigger substitution (`_SERVICE_NAME`, `_DEPLOY_REGION`, `_AR_*`, `REPO_NAME`, `_TRIGGER_ID`).
- **`tools/updateJobsFromService.py`** (new shared file, stdlib only): gives every Cloud Run job named `<service>-*` (not `*-once`, and only jobs already on the service's image repository) the image that just went live and the service's env vars, through `--env-vars-file`, so multiline values such as `FIREBASE_PRIVATE_KEY` survive. A secret-ref env var or an empty environment refuses (exit 2); a failed job update fails the build (exit 1) after trying the others. A `job_*.py` without a job is a WARNING; new jobs and schedules stay manual (`create_or_update_job.bat`). It prints env names and counts, never values, and writes to GCP: Claude never runs it.
- **`test_cloudbuild.py`** (new shared file, no DB): pins the four steps, the image expression, the allowed substitutions, the absence of any service name, the Dockerfile guard, and the script against a fake gcloud.
- **`Dockerfile`** (per repo): `ARG BUILD_CONFIG` and a `RUN` that exits 1 unless it is `cloudbuild.yaml`, before the first `COPY`. A trigger still on its inline build fails at `docker build`, before anything ships, instead of deploying a service whose jobs keep the old image. A local `docker build` needs `--build-arg BUILD_CONFIG=cloudbuild.yaml`. New **`server/.dockerignore`** keeps `.env`, venvs and `keys/` out of a local build.
- **`tools/update_all_jobs.ps1`** keeps its pickers and review summary but previews with `updateJobsFromService.py --dry-run` and syncs by running it, instead of its own comma-joined `--set-env-vars` loop (which mangled multiline values and also touched `*-once` jobs). Its exit code is the script's.
- **Docs**: ARCHITECTURE.md "GCP Cloud Run Jobs" (automatic refresh, the guard, switching a trigger), CLAUDE.md "Deploys".
- **`checkPubSubTopics.py`** (per repo) reads the topics and their prod flag from the committed `server/pubsub_models.json` and imports only `pubSub`, instead of importing every publisher module (the whole app, a second time before gunicorn loads it: roughly double the cold start). `pubSub.checkPubSubTopics(topics=None)` takes those pairs; with none it still reads the registry. The **`Dockerfile`** CMD runs it before gunicorn (`sh -c "python checkPubSubTopics.py && gunicorn ..."`): it stays outside app.py because gunicorn binds the port before a worker imports the app, so Cloud Run's TCP startup probe would pass before an in-app check ran. A missing manifest, or one `pubSubModels.parseManifest` refuses for this project, fails the start, and so does a stale one: the script compares its `(fn, prodPublish)` pairs with `pubSubMapSection.publisherProdFlags` (the map's AST publisher scan of the shipped sources, about 0.3 s) and exits 1 on any difference, since the build runs no tests.
- **`test_response_compression.py`** drops its two start-up subprocess tests (`test_killSwitch_isReadFromTheEnvironmentAtStartup`, `test_killSwitch_isLoggedOnceAtStartup`) and their helpers: each spawned fresh `app.py` imports, the slowest part of the suite's compression file. `test_killSwitch_turnsCompressionOff` still pins that `app.config['COMPRESS_DISABLED']` turns compression off; how app.py reads `COMPRESS_DISABLED` from the environment, and its `compressionDisabled` log line, are no longer tested.
- **`test_generated_client_is_fresh.py` is gone** (template and `fleet/sharedFiles.json` entry): nothing checks any more that the committed schemas and `ServerRequests.ts` match a fresh AppCreator run, so rerun AppCreator after a model or route change and commit what it writes. `test_client_typecheck.py` stays. The json2tsBatch.js comment, the test_client_typecheck.py docstring, ARCHITECTURE.md and CLAUDE.md no longer point at it.
- **`test_unused_response_fields.py::test_everyResponseFieldIsReadByTheClient` skips for 7 days after a pass.** A pass (never a failure or a skip) writes `server/.testStamps/unusedResponseFields.json` (`{"lastPassedAt": <ISO 8601 UTC>}`, one per checkout or worktree, so a fresh worktree runs it; `server/.testStamps/` is in the scaffold's `.gitignore`). A run deletes it before the analyzer starts (`clear_pass`), so a failed or interrupted run deletes it and the next run runs again. A missing, unreadable or malformed stamp, or one dated in the future, runs it; `RUN_UNUSED_RESPONSE_FIELDS=1` (or true, yes, on) always does. The skip message gives the pass time in the project's timezone. The fast tests always run, and new ones pin the skip decision. When it runs, it still fails, never skips, without `npm install`.
- **`test_callerAuth.py` drops `test_exactlyTheServiceOnlyRoutes_requireAServiceCaller`**, which ran AppCreator's whole route code generation (`_all_functions_code('app')`, about 12 to 21 s). `test_theCommittedApp_requiresAServiceCallerOnExactlyTheServiceOnlyRoutes` is now the service-only guard: the same `_assertServiceRoutesGuarded` asserts on the committed app.py, so a route made serviceOnly without rerunning AppCreator still fails it. The push-handler tests are unchanged.
- **`responseModelCheck.py` no longer slows every app start.** `route_config` runs `assertResponseModel` at decoration, for every route, in every environment (each Cloud Run worker start), and `responseModelProblems` worded each field's `file:line` up front, through `inspect.getsourcelines`, which re-parses the model's whole module each call (thousands of calls per app import, most of its 25 to 65 s locally). The per-field location is now built only for a problem, and `_where` is cached. The same problems are found, with the same messages.
- **`AppConfig.py` (per repo) no longer re-runs `services/*.py` on every `AppConfig()`.** `PubSubConfig` executed every services file again on each call (about 8 to 14 ms; ecommerce's suite builds an AppConfig about 12,600 times, and every publish and transaction builds one), and `AppConfig.__init__` ran it twice (in most repos once through `Environment`'s `super().__init__()` and once by name; in accounting twice by name). The `(serviceName, PUBSUB_ENABLED_* variable)` pairs are now found once per process by `_serviceEnvVarNames()`, which imports the service modules (reusing the ones the app already imported) instead of running them again; the variables' values are still read on every init, with the same "not set" and "Invalid" errors. Every base calls `super().__init__()` and `AppConfig.__init__` is just `super().__init__()`, so each base runs once. `Environment` checks `ENVIRONMENT` before its `super().__init__()`, so an invalid one is the error even when the `PUBSUB_ENABLED_*` variables are unset too (accounting already did this; the other repos raised the missing-variable error first). A call now costs about 0.003 ms.
- **Service objects no longer ping their sibling when they are built** (`services/__abstractService.py`, per repo). Each `services/*.py` object did `self.isOnline = self.ping()`, an HTTP HEAD to the sibling (its prod URL on Cloud Run) at import, on every worker start. `isOnline` is now a property that pings on its first read and keeps the answer; the `isOnline` field is gone from `model_dump()`. The dead `__main__` block that built the abstract `AbstractService` is gone, and the `servicePortCheck.py` and AppCreator comments no longer point at an import-time `model_dump()` print (accounting printed two).

### Upgrading a 1.33.0 project to 1.34.0

1. `cd server && python sharedFileCheck.py --write` takes `cloudbuild.yaml`, `tools/updateJobsFromService.py`, `test_cloudbuild.py` and the new `tools/update_all_jobs.ps1`.
2. By hand (both are per repo): add the `ARG BUILD_CONFIG` guard from a fresh scaffold's `server/Dockerfile` after `WORKDIR`, before the first `COPY`; add `server/.dockerignore`, re-including by name (`!keys/<file>.json`) any tracked file under `keys/` the app opens at runtime. Run `test_cloudbuild.py`.
3. Merge to `main`. That push's build fails on purpose at `docker build` (the trigger still runs its inline build); the old revision keeps serving.
4. Switch the trigger to the file, once (the user runs it; `gcloud builds triggers update github --build-config` fails with INVALID_ARGUMENT on gcloud 493): `gcloud builds triggers describe <TRIGGER> --project=<P> --format=yaml > $env:TEMP	rigger.yaml` (PowerShell; `%TEMP%	rigger.yaml` in cmd: never in the repo, so it cannot be committed), delete its `build:` block, add `filename: cloudbuild.yaml`, move any `build.substitutions` to a top-level `substitutions:` map (drop `createTime` / `resourceName` if the import complains), then `gcloud builds triggers import --project=<P> --source=$env:TEMP	rigger.yaml`. Check with `gcloud builds triggers describe <TRIGGER> --project=<P> --format="yaml(filename,substitutions,build)"`: `filename: cloudbuild.yaml`, the substitutions, no `build:`.
5. Rerun the failed build (or push again). It goes green with an `update-jobs` step, and every `<service>-*` job carries the service's image.
6. `cd server && python sharedFileCheck.py --write` also takes `pubSubMapSection.py` (`publisherProdFlags`) and `pubSubModels.py` (`collectPublishers` now refuses a registry `publishToProdPubSub` the map reads otherwise; rerun AppCreator if it stops there). By hand (per repo): take a fresh scaffold's `server/checkPubSubTopics.py`, give the repo's `pubSub.checkPubSubTopics` the `topics` parameter, and make the Dockerfile CMD run `python checkPubSubTopics.py && exec gunicorn ...` (keep the repo's own gunicorn flags and any other pre-start step). With the cloud guard in `pubSub.checkPubSubTopics`, a local `python checkPubSubTopics.py` runs only the stale-manifest check. Retire any repo test that runs the old script's imports to fill the registry.
7. `cd server && python sharedFileCheck.py --write` also takes the shorter `test_response_compression.py` and the new `tools/json2tsBatch.js` comment and `test_client_typecheck.py` docstring. Delete `server/test_generated_client_is_fresh.py` (the manifest no longer lists it) and its mentions in the project's CLAUDE.md and ARCHITECTURE.md.
8. `sharedFileCheck.py --write` also takes the 7-day skip in `test_unused_response_fields.py`. Add `server/.testStamps/` to the project's `.gitignore`, and mention the skip and `RUN_UNUSED_RESPONSE_FIELDS=1` where its CLAUDE.md describes the gate.
9. `sharedFileCheck.py --write` also takes the shorter `test_callerAuth.py`.
10. `sharedFileCheck.py --write` also takes the faster `responseModelCheck.py`.
11. By hand (per repo): take a fresh scaffold's `_serviceEnvVarNames()` and `PubSubConfig` into `server/AppConfig.py`, add `super().__init__()` to `Project.__init__`, and make `AppConfig.__init__` call only `super().__init__()` (keep any check after it). In `services/__abstractService.py`, replace the `isOnline` field with the `_isOnline` PrivateAttr and the `isOnline` property; delete `self.isOnline = self.ping()` from every `services/*.py` and any `print(<service>.model_dump())`. `sharedFileCheck.py --write` takes the `servicePortCheck.py` docstring.

### 1.33.0 — caller auth

- **`callerAuth.py`** (new shared file, with `test_callerAuth.py`): the routes a machine calls check who calls them. AppCreator puts `@requirePushCaller` under the route of every generated Pub/Sub push handler and `@requireServiceCaller` under every `@route_config(serviceOnly=True)` route. Until now any caller who knew a service's run.app URL could POST a forged push (mark orders paid, overwrite customers) or call a service-to-service route. Both now require `Authorization: Bearer <Google-signed ID token>`:
  - RS256, signed by a cert Google serves (cached an hour, refetched at most once a minute, the last good certs kept through a failed fetch for 6 hours), unexpired, from accounts.google.com, with `email_verified`;
  - a push: `aud` is exactly `https://<the request's host><path>` (what pubSubAdmin.py configures), the email in `PUSH_SERVICE_ACCOUNTS`;
  - a service call: `aud` is `https://<the request's host>` (a trailing `/` ignored), the email in `SERVICE_CALLER_ACCOUNTS`.
  A refusal is a 401 `{"message": "Unauthorized", "status": 401}`, logged as `callerRejected` with the reason, function and User-Agent, never the token. Skipped in localdev only. `CALLER_AUTH_MODE=report` on a service logs a refusal and still runs the route while `CALLER_AUTH_REPORT_UNTIL` (a date, through that day UTC, or a datetime with its offset, at most 30 days ahead) has not passed; unset, unreadable or past, or any other mode, enforces. Report mode logs a WARNING at startup. `callerVerified()` tells a route whether this request's caller passed the check (not merely let through). The two account tuples are callerAuth.py's per-repo block, empty in a new project (every caller refused outside localdev).
- **`route_config(serviceOnly=True)`**: a route only another service calls, i.e. every route in `actions/ServiceToServiceActions.py` and no other (`test_callerAuth.py` pins both ways). It takes no `jwtRequired`, `createAccessToken` or `roleAccess`.
- **`services/__request.py`**: `_authHeaders(baseUrl)` sends `Authorization: Bearer <ID token>` for the sibling's URL (its audience) when the service runs in the cloud and the URL is not loopback, fetched from the metadata server (`google.oauth2.id_token.fetch_id_token`) and reused until 5 minutes before it expires. A failed fetch fails the call. A local run sends none (a user's credentials cannot mint one).
- **`pubSubAdmin.py`** no longer asks for the push service account: it uses `callerAuth.PUSH_SERVICE_ACCOUNTS[0]`, so the subscriptions and the handlers can never disagree, and exits while it is empty.
- **`sharedConstantsTS.py`** (new shared file, not in SalesApp, which has no shared constants): `renderSharedConstantsTS` and its secret and JSON guards move out of `AppCreator.py`, which imports it (so `from AppCreator import renderSharedConstantsTS` still works) and keeps `writeSharedConstantsTS`. The caller-auth and error-handling work took AppCreator.py past `test_file_sizes.py`'s 1,500 lines. The generated serverConstants.ts is unchanged.

### Upgrading a 1.32.0 project to 1.33.0

1. `cd server && python sharedFileCheck.py --write` takes `callerAuth.py`, `test_callerAuth.py`, `services/__request.py`, `test_service_request.py` and `sharedConstantsTS.py`. In `AppCreator.py`, delete the shared-constants section from `_TS_BANNER` through `renderSharedConstantsTS` and replace `import sharedConstants` with `from sharedConstantsTS import renderSharedConstantsTS`. Fill callerAuth.py's per-repo block: `PUSH_SERVICE_ACCOUNTS` (first: the account your push subscriptions' OIDC tokens use, `gcloud pubsub subscriptions list --format="value(pushConfig.oidcToken.serviceAccountEmail)"`), `SERVICE_CALLER_ACCOUNTS` (the runtime accounts of the siblings and jobs that call you).
2. In `route_config.py`, take the `serviceOnly` parameter, its validation and `func.serviceOnly`.
3. In `AppCreator.py`, take from a fresh scaffold: the two `callerAuth` imports (APP_IMPORTS, APP_PUBSUB_IMPORTS), `serviceOnly` in `_route_meta` and `_validate_route_meta`, and `caller_decorator` in `_function_code` (and the `{caller_decorator}` before `{jwt_decorator}`).
4. In `pubSubAdmin.py`, take `__pushServiceAccount()` in place of `__selectServiceAccount`.
5. Mark every route in `actions/ServiceToServiceActions.py` `serviceOnly=True`, then run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
6. Roll out: set `CALLER_AUTH_MODE=report` and `CALLER_AUTH_REPORT_UNTIL=<date the report phase ends, at most 30 days ahead>` on every service, deploy every service (callers then send tokens), redeploy the jobs that call a sibling, read the `callerRejected` log lines until only callers you mean to cut off remain, then remove both (enforcement also starts by itself after that date).

### 1.32.0 — error handling

- **`routeErrors.py`** (new shared file, with `test_route_errors.py`) decides how every generated handler answers a failure and what it logs. The except block of each route and push handler is now `failure = routeFailure(e, status=403|400)`, one `logging.log(failure.level, {<its log entry>, 'traceback': failure.traceback})` and `return failure.response()`:
  - `PermissionError`: 403 with its message; a `ValueError`: 400 with its message (a route raises one on purpose, so its text is user-facing); a pydantic `ValidationError`: 400 naming the failing fields and why, never the values (its str() quotes the input);
  - `mongoDb.OutdatedDataError` (new; the `_version` lock, still "Your data is outdated...", not a ValueError): 400;
  - a sibling's 400 with an envelope message (`ServiceError`): 400 with that message; any other `ServiceError` or `ServiceUnavailable`: 503 "<Service> is unavailable right now. Please try again. (error id X)";
  - a push handler whose envelope never parsed: 400 "Malformed Pub/Sub push";
  - **anything else: a real HTTP 500**, "Something went wrong on our side. Please try again. (error id X)". It was a 400 carrying `str(e)`, so no route could answer 5xx (Cloud Run's error metrics stayed at zero in an outage) and the exception text (a stored document's values in a ValidationError, a sibling's URL) reached the user and the log.
  Each failure is logged ONCE (no more `traceback.print_exc()` beside the log line): a caller's error as a WARNING, a server fault as an ERROR. The traceback is the stack and the exception type with the error id, never the exception's message, chained causes or locals; a push handler's response carries the same one. The error log keeps its keys (function, event, requestKeys or messageId/messageKeys, traceback), and GET routes now log their failures too.
- **A dict, model or list param must arrive as that JSON shape**: next to the scalar guard, the generated handler refuses any other value (a 400 "Invalid <param>"; None only for an Optional param or one with a default), so a bad shape never reaches the route as a TypeError, which is now a 500 (`_shape_of_annotation`, `_shape_params`).
- **app.py answers an exception raised outside a route's try** (serializing the response, a hand-written blueprint) through the same `routeFailure` (`@app.errorhandler(Exception)`, `_error_handler_block`), logged once; an `HTTPException` (404, 405) is answered as Flask would.
- `routeErrors.validationMessage` names a dict key in a field's location as `*` (only declared field names and list indexes stay): a key is the caller's data.
- `str(ServiceError)` carries no sibling message (only `.message` does, which routeFailure reads for a 400).
- **The generated TS client hands a 5xx to the page as status 0** with the server's message (`if (res.status >= 500) ...` after the body is read), so `ApiFailure` stays `0 | 400 | 401 | 403`. `AppCreator.STATUS_SERVER_ERROR` is 500.
- **`services/__request.py` is one HTTP client for sibling calls** (now a shared file, with `test_service_request.py`). Every call goes through one module-level `requests.Session`, and fails with one of two exceptions, never a raw `requests` one:
  - `ServiceError` (a `requests.HTTPError`, `.response` kept): the sibling answered a non-2xx status. It carries `service`, `route`, `status` and `message`, the `message` of the sibling's ApiResponse envelope (or None);
  - `ServiceUnavailable` (a `requests.RequestException`): no answer (refused, unreachable, timed out), raised `from None`.
  Neither puts the sibling's URL in its text. Code that catches `requests.HTTPError` or `requests.RequestException` still catches them. A 200 that is not JSON is a `ServiceError` too.
- **Every sibling call has a timeout**: `request(..., timeout=DEFAULT_TIMEOUT)`, `(5, 60)` seconds (connect, read), so a read can never hang forever and a slow sibling call is not cut short. A service class passes its own to tighten it (ecommerce keeps `(5, 18)` on all three of its services).
- `request()` takes `service=` (each service class passes its `cloudRunServiceName`, named in the errors) and `headers=` (added to the request's headers). `_authHeaders(baseUrl)` is the one place a header every call carries is added, merged last, so a caller's `headers=` can never replace one it sets (a service token). It still returns the sibling's whole JSON envelope.

**Deploy order (a fleet)**: a sibling's 400 message is passed on to the user. Until every service runs routeErrors, an old sibling still answers any exception with a 400 carrying its raw text, which would reach the caller's user. Deploy the callees first (accounting, warehouse, SalesApp) and the caller (ecommerce) last, or all of them close together.

### Upgrading a 1.31.0 project to 1.32.0

1. `cd server && python sharedFileCheck.py --write` takes `services/__request.py`, `test_service_request.py`, `routeErrors.py` and `test_route_errors.py`.
2. In each `services/<name>.py`, pass `service=self.cloudRunServiceName` to `request(...)` (and `timeout=` where the service needs a tighter one). Code that unwrapped a raw `requests.HTTPError` by hand still works (`e.response`), and can read `e.message` instead.
3. In `mongoDb.py`, add `class OutdatedDataError(Exception)` from a fresh scaffold and raise it in `update()` on a version mismatch, in place of the plain `Exception` (same message).
4. In `AppCreator.py`, take from a fresh scaffold: the `from routeErrors import routeFailure` import in place of `import traceback` (app.py and appPubSub.py), `STATUS_SERVER_ERROR`, the `error_log` / `push_arg` / `envelope_arg` lines and the two except branches in `_function_code`, `_shape_of_annotation`, `_shape_params` and the `shape_guard` (passed from `_all_functions_code`), `_error_handler_block` and its call in `generateAPI`, and the `if (res.status >= ...)` line after `data_decl` in `_ts_method_code`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
5. Every `raise Exception('<message for the user>')` (or another non-ValueError type) a route relies on to tell the user something becomes a 500 with the generic message: raise a `ValueError` instead. Check first that no `except ValueError` between the raise and the route would start catching it.

### 1.31.0 — ResponseModel: what a route sends is never a database model

- **`objects.ResponseModel`** (new base class): the responseModel of every route, the payload model of every Pub/Sub publisher (its route's responseModel or its `PUBSUB_PAYLOAD_MODELS` entry), and every model nested in either, all the way down. It is never a `MongoModel`, `AppModel`, `StoredDocumentKeys` or `*View`, and inherits from `ResponseModel`s only. Its fields are built from allowed leaves only (str, int, float, bool, None, datetime, date, a Literal, an Enum or a ResponseModel, through Optional, unions, `list[...]` and `dict[str, ...]`; never `Any`, a bare `dict` or `list`, `object`, a TypedDict, a dataclass, a tuple or a set), and it declares no `@computed_field`. It has `populate_by_name` and the copy-on-write empty-string coercion. PubSubRequests push handlers stay exempt.
- **route_config refuses a non-ResponseModel `responseModel` when the route is decorated** (the import fails, naming the route and every offender with its file:line): only a ResponseModel, or the RootModel of a bare cross-repo route (CROSS_REPO_BARE_RESPONSE_ROUTES), whose whole tree holds. `@pubSubDecorator` takes no model of its own: a publisher's model is its route's responseModel or its `PUBSUB_PAYLOAD_MODELS` entry, which `assertPublisherModels` checks.
- **A ResponseModel declares exactly what the route sends.** Under the localdev response check (`validate_route_response`'s context) it rejects every key that is not a field's serialized key (its alias, else its name: a field's name is refused when it has an alias), at any depth, as pydantic's `extra='forbid'` would (`<path>.<key>: Extra inputs are not permitted`, never the value), reported next to the other mismatches. Production still never validates a response and returns it unchanged; a ResponseModel validated outside the check ignores an extra key.
- **`server/responseModelCheck.py`** (new shared file): `responseModelProblems(model)` names every class and field of a response tree that breaks the rule (not a ResponseModel, a ResponseModel with a non-ResponseModel pydantic base, a leaf that is not allowed, a computed field), with its path and file:line, looking through a bare `RootModel`; `assertResponseModel` is route_config's check; `publisherModelProblems(projectRoot)` does it for every publisher `pubSubModels.collectPublishers` finds, and `assertPublisherModels` is AppCreator's refusal. `models_in(annotation)` is the one helper that finds the models an annotation holds (pubSubModels uses it too). **AppCreator refuses to generate** while either finds one (`validateRoutes` for the routes, right after the Pub/Sub manifest for the publishers), and **`test_responseModelsAreResponseModels`** (test_route_response_models.py) asserts it.
- **`projection_of(Model)`** replaces `_projection_of`: a nested ResponseModel (alone, in a list, a union of them) is read field by field as dotted keys, any other field whole, and `_id: 0` is added when the model declares no `_id` (Mongo returns it otherwise). A field that may be null, or a list whose items may be, is read whole: a dotted projection would drop a null or a legacy scalar instead of returning it. A nested ResponseModel in a shape no projection can express (a dict of them, a list of lists, a union with a scalar), or one with no fields, makes it raise. **`projectedCopy(Model, doc)`** cuts a document down to what the model declares at every depth, through lists, tuples, dict values, Annotated and unions (a union member chosen by its discriminator, else as the first member the value validates as, never by merging members' keys; a null or legacy scalar is kept): for a document the route built, or read whole. `ProjectedSubdocument` is gone.
- **`narrowing.py` is retired** (and dropped from the shared-file manifest): route_config no longer prunes a response (its two `narrowing` per-repo blocks are gone), since a ResponseModel lists only what is sent. `Omitted`, `omitted`, `kept`, `omit_computed`, `omitted_tree`, `prune_omitted` and `omitted_projection` go with it. `conftest.modelKeys` counts every declared field again.
- **Samples**: `UserResponse` (with `RoleResponse`) replaces `ClientUser` / `ClientUserView`: `loginWithGoogle` returns `projectedCopy(UserResponse, signInIdentity(...))` and `devLogin` reads with `projection_of(UserResponse)`. `DevUserRow`, `DevUserListResponse`, `RoleListResponse`, `HandshakeResponse`, `NoContentResponse`, `SampleTodoRow` and `SampleTodoListResponse` are ResponseModels. The client's `AuthContext` and `storedShapes.ts` take `UserResponse`. The fields sent are unchanged.
- **Whole-document rows are derived** (test_route_response_models.py `_isWholeDocument`, which replaces the hand list `WHOLE_DOCUMENT_MODELS`): a ResponseModel sends a stored document whole when, for a stored model (a MongoModel in objects.py) with a field that is no scalar, it copies every such field whole and at least half of its scalar ones.
- **The response check's mismatch message drops pydantic's `function-wrap[...]` loc segments** (a union member's validator).
- **A field typed `None` is refused**, except a never-sent guard, `x: SkipJsonSchema[None] = None`: a key that never goes out (absent or null), kept out of the TS type, never read by `projection_of` and dropped by `projectedCopy` at every depth and in every shape it walks, whatever it stores; a route that builds its dict without `projectedCopy` and leaves a value in it fails the check. `projectedCopy` on a top-level RootModel cuts its value through its root; `projection_of` refuses a RootModel (read it and cut it with `projectedCopy`).
- **Nothing in a response hides from the TS type**: SkipJsonSchema anywhere in a field (a union member, a list item) is refused, except on that guard and on a `SECRET_RESPONSE_KEYS` key (which every egress strips); so are `exclude` and a callable `json_schema_extra`. A field is read under one key, its alias else its name: a different `serialization_alias` or `validation_alias` (AliasChoices included) is refused.
- **`db.readResponse(Model, query, collection)` / `db.readResponses(...)` / `db.aggregateResponses(Model, pipeline, collection)`** (mongoDb.py) read with the model's projection and cut every document with `projectedCopy`: the read a route makes to answer with what it read. `aggregateResponses` appends `{'$project': ...}` to the pipeline (the caller's list is not changed). `readResponse` reads one document (None when none matches) and `readResponses` refuses `findOne` and `countOnly`. All three take `readWhole=(...)`, top-level keys read whole (a declared field a legacy-shape repair needs as stored, in place of its dotted keys, or an undeclared key the route reads for its own use; never a never-sent guard), and `prepare=fn(doc)`, run on every document as read, before the cut (a repair, an enrichment, a check that raises; it returns the document, and None is a TypeError). A sub-document read whole (a `readWhole` key, or one that may be null) may hold no never-sent guard at any depth: the read raises, so a guard's stored value is never even read. `db.projectionStage(Model, readWhole)` is a `$project` for the middle of an `aggregateResponses` pipeline (before a `$group` that pushes the rows): only `aggregateResponses` expands it, and `db.aggregate` refuses it. These three are the only code that reads a nested ResponseModel that may be null whole (`_projection_of_for_cut`, private to them; `test_cutProjection_isReachedOnlyThroughTheReadAndCutHelpers` fails on any other module that names it): `projection_of(Model)` takes the model alone and refuses such a model at any depth. The samples read with them.
- **A union of two or more ResponseModels declares a str discriminator** (`Field(discriminator='<tag field>')` on the field, or `Annotated[A | B, Field(discriminator=...)]` inside a list or a dict): `responseModelProblems` refuses an untagged one at any depth, naming its path and file:line (a callable `Discriminator` is no str tag). A union whose members carry two or more different models in lists or dicts (`list[A] | list[B]`, `A | dict[str, B]`) can carry no tag and is always refused: write `list[A | B]` with a discriminator. A model unioned with any dict (`A | dict[str, X]`) is refused too: both accept a dict, so a value that fails the model would go out whole as the dict; as a backstop, the cut never sends a dict value as a dict member beside a model, but cuts it to the model's keys. Unions are read through one walk (`_union_of`), so the standard optional form `Annotated[A | B, Field(discriminator='kind')] | None` (and inside a list, and a RootModel's own `Field(discriminator=)`) is checked and cut by its tag; an inner union's tag is carried outward only through None (`Annotated[A | B, tag] | C` or `... | list[C]` is an untagged union, refused), and every member of a tagged union declares the tag field as a Literal, and the tag field is read under its alias (else its name), its Literal through Optional and Annotated. One ResponseModel with scalars, None or containers needs no tag.
- **A union value is cut as the member its tag names**, never one chosen by validation; a value whose tag names no member, or a value of a union refused above, is cut down to the keys any member model declares (a list to nothing), never sent whole. A union with one model is cut as that model when the value validates as it, else through the dict or list member it validates as; a list no member accepts is cut to nothing.

### Upgrading a 1.30.0 project to 1.31.0

1. In `objects.py` (or `objects/base.py`), add `ResponseModel` and its helpers (`_sent_keys`, `_union_of`, `_members`, `_model_members`, `_holds_a_response_model`, `_may_be_null`, `_dotted_models`, `projection_of`, `_projection_of_for_cut`, `_projection`, `projectedCopy`, `_str_tag`, `_tag_of`, `_cut`, `_cut_union`, `_adapter`, `_carries_several_models`, `_literal_values`, `_tag_value`, `_member_for`) from a fresh scaffold, and to `mongoDb.py` `_ProjectionStage`, `_cutResponse` and the `readResponse` / `readResponses` / `projectionStage` / `aggregateResponses` methods (with `aggregate`'s refusal of a projection stage), and delete `ProjectedSubdocument` and `_projection_of`. Replace every `_projection_of(` with `projection_of(`; a read whose model may hold a null sub-document goes through the read-and-cut helpers (an aggregation through `aggregateResponses`). Give every union of two or more response models a str discriminator.
2. `cd server && python sharedFileCheck.py --write` takes `responseModelCheck.py`. In `AppCreator.py`, take from a fresh scaffold the `responseModelCheck` import, the `responseModelProblems` check at the end of `_validate_route_meta`'s ApiRequests branch, and the `assertPublisherModels` step after `buildManifest` in `__main__`. In `route_config.py`, replace the decorator's BaseModel check with `assertResponseModel(func.__name__, responseModel)` (wire it last: every route must be a ResponseModel by then, or the app does not import). In `test_route_response_models.py`, take the `test_responseModelsAreResponseModels` and `responseModelProblems` tests.
3. Turn every route's responseModel, and every publisher's payload model, into a ResponseModel all the way down: a `Client<Name>` model becomes a ResponseModel listing the fields it SENT (its Omitted fields left out, its kept nested models their own ResponseModels), named for what it is (`ClientSalesOrder` becomes `SalesOrderResponse`, say). A computed field a response sends becomes a field the route fills. Each route then returns exactly those fields: `projection=projection_of(Model)` on its read, or `projectedCopy(Model, doc)` on what it built, or an explicit dict. Watch a normaliser that validates a whole stored sub-document: read it whole, normalise, then cut it down.
4. A publisher's payload must not change (consumers parse it with models generated from it): compare `pubsub_models.json` before and after (only model names may change), run `test_pubSubContracts.py` and `test_pubSubModels.py`, and `python pubSubModels.py --push` then commit in each consumer when a name changed.
5. Delete `server/narrowing.py`, the two `narrowing` blocks of `route_config.py` and the `narrowing` import of `conftest.modelKeys`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`, retype the client where a schema was renamed, and run the full suite, `test_unused_response_fields.py`, tsc and the build.

### 1.30.0 — response compression, client type-check gates, unused-response-field gate

- **app.py compresses its responses** with flask-compress (`flask-compress==1.25` in `requirements.txt`, with its brotli and, below Python 3.14, backports.zstd pinned beside it: `Brotli==1.2.0`, `backports.zstd==1.7.0`, all with Python 3.10 wheels). A JSON or CSV body of 500 bytes or more is sent as brotli at quality 4, else gzip at level 6, when the request's `Accept-Encoding` allows it, with `Vary: Accept-Encoding`; a smaller body, an error response, another mimetype and a request without `Accept-Encoding` go out as they are. AppCreator turns flask-compress's own hook off (`COMPRESS_REGISTER = False`) and generates `_compressResponse`, which leaves `NO_COMPRESSION_ENDPOINTS` alone.
- **Kill switch**: `COMPRESS_DISABLED=1` (or `true`, `yes`, `on`, any case) in the environment, read when app.py starts, turns compression off, and app.py logs `{'event': 'compressionDisabled'}` once at start-up. Set COMPRESS_DISABLED=1 on the Cloud Run service to turn compression off without a code deploy.
- **The routes that issue an access token are never compressed** (`createAccessToken`, BREACH: the token rides next to sign-in data the caller sent). The sliding `jwtToken` of the other routes is compressed: the client sends its token in an `Authorization` header from localStorage, never in a cookie, so a cross-site page cannot make a victim's browser send the authenticated requests BREACH needs. `AppCreator._compression_block` explains it; ARCHITECTURE.md has a paragraph.
- **`test_response_compression.py`** (new shared test) pins the settings, that a large JSON or CSV body is compressed and decodes to the same content, that small, error and unaccepted responses are not, that `NO_COMPRESSION_ENDPOINTS` is exactly the createAccessToken routes and that none of them is compressed, and that `COMPRESS_DISABLED` turns it all off (and is logged). Its start-up subprocess scrubs every cloud database variable from the child's environment. (The start-up subprocess tests were removed in 1.34.0.)
- **AppCreator compiles the schemas in one node process**: `server/tools/json2tsBatch.js` (new shared file) runs json-schema-to-typescript, the global library behind the json2ts CLI, over every `<Name>Schema.json` at once. The output is the CLI's, byte for byte (checked on all 895 schemas of the fleet), in seconds rather than a process per file. It pins the library's major version (`EXPECTED_MAJOR = 15`) and fails, naming both versions, on another; it writes the .ts files only once every schema compiled. AppCreator passes node's message on in the error; missing node fails with the install hint. The stale-schema deletion is unchanged.
- **`test_generated_client_is_fresh.py`** (new shared test) regenerates `server/schemas/*.json`, the client's `schemas/*.ts` and `CLIENT_API_DIR` (ServerRequests.ts and its companions) into a temp folder, with AppCreator's folder constants monkeypatched, and fails on any working-tree file that differs (CRLF/LF ignored), is missing, or that no model generates any more; hand-written files in CLIENT_API_DIR are ignored. It refuses a vacuous comparison (an empty output folder, no ServerRequests.ts or ApiResponse.ts, schema JSON and TS counts that differ). It writes nothing into the repo. (Removed in 1.34.0.)
- **`test_client_typecheck.py`** (new shared test) runs `npx --no -- next typegen` (Next 15.5 and later) and `npx --no -- tsc --noEmit --pretty false --project tsconfig.json` in the client project (the folder with tsconfig.json at or above `CLIENT_SCHEMAS_DIR`) and fails on any error, listed as file:line:col, code and message (capped, with the total). No baseline. The `--` is required: `npx --no tsc ... -p <dir>` makes npx take `-p` as its own --package option and never run tsc.
  A green run does not prove a removed field is safe: casts into client interfaces, string column names, stored JSON and Python callers get past tsc.
- **`server/clientNode.py`** (new shared file) holds what the client gates share: the client project folder, the node/npx and node_modules checks with their "npm install" hint (it also requires node_modules/next when package.json depends on next), running a command with a timeout, and `capped()` for long lists.
- Both gates need node and the client's node_modules and fail, never skip, without them; neither touches a database. CLAUDE.md's Frontend commands gain the type-check line.
- **`test_unused_response_fields.py`** (new shared test) fails while any route sends a field no hand-written client code reads, and on every dynamic-access site that hides which fields a response-typed value needs (`Object.keys/entries/values`, `JSON.stringify`, a rest element, a flow into a loose type). No baseline, no allowlist. It runs `client/scripts/unusedResponseFields.ts` (new shared file, ts-morph; SalesApp keeps it at `client/salesapp/scripts/`), which follows each `RouteResponseModels` type through the client and lists the unread property paths. A route is excluded only when code outside client/ reads its response: it publishes to Pub/Sub (its response is the payload), or a sibling calls it over HTTP (`<client>.request('<route>', ...)`). The brief says how to trim the top route. Needs node and the client's `ts-morph` and `tsx` (pinned exactly as dev dependencies: `ts-morph@28.0.0`, `tsx@4.23.15`; the scaffold installs them).
  The analyzer runs from the client project (client/salesapp/ in SalesApp), so the gate passes every `--skip` relative to that folder, and a self-test checks each one names an existing path there. A sibling that cannot be read fails the gate. Only while there is no fleet yet (the checked-in `pubsub_map.json` lists no repo but this one) is a sibling that was never checked out and is not in that map skipped (a fresh scaffold's example service: no sibling yet); once the map lists another repo, a missing sibling fails ("clone it or pass --repo"), mapped or not. A route published to two topics keeps both reasons.
- **`server/actions/ServiceToServiceActions.py`** (new, per project): `class ServiceToServiceActions`, a mixin combined into `ApiRequests` like the others, holds the routes another service calls over HTTP (a sibling repo, a job, a script). Their response is a contract with that service, so the unused-response-field gate skips every route defined there (reason "called by another service (ServiceToServiceActions)"). It still scans the siblings for HTTP calls, now as enforcement: a route a sibling calls that is not in that file fails ("move it there"), and so does a route there that no sibling calls (stale). Analyzer problems of an excluded route still fail. The scaffold writes it empty (with `server/actions/__init__.py`). When the gate lists unused fields, its brief first asks whether a route is called by another service, and its trim how-to ends with the short form.
- **`server/narrowing.py`** (new shared file): `Omitted` / `omitted()` mark a field a client-facing response model (`Client<Name>`, a subclass of the stored model) omits, so it drops out of the generated TS; `omitted_tree`, `prune_omitted` and `omitted_projection` (with `readWhole` for a field a route validates before pruning) derive the response pruning and the Mongo projection from the same marks; `omit_computed` drops an inherited computed field; `kept` retypes a kept nested field; `is_omitted` is the predicate. **route_config prunes every response** with `prune_omitted(result, omitted_tree(responseModel))` before its localdev check, in two `# >>> star-app per-repo` blocks (route_config.py is not a shared file).
- **`sharedFileCheck.py`**: a manifest entry may name `onlyProjects` (the only projects that have the file; never a fresh scaffold), the counterpart of `exceptProjects`.
- **`objects.py` has `NoContentResponse`**, the responseModel of a route whose result is its status and message alone: no client code reads data from it (`test_unused_response_fields.py`), so it returns `{}`.
- **A fresh scaffold's unused-response-field gate starts green.** The todo mutations (`createTodo`, `markTodoAsCompleted`, `markTodoAsUnfinished`, `deleteTodo`) and `getUserRoleTypes` answer `NoContentResponse` (`{}`): the starter page reads none of them (`DeletedTodoResponse` and `UserRoleTypesResponse` are gone). `loginWithGoogle` and `devLogin` answer `ClientUserView` (`ClientUser` omits `needToBeLinked` and checks only the id; the view omits `_version`). `client/lib/storedShapes.ts` (new, per project) has `storedUser` / `StoredUser`, and `AuthContext` stores the user field by field (`userData` is a `StoredUser`). The starter page has a localdev dev sign-in (`devGetUserList`, `devLogin`), as the fleet's login pages do. `conftest.modelKeys` skips Omitted fields. `test_pubSubModels.py`: a project with no push handler passes `test_everyHandlerParsesItsGeneratedMessage[(none)]` (the map leaves `handlers` out at its default).
- **The handshake sample no longer sends `projectName`** (the starter page shows the scaffolded name instead).
- **The Pub/Sub map types a module publisher's dict literal with its `PUBSUB_PAYLOAD_MODELS` model**, as it already did a route's with its `responseModel`: the unknown values of the returned dict take the model's types, and the payload names the model (`dictLiteral <Model>`). So a module publisher can build its payload as a plain dict, checked by `route_config.check_route_response` in localdev only, without the map losing its types. Regenerate the map (`cd server && python pubSubMap.py --write-all`).
- **`deleteAllDataInDatabaseForDevEnvironment` runs in localdev only** (`getisLocalDevEnvironment`, was `getIsDevEnvironment`, which lets clouddev through): it empties every collection, and clouddev runs on Cloud Run against Atlas. Every sign-in without proof was localdev-only already. `test_db_wipe_localdev_only.py` (new shared test; ecommerce covers its wipe in its own test_identity_bypass_localdev_only.py) checks it refuses under clouddev, localprod, cloudprod and an unknown ENVIRONMENT, over a fake database.

### Upgrading a 1.29.0 project to 1.30.0

1. Add `flask-compress==1.25`, `Brotli==1.2.0` and `backports.zstd==1.7.0; python_version < "3.14"` to `server/requirements.txt` and install them.
2. In `AppCreator.py`, take from a fresh scaffold the `from flask_compress import Compress` import line, the `COMPRESS_*` lines (the `COMPRESS_DISABLED` kill switch included) and `compress = Compress(app)` after `CORS(...)` in the Flask config, `_compression_block`, and its call in `generateAPI` (between the functions and the main block). Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
3. `cd server && python sharedFileCheck.py --write` takes `test_response_compression.py`, `test_client_typecheck.py`, `clientNode.py` and `tools/json2tsBatch.js`. Run them.
4. In `AppCreator.generateTypeScriptSchemas`, replace the per-file `json2ts` loop with the batch call from a fresh scaffold (the `node` check, then one captured `subprocess.run([node, tools/json2tsBatch.js, input_dir, output_dir])` that raises with node's stderr). Install `json-schema-to-typescript@15` globally.
5. Install the client (`cd client && npm install`) and fix every error `test_client_typecheck.py` lists. A Next.js 14 client has no `next typegen`: if it imports images, commit a `.d.ts` with `/// <reference types="next" />` and `/// <reference types="next/image-types/global" />`, since next-env.d.ts is gitignored.
6. `cd client && npm install --save-dev --save-exact ts-morph@28.0.0 tsx@4.23.15`; `sharedFileCheck.py --write` (step 3) also takes `narrowing.py`, `test_unused_response_fields.py` and `client/scripts/unusedResponseFields.ts`. Add the two `narrowing` blocks to `route_config.py` from a fresh scaffold (the import, and the prune before `check_route_response`). Then make `test_unused_response_fields.py` green: for each route it lists, a `Client<Name>` model with `Omitted` fields (or read the field by name in the client where something needs it), never shrinking a stored `*View` model.
7. In the handshake route and `HandshakeResponse`, drop `projectName` (a client that shows it can use the name it was scaffolded with).
8. Add `class NoContentResponse(BaseModel)` (an empty model, with the docstring from a fresh scaffold) to `objects.py`, or to `objects/__init__.py` through its own module, and use it as the `responseModel` of a route no client reads data from, returning `{}`. Run `cd server && ENVIRONMENT=localdev python AppCreator.py`.
9. Take `server/actions/ServiceToServiceActions.py` (and `server/actions/__init__.py` if you have no `actions/` package) from a fresh scaffold, import it in `ApiRequests.py` and add `ServiceToServiceActions` to `class ApiRequests`'s bases. Move every route a sibling calls over HTTP into it unchanged (same name, URL, behaviour and response; `test_unused_response_fields.py` names them), then run `cd server && ENVIRONMENT=localdev python AppCreator.py`. Add to CLAUDE.md's Architecture: routes another service calls over HTTP go in `actions/ServiceToServiceActions.py`.
10. In `mongoDb.py`, gate `deleteAllDataInDatabaseForDevEnvironment` on `AppConfig().getisLocalDevEnvironment()`, and run `cd server && python sharedFileCheck.py --write`, which takes `test_db_wipe_localdev_only.py`. Check the project's other dev-only paths (dev sign-in, a client-sent identity, any wipe) the same way: localdev only, never `getIsDevEnvironment`.

### 1.29.0 — generated Pub/Sub message models

- **`pubSubModels.py`** (new shared tool, with `test_pubSubModels.py`; pure stdlib, Python 3.10). A publisher commits `server/pubsub_models.json`: the serialization schema of every topic's payload model (its route's `responseModel`, or a `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}` entry for a publisher that is no route), with secret keys stripped. A consumer renders the topics it consumes into `server/objects_<publisherProjectName>.py`: one pydantic class per object schema, fields and types only, and a `<Fn>Message` per topic. Never edit one, and keep logic out of it. `python pubSubModels.py` checks (exit 1 when a generated file is stale); `--pull` (`--write`) regenerates them from each sibling's committed manifest; `--push` pulls in every same-branch consumer checkout (it never commits); `--write-manifest` writes the manifest (it imports the app). A sibling's manifest is untrusted input (validated, bounded, rendered through `ascii()` literals), a sibling's code never runs, and a hand-written file at a target path is never overwritten.
- **AppCreator**: (B1) it pulls the generated models before it imports the app, since the handlers import them; (B2) it builds the manifest right after `validateRoutes()` (a publisher without a payload model stops the run) and writes it right before the map. `--constants-only` does neither.
- **Handler gate**: `test_everyHandlerParsesItsGeneratedMessage[<handler>]` requires each push handler to parse its whole message once with its generated `<Fn>Message` (`.model_validate(data)` or `(**data)`; named keywords such as `strict=True` are allowed) and to read the raw payload only in a single-type `except ValidationError:` fallback on that parse. The rule is `pubSubModels.handlerProblems`. `test_ifAllPubSubMockDataParsesWithGeneratedModels` (in the shared "Static contract tests" region of `test_pubSubContracts.py`) parses every mock message addressed to the repo with its generated model, as stored and as sent on the wire (`json.loads(pubSub.convertToJson(payload))`), and names the failing field.
- **Map**: a parse with a generated model is one root row, and reads of its nullable sub-models and lists (attributes, forwards, dumps) are tracked, so an unguarded read of a nullable one is a `nullable` issue.
- **Serializer guard**: the manifest refuses a payload model (at any depth) whose `field_serializer`, `model_serializer`, `PlainSerializer` or `WrapSerializer` has no return type, since the serialization schema would then state the declared type instead of what is sent.
- **`route_config.check_route_response(func, result)`**: the localdev response check for a payload published outside the route (a hand-publish site) or by a publisher that is no route. Its model is `func.responseModel`, else the `PUBSUB_PAYLOAD_MODELS` entry of func's module.
- **`PUBSUB_MESSAGE_MODELS` is retired**: defining it in any form is a hatch error, and the scaffold no longer defines it. `PubSubRequests.py`'s docstring example parses generated messages.
- **Deploy order**: see `ARCHITECTURE.md`, "Deploying strict consumers".

### Upgrading a 1.28.0 project to 1.29.0

1. `cd server && python sharedFileCheck.py --write`: it takes `pubSubModels.py`, `test_pubSubModels.py`, the map tools, `test_pubSubMap.py`, `test_sharedFiles.py` and the shared regions of `test_pubSubContracts.py`.
2. Apply the not-yet-shared parts by hand, from a fresh scaffold. In `AppCreator.py`: `_pubSubStep`, the pull before the app import (B1), and `buildManifest` / `writeManifest` around the generators (B2). In `route_config.py`: `check_route_response` and `_payload_model`. Then call `check_route_response(<publisher>, payload)` at every hand-publish site.
3. Give every publisher a payload model: a `responseModel` on its `@route_config`, or `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}` in the module of a publisher that is no route. Build its return value through that model, and give every serializer in a payload model a return type.
4. `cd server && ENVIRONMENT=localdev python AppCreator.py`: it writes `server/pubsub_models.json`, pulls `objects_<publisher>.py` for every publisher that has a manifest, and refreshes the map. Commit them. In a fleet, adopt in every publisher first, then run `--push` from it (or `--pull` in each consumer) and commit each consumer.
5. Migrate every handler, one or a few per commit, with its tests run each time:
   - parse with `message = <Fn>Message.model_validate(data)` and read only from `message`;
   - move the consumer logic into helpers that take the message and build what they store through this repo's own models (never a dump of the message: it does no empty-string coercion and carries the publisher's `_version`); a dump that reaches the database is python-mode `model_dump(by_alias=True, exclude_unset=True)`;
   - where the old handler acted on only a few fields, keep that action in an `except ValidationError:` fallback with the old lenient reads, so drift elsewhere in the payload does not drop it;
   - tests feed full payloads the publisher can produce.
6. Delete `PUBSUB_MESSAGE_MODELS` and its comment from `PubSubRequests.py`. Regenerate the map in every repo (`--write-all`), commit, and deploy in the order below.

**This fleet** (ecommerce, accounting, warehouse, salesApp): before ANY strict consumer deploys, back up `SalesOrders` (`mongodump --collection SalesOrders`), then run ecommerce's `server/migrate_legacy_order_shapes.py` against production, first as a dry run and then with `--apply`. Also run the prod-dump scan on each repo's locally restored `test<project>` database:
- SalesOrders with a numeric `freight` or a top-level `freightDocuments`, a flat `approvalHold`, a string `deliveryDate`, a missing `customerName` or `contactDetails`, or a fractional `itemsOrderList.quantity`;
- for every date-time path in each manifest, a stored value that is not a BSON date.

Fix each finding first (each data fix approved on its own), then deploy publishers before their consumers.

### 1.28.0 — response check in localdev only

- **`route_config` checks a response in localdev only**: there (and so in tests) a mismatch still raises `ValueError`, with the mutation guard. Every other environment skips the check entirely (no validation, no deep copy, no log), because validating every response cost every production call time. The `responseModelMismatch` warning, its `loc` masking (`_masked_loc` and its helpers) and the `logging`/`annotationLeaves` imports they needed are gone; the route's value is still returned unchanged.
- **conftest**: the `responseCheckWarnsOnly` fixture is now `responseCheckOff` (it turns the localdev check off for a test that pins a shape no model describes), and `mismatchRecords` is gone.
- **`test_route_response_models.py`**: the tests outside localdev pin that the check never validates, copies or logs; the warning and masking tests are gone.

### Upgrading a 1.27.0 project to 1.28.0

Take `_check_response_model`, the wrapper's check call and the imports from a fresh scaffold's `route_config.py`. In `conftest.py`, rename the `responseCheckWarnsOnly` fixture to `responseCheckOff` (and every test that uses it), take its comment from the scaffold and delete `mismatchRecords`. In `test_route_response_models.py`, take the scaffold's `_assertCheckSkipped` helper (it needs `from types import SimpleNamespace`) and its `...skippedOutsideLocaldev`, `test_check_neverValidatesNorCopiesOutsideLocaldev` and `test_responseCheckOff_fixtureTurnsTheCheckOff` tests in place of the warning tests, which you delete with any helper only they used.

### 1.27.0 — strict Pub/Sub contract check

- **Compat is strict**: every payload/handler mismatch (`undeclared`, `nullable`, `literal`, `numericNarrowing`, `forbiddenExtra`) fails `test_pubSubMap.py` and `python pubSubMap.py` in both repos of the flow, and nothing suppresses one: fix it at its source, the publisher's payload model or the consumer's handling (`--explain <handler>` shows both sides). `PUBSUB_CONTRACT_GAPS` is retired: defining it in any form, even `{}`, is a hatch error. `PUBSUB_PAYLOAD_MODELS` stays; it declares a model and suppresses nothing (1.29.0 retires `PUBSUB_MESSAGE_MODELS`). A flow's verdict is `ok`, `issues` or `unverified` (`knownGap` and `notEnforced` are gone), and the map's `formatVersion` is 3.

### Upgrading a 1.26.0 project to 1.27.0

Run `cd server && python sharedFileCheck.py --write` (it takes the new map tools and `test_pubSubMap.py`), then delete `PUBSUB_CONTRACT_GAPS` and its comment from `PubSubRequests.py` (going on to 1.29.0, delete `PUBSUB_MESSAGE_MODELS` and its comment too). Run `python pubSubMap.py`, fix every compat issue it reports at its source (in whichever repo owns it), then regenerate and commit the map in every repo of the fleet (`--write-all` regenerates them at once).

### 1.26.0 — Pub/Sub map and shared-file check

- **Pub/Sub map**: `pubSubMap.py` (with `pubSubMapScan.py`, `pubSubMapContract.py` and `pubSubMapSection.py`; pure stdlib, Python 3.10, no app import) writes `server/pubsub_map.json` and `server/PUBSUB_MAP.md`: every `@pubSubDecorator` publisher in the fleet, each consumer's handler, the payload paths each handler reads, and whether the publisher's types satisfy them. Siblings are found through every repo's `services/*.py` (`localRepoDirName`) and read at a committed git ref (this branch's name, else `main`); a missing one falls back to the checked-in snapshot and gets a gitignored `server/services/<name>.notFound.md` note (`--write` and the AppCreator hook refuse while a sibling this repo needs is neither checked out nor in the snapshot). `python pubSubMap.py` exits 0 only when the map is fresh and clean for this repo, equivalent to `test_pubSubMap.py` (1 = stale or a failing problem, 2 = an error); `--write` regenerates, `--explain <topic|handler|publisherFn>` prints live `file:line`, and `--repo name=path[@ref]` / `--worktree-siblings` override where siblings are read.
- **`--write-all`** regenerates the map here and in every sibling checkout on the same branch, with this repo's generator in-process (a sibling's code is never run). It skips other-branch, detached, missing and not-yet-adopted siblings with a reason, never stages or commits, and ends by listing the repos to commit.
- **`test_pubSubMap.py`** fails on a stale map, a consumer with no handler, a handler with no publisher, a message contract it cannot resolve, a publisher `pubSubAdmin.py` cannot see, a hand `publishMessage` on a topic that is not a decorated function's, a dead escape hatch or gap, and a compat issue (`undeclared`, `nullable`, `literal`, `numericNarrowing`, `forbiddenExtra`) on a consumer that enforces compat. Static and type-level only: the `test_pubSubContracts.py` replay stays the ground truth.
- **AppCreator** runs `writeMap()` at the end of a full generation (never with `--constants-only`), so a new scaffold's first run writes the initial map; a hard map error stops the run.
- **`PubSubRequests.py`** defines empty `PUBSUB_MESSAGE_MODELS` (a hatch 1.29.0 retires: a handler parses its generated message instead) and `PUBSUB_CONTRACT_GAPS` (`{'<handler>': {'<path>': {'<issue>': '<reason>'}}}`), so a new project enforces compat from the start (1.27.0 retires `PUBSUB_CONTRACT_GAPS`: compat is always enforced). A publisher's module may declare `PUBSUB_PAYLOAD_MODELS = {'<fn>': '<Model>'}`.
- **`sharedFileCheck.py`** and **`test_sharedFiles.py`** hold the files `fleet/sharedFiles.json` lists to this repo's committed HEAD (see "Shared files"). `--write` takes the template's version, `--template-worktree` compares with uncommitted template work, `--template <dir>` names another checkout.
- **Markers**: `test_pubSubContracts.py` marks its "Shared AST helpers" and "Static contract tests" regions, and `test_ifAllServicesMatchConsumerProjectName.py` a per-repo `SERVICES_WITHOUT_MOCK_DATA_YET` block.
- **`.gitignore`** ignores `server/services/*.notFound.md`.
- **`fleet/`** (new, outside the package): `sharedFiles.json` and the fleet-only `apiNamespace.py` and `test_objects_package_exports.py`.

### Upgrading a 1.25.0 project to 1.26.0

1. Mark the shared parts. In `server/test_pubSubContracts.py`, wrap the "Shared AST helpers" block and the static tests in `# >>> star-app shared: Shared AST helpers` / `# <<< star-app shared: Shared AST helpers` and `# >>> star-app shared: Static contract tests` / `# <<< star-app shared: Static contract tests`, and move anything project-specific (a wipe helper, a collection list) below them. In `test_ifAllServicesMatchConsumerProjectName.py`, wrap the `SERVICES_WITHOUT_MOCK_DATA_YET` line in `# >>> star-app per-repo: SERVICES_WITHOUT_MOCK_DATA_YET` / `# <<< star-app per-repo: SERVICES_WITHOUT_MOCK_DATA_YET`.
2. Copy `server/sharedFileCheck.py` from a fresh scaffold (not the raw `.tmpl`, which escapes `$`), check this repo out at `../create-star-app`, and run `cd server && python sharedFileCheck.py --write`: it writes the map tools and both new tests. Without a checkout, copy `pubSubMap.py`, `pubSubMapScan.py`, `pubSubMapContract.py`, `pubSubMapSection.py`, `test_pubSubMap.py` and `test_sharedFiles.py` from the fresh scaffold too.
3. Add the `writeMap` hook to the end of `AppCreator.py`'s `__main__`, the `server/services/*.notFound.md` line to `.gitignore`, and the two dicts (with their comments) to `PubSubRequests.py`. Defining `PUBSUB_CONTRACT_GAPS` turns compat enforcement on: fix, or record with a reason, every issue `python pubSubMap.py` reports, or leave the dict out until you can. Going on to 1.27.0, add neither dict if you are also taking 1.29.0 (it retires both); otherwise add only `PUBSUB_MESSAGE_MODELS`: 1.27.0 retires `PUBSUB_CONTRACT_GAPS`.
4. Run `cd server && python pubSubMap.py --write` and commit `pubsub_map.json` and `PUBSUB_MAP.md`. In a fleet, regenerate and commit them in every repo, with the same branch name checked out in each (siblings are read at this repo's branch name if they have it, else at `main`): `--write-all` regenerates every sibling checkout on the branch at once, and the commits stay manual.

### 1.25.0 — mock drift test

- **`test_mock_drift.py`** fails while a test's fake database returns a hand-written document. It scans every git-listed test file under `server/` (and `conftest.py`) for a db fake (`<...>.<db method>.return_value` / `.side_effect`, `MagicMock(return_value=...)` on a db method, `patch('<...>.db.<db method>', return_value=...)`, `patch.object(<...>, '<db method>', return_value=...)`) whose value is a dict literal, a `dict(...)` call, a list, tuple or `iter(...)` of them, or a name the same function (else the module) binds to one. Such a fake keeps its old shape when a model renames or removes a field, so the test passes against data the real database never returns. The failure lists each fake and how to fix it.
- **`fakeDoc(Model, **fields)`** in `conftest.py` builds a fake document through its model: it validates the fields, refuses any key the model does not declare (nested models and lists of them included) and returns the dump by alias (`_id`, `_version`, defaults). A value the model rewrites (`''` -> `None`) arrives rewritten; set it on the returned dict to feed a raw one.
- **Exemption**: a deliberately raw document (a legacy shape a normalization test repairs, a malformed record) keeps its dict and ends the fake's statement, or the statement binding the name, with `# raw-doc: <reason>` (a reason of at least 10 characters).
- **Known limits** (convert these by hand): documents returned from the body of a `def` used as a `side_effect`, and documents passed into a helper as parameters. Not scanned: anything in `venv`, `.venv`, `node_modules` or `__pycache__`.

### Upgrading a 1.24.0 project to 1.25.0

Copy `server/test_mock_drift.py` from a fresh scaffold (it is the same in every project; from 1.26.0 on, `cd server && python sharedFileCheck.py --write` takes it), and paste the `# --- Fake documents ---` block (`_declaredKeys`, `_unknownKeys`, `fakeDoc`) from its `server/conftest.py` into yours below `modelKeys`, adding `from pydantic import BaseModel, RootModel`. Run the test: an existing project usually fails at first. Convert each fake it lists to `fakeDoc(<Model>, ...)` (a repo factory such as `unitDoc` can wrap it), or mark a deliberate one `# raw-doc: <reason>`. Change test setup only, never an assert; a test that fails once its fake goes through the model was hiding a real mismatch, so report it.

### 1.24.0 — file size test

- **`test_file_sizes.py`** fails while any hand-written source file outside `client/` (`.py .ts .tsx .js .jsx .mjs .cjs .css`) is over 1,500 lines. The failure is a brief for the smallest oversized file: an outline with line ranges, how to split that kind of file (`ApiRequests.py` into `server/actions/<Name>Actions.py` mixins, an oversized `*Actions.py` into topic mixins, `objects.py` into an `objects/` package), the rules (move code only, retarget patches, never raise the limit or change an assert) and the checks that mark the split done (unchanged generated code, unchanged test count, full suite). Not checked: the client folder, files AppCreator generates (paths read from AppCreator.py), files whose name starts with `test`, and anything in `venv`, `.venv`, `node_modules`, `.next` or `__pycache__`.
- **`test_route_coverage.py`** fails when a route (a public `@route_config` method on ApiRequests or a PubSubRequests push handler) is not called by any test, as `api.<route>(...)` or a request to `/<route>`, outside `pytest.raises`. It also fails when a test calls a route but asserts nothing (no `assert`, no `pytest.raises`, no `assert*` helper or mock `assert_*`).

### Upgrading a 1.23.0 project to 1.24.0

Copy `server/test_file_sizes.py` from a fresh scaffold (it is the same in every project; from 1.26.0 on, `cd server && python sharedFileCheck.py --write` takes it) and run it. An existing project usually fails at first; split the files it names one at a time.

### 1.23.0 — typed API responses

Ports the typed-responses redesign (ecommerce, SalesApp, accounting, warehouse) into the templates:

- **Every route declares a `responseModel`** (`@route_config(responseModel=<objects.py model>)`); AppCreator refuses to generate without one (enforcement on from the start; `PubSubRequests` handlers are exempt). `route_config` checks every response against it: it raises in localdev (so in tests) and elsewhere logs `responseModelMismatch` with `type`/`loc` only (dict keys masked as `<key>`), never values, and never copies outside localdev.
- **Typed client**: `ServerRequests.ts` methods return `Promise<ApiResponse<Model>>`; the generated `ApiResponse.ts` is the `ApiSuccess<T>` / `ApiFailure` union built from the `STATUS_*` constants; `RouteResponseModels`, `RouteRequestBodies` and `PublicRoute` maps; imports derived from the routes; no `any`.
- **AppCreator**: schemas from the cached real `objects` module (BaseModel classes only, no silent skips), closed `$defs`, `$ref` siblings wrapped in `allOf`, the `X1` collision guard, stale-schema deletion, `json2ts` via `subprocess.run(check=True)`, `validateRoutes()` before any write, param guards (no date/datetime/time, `= None` needs Optional, renderable containers only, `objects.py` classes only), the secret-key guard, and the `CROSS_REPO_BARE_RESPONSE_ROUTES` allowlist (empty).
- **Generated handlers** bind the request body unconditionally (a body that is not a JSON object reads as {}, so a route's params are None and it answers with its own envelope, never a 500), refuse an object or a list for a scalar param (a `{"$ne": null}` never reaches a query), log which of their own params a POST sent (never values), and a login route logs no request data. Pub/Sub push handlers parse the envelope inside the try (a malformed push is a 400) and log the messageId and the message's keys.
- **route_config** refuses `roleAccess` without `jwtRequired=True`; the mismatch log masks int dict keys too.
- **Local runs bind 127.0.0.1**; `LOCAL_BIND_HOST=0.0.0.0` in `server/.env` opens the server to the LAN, with a startup warning. Scaffolding writes a random `JWT_SECRET_KEY`, and app.py refuses one under 32 characters (or the old placeholder) outside localdev. Flask's `MAX_CONTENT_LENGTH` is 16 MB.
- **mongoDb.py** prints a query's keys, never its values.
- **objects.py**: copy-on-write empty-string coercion, `StoredDocumentKeys`, `validate_route_response`, `_projection_of` / `ProjectedSubdocument` / `projectedCopy`, typed sample models (`SampleTodoView`, the `SampleTodoRow` row model, response wrappers). (Superseded in 1.31.0: a response is a `ResponseModel`, read with `projection_of` and cut with `projectedCopy`; a `*View` is never a response.)
- **Sign-in**: `loginWithGoogle` verifies the Firebase ID token (google-auth with a cached cert set: an RS256 token with a known key id only, at most one fetch attempt a minute, the last good certs kept for 6 hours while Google is unreachable, an answer of the wrong shape refused), requires a verified email and the google.com provider, and accepts a raw identity in localdev only; `devLogin` / `devGetUserList` are localdev only. The project id is `FIREBASE_PROJECT_ID` in `sharedConstants.py`, read by the server and `client/lib/firebase.ts`; while it is the placeholder, sign-in fails closed with a warning. The first superAdmin is an atomic one-time claim. The client's `AuthContext.signInWithGoogle` sends `{ idToken }`.
- **utils**: strict `appDayStart`, `appDayBounds`, `parseIsoInstant`.
- **Tests**: `conftest.py` refuses non-localdev and non-`*Scratch` databases, unsets the cloud DB variables (again after AppConfig loads `.env`) and installs `_LocalOnlyMongoClient` (sync, async and `pymongo.synchronous`) before any import, and holds the sign-in fixtures; new `test_route_response_models.py`, `test_auth_surface.py`, `test_route_responses.py` and `test_route_responses_http.py`.
- **Client**: narrows on `status === 200` with the generated types, `eslint.config.mjs` bans importing `schemas/ApiResponseSchema`, and the starter is lint-clean (`npm run lint`: 0 errors); `DevClickToComponent` renders in development only.
- **Docs**: CLAUDE.md, ARCHITECTURE.md, DO_NOT_DO.md, `.coderabbit.yaml`, `.vscode/tasks.json` and a project section appended to `.claude/context/TYPE_SAFETY.md`.
- **Breaking for the sample routes**: `getRoles` answers `{roles}`, `getUserRoleTypes` `{userRoleTypes}`, `devGetUserList` `{users}`, `getTodos` `{todos}`; `FirebaseUserObject` moved to `objects.py` and `AuthHandler.getOrCreateUser` became `signInIdentity`.
- **Deferred**:
  - `json_schema_serialization_defaults_required` (T5) together with validation-mode request-param TS types (T5b). Routes return raw dicts and the model rules allow omittable fields (`X = Field(None)`), so a defaults-required serialization schema would type keys the route can leave out as always present. A model always returned as a full `model_dump()` may opt in per model (documented in `objects.py`).
  - A guard against `@computed_field` on response models. pydantic lists a computed field as required in the serialization schema, so the TS type declares it, but a route returning a raw dict never sends it. The existing repos use computed fields, so it is documented (DO_NOT_DO.md, `objects.py`) rather than enforced.
  - Requiring an allowlist of bootstrap superAdmin emails outside localdev (the first sign-in on an empty Users still becomes superAdmin, now through an atomic claim).

### Upgrading a 1.22.0 project to 1.23.0

The scaffold overwrites every template file in place, so an existing project upgrades by hand. In this order:

a. **Server infrastructure.** Re-scaffold into a scratch directory with the same name and port, then copy over `route_config.py`, `AppCreator.py`, `conftest.py`, `utils.py` (the date parsers and annotation walkers), `AuthHandler.py`, the base of `objects.py` (from the top through `ApiResponse`, plus `UserView`, `FirebaseUserObject`), the `mongoDb.py` print changes, `sharedConstants.FIREBASE_PROJECT_ID`, the `'Bootstrap'` entry in `pubSubMockDataGenerator.py`'s `TEST_COLLECTIONS`, `services/__getIsProductionEnv.py`, and the four new `test_*.py` files. Copy the sign-in routes of `ApiRequests.py` (`loginWithGoogle`, `devGetUserList`, `devLogin` and the `_requireLocalDev` helper) and keep your own models and other routes.
b. **A `responseModel` on every route.** Add `responseModel=<objects.py model>` to every `ApiRequests` route: a `*View(StoredDocumentKeys, Model)` for a stored document, a row model with `projection=_projection_of(Row)` for a projected read, a `...Response` wrapper otherwise. AppCreator refuses to generate until every route has one. Give `roleAccess` routes `jwtRequired=True`. (Superseded in 1.31.0: every responseModel is a `ResponseModel`, read with `projection_of(Model)` and cut with `projectedCopy`; see the 1.30.0 to 1.31.0 upgrade.)
c. **Wrap bare answers.** A route returning a list or a primitive now returns `{'key': value}`; update its callers (the sample routes: `getRoles` → `{roles}`, `getUserRoleTypes` → `{userRoleTypes}`, `devGetUserList` → `{users}`, `getTodos` → `{todos}`). Never reshape a `@pubSubDecorator` route or a route another service reads.
d. **Route params.** Replace `datetime`/`date` params with an ISO `str` parsed by `utils.appDayStart` / `appDayBounds` / `parseIsoInstant`, a bare `dict`/`list` with a model or a typed container, and a `= None` default without `| None`.
e. **Sign-in.** Put the Firebase project id in `sharedConstants.FIREBASE_PROJECT_ID` (or the `FIREBASE_PROJECT_ID` env var), have the client send `loginWithGoogle({ idToken })` (`AuthContext.signInWithGoogle`), and deploy the backend and frontend together: the old raw-identity login is refused outside localdev.
f. **Secrets and local runs.** Give every deployed environment a `JWT_SECRET_KEY` of at least 32 random characters before deploying (app.py refuses a weaker one outside localdev). Local runs now bind `127.0.0.1`; set `LOCAL_BIND_HOST=0.0.0.0` in `server/.env` only if devices on your network must reach it.
g. **Client and tests.** Copy `client/eslint.config.mjs`, `client/lib/AuthContext.tsx`, `client/lib/firebase.ts` and `client/app/layout.tsx`, run `ENVIRONMENT=localdev python AppCreator.py`, fix every `npx tsc --noEmit` error by narrowing on `res.status === 200` (no `res.data as X`), and run the tests with `ENVIRONMENT=localdev databaseName=test<projectName>Scratch pytest` (conftest refuses any other database).
