Chirp 0.8.0

The verified SQL→render data layer (Shapes), server-owned reactive values (signals), a security-by-default hardening wave, unified form bind+validate, FileResponse, and HTTP 103 Early Hints.

Focus: give hypermedia apps a verified data layer and a single-connection reactive primitive, and make the secure posture the default rather than an opt-in.

This release lands two large new surfaces — the Shapes SQL→render data contract and the signal() reactive primitive — alongside a security-by-default wave that tightens the framework's out-of-the-box trust boundaries (default CSP, SSE origin, upload and static-file limits) and a broad expansion of the app.check() startup contracts. It requires bengal-pounce>=0.8.0. A handful of stable defaults changed; see Migration Notes before upgrading.


Highlights

Shapes — the verified SQL→render data contract

Chirp's answer to the ORM, without becoming one. Each hypermedia block declares the exact dataShape it needs as a frozen, slotted dataclass with its SQL co-located on the decorator; the shape runs behind the Database facade producing frozen instances; and the new shapecheckcontract proves at startup that the block reads only fields the query provides.

The full set ships in this release:@shape + Shape (fetch/fetch_one/stream), computed= members, nested(...) batched child shapes (a bounded 1 + child-levels query count, independent of child row count, fail-loud when un-expressible), per-@shape/@composite tenant scope= injection, page-level @composite aggregation behind the repository seam, the shapecheck registry-drift / under-fetch / over-fetch checks, and the chirp shapes-codegen CLI for adopting Shapes view-by-view with an --auditdrift report.

The honest marquee is the field-level + registry-drift startup contract — the thing no ORM, SQL tool, or GraphQL layer can offer, because only Chirp owns both the query and the template. Field-level verification is conservative single-object analysis with documented escape hatches; the Shapes guide explains exactly when the check stays silent and how to tune severity.

signal() — server-owned reactive values over one SSE connection

Declare a live value with@app.signal(name, ...), a computed one with @app.derived(name, on=(...)), push updates with app.emit(name, value), and bind it anywhere in a template with {{ signal('name') }} / {{ signal_block('name') }} under a single {{ signal_connect() }}wrapper.

A named value fans out to every binding — declare once, bind many — over one shared/_chirp/live stream, so an SSE-heavy page holds one connection instead of N (a topbar balance and a modal update together from one app.emit). app.check() validates bindings against producers (signal_dead_binding → ERROR, signal_orphan → INFO), and a derived must be a pure function of its inputs so it stays correct across workers. The single-node primitive ships now; a pluggable multi-worker SignalBus backplane is designed in the live-SSE-topics RFC (§12). Run reactive apps with workers=1 / worker_mode="async"until the backplane lands.

Secure by default

The secure posture is now what you get out of the box:

  • Secure-by-default scaffolds. Everychirp new variant — including --minimal and --shell — wires the SessionCSRFSecurityHeaders stack, reads CHIRP_SECRET_KEY, and passes the contract in production unchanged.
  • security_stack contract. app.check() flags any app with a mutating route (POST/PUT/PATCH/DELETE, or a filesystem page shipping _actions.py) that is missing the stack — ERROR in production, WARNING in staging, silent in development. This category is now the canonical owner of the "mutating route" definition.
  • Default CSP drops'unsafe-inline' from script-src, with full per-request nonce coverage across every framework inline script (Suspense, SSE, Alpine, view-transitions, islands, speculation rules). A standard alpine=True app no longer needs alpine_csp=True, and use_chirp_ui()owns its own working CSP.
  • DoS hardening. Static files stream from disk past a threshold instead of buffering into RAM; uploads spool to disk with413 size limits, a multipart-part cap, and path-traversal-safe save(); SSE responses default to same-origin instead of a hardcoded wildcard CORS header.

Forms: bind and validate in one pass

form_or_errors() now runs chirp.validation rules attached to dataclass fields via Annotated — e.g. title: Annotated[str, required, max_length(100)] — in the same pass as binding. No separate validate() call, no Form/ModelForm class. Binding and rule errors merge per field, context["form"] re-populates submitted values, and Optional/union nesting is unwrapped to find the rules.

FileResponse, Early Hints, and opt-in htmx

  • FileResponse serves files through a dedicated bytes-capable sender with conditional-GET (ETag/Last-Modified/304), Range/206, and If-Range support — and conditional-GET is restored for HTMLInject/AlpineInject-rewritten static HTML.
  • HTTP 103 Early Hints via theLink/preload header convention: set a preload-class Link header and Chirp emits a preliminary 103frame so the browser preloads while a slow-first-byte page renders.
  • AppConfig(htmx=True) opt-in htmx injection, symmetric with alpine — Chirp injects the htmx core script (with the live CSP nonce) and dedups on data-chirp="htmx".

Streaming runs off the event loop

BothStream and Suspense renders previously drove kida's synchronous renderer inline on the event loop, so one heavy render stalled every other in-flight request. Both now render on a worker thread (preserving shell-first progressive flush and per-block OOB), closing that head-of-line DoS class. Suspensealso gained true per-block failure isolation — one failing deferred source no longer sweeps its siblings into an error state.

Expanded startup contracts

Newapp.check() categories: shapecheck, security_stack, chirpui_csp, csp_nonce, nojs_floor, i18n_missing_key, suspense_defer, macro_css, htmx_provisioned, static_streaming, trusted_proxies, plus the deploy_debug/deploy_metrics/deploy_sentry rules surfaced through the new chirp check --deployproduction-posture preflight, and five documented static accessibility checks.


Migration Notes

  • Default CSP no longer includes'unsafe-inline' in script-src. Framework inline scripts now require a per-request nonce. Enable one with CSPNonceMiddleware or AppConfig(csp_nonce_enabled=True)use_chirp_ui() and the scaffolds wire this for you. app.check() fails loud (ERROR in production) when an inline script would be un-nonced. Apps that intentionally keep un-nonced inline scripts must re-add 'unsafe-inline' to their own script-src.
  • SSE responses default to same-origin.EventStream no longer emits Access-Control-Allow-Origin: *. Cross-origin SSE must opt in with EventStream(gen(), allow_origin="https://app.example.com").
  • Request-body limits split.AppConfig.max_upload_size is now the multipart-total ceiling only; a new AppConfig.max_request_body_size (default 16 MB) caps the general request envelope. Both default to 16 MB, so default configs are unchanged. If you relied on max_upload_size to cap JSON/text bodies, set max_request_body_size to the same value. The long-dead, never-enforced AppConfig.max_content_length is removed in favor of max_request_body_size.
  • SQLitepool_size is now load-bearing. File-backed SQLite opens a bounded WAL connection pool sized by DatabaseConfig.pool_size (previously ignored), with concurrent reads and write-only serialization. In-memory databases are unchanged. Confirm no app set pool_sizeexpecting it to be a no-op.

Dependencies

  • bengal-pounce>=0.8.0 (raised from 0.7.0)
  • kida-templates>=0.9.0
  • chirp-ui>=0.9.0 (optional uiextra)

Upgrading

uv pip install --upgrade "bengal-chirp>=0.8.0"
pip install --upgrade "bengal-chirp>=0.8.0"