Skip to content
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## Unreleased maintenance

- Reject non-finite JSON values before CRUD/RPC writes even without a schema, and reject numeric column conversions that produce NaN/Infinity. Cache-disabled mutations no longer create unawaited placeholder coroutines.
- Encode idempotent operation replay results with the same JSON encoder as the first HTTP response, preserving binary text and numeric types; invalid JSON output rolls back before business changes commit. Stop collecting schema errors after the twenty-diagnostic response budget instead of materializing every invalid array element.
- Keep media and Editor attachment writes, flushes, output closure and publication off the event loop; drain owned work under repeated cancellation. Failed uploads remove partial objects, while completed metadata transactions retain their matching files if the caller disconnects.
- Negotiate identity encoding for bounded HTTP sources/JWKS and reject encoded bodies before decoding; ignore non-finite Retry-After values and close recovered request circuits.
- Reject unsafe checksum manifest records and linked/reparse-point source parents, pruning ignored build directories before exported-source traversal.
- Keep Editor document lock acquisition, validation, atomic publication and release in one IO worker. Cancelled saves stop waiting for busy locks and drain owned work before releasing the write mutex; slow disk work no longer blocks the event loop.
- Reload malformed cache envelopes instead of returning internal metadata or failing requests on invalid, non-finite or overflowing expiry values.
- Close Redis pub/sub resources when subscription setup fails or is cancelled, reject listeners that already stopped, and permit a clean subsequent connection attempt.
- These changes are separate from the frozen v0.5.2 publication candidates.

## v0.5.2 (in development)

- Normalize clipboard whitespace in release SHA input and add one-run publication of all four prepared component drafts, with full preflight, pinned source/asset manifests, controller CI gates and safe recovery after partial publication.
Expand Down
36 changes: 23 additions & 13 deletions MANIFEST.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ d8ab139f74ef9ebe932cf11837c19b017b424a673a0b4c5f1349a8e41012fa7f .vscode/settin
759da1adabb4d60f41e3b678452af42192ee3a2c9cc40b0d30522153df374708 AUTHORS.md
699957fecf01059637d6d6531e82729aaa5a7e2425bef812f48552dafe7d97f7 BRANCHES.md
db93badbb155f5e5d43633e044af661bb8d9b19e05a6a30607a4edb7964b22a0 BRAND.md
74d9b1ae7d651f6daddbb82ba7578455f6f25dca5476eb25ee7608f43bd734f5 CHANGELOG.md
8527c321a1f8a714c8b7ae36acde707f8a2eda8839fa0ef50863e3b14ea7ee7f CHANGELOG.md
75f224cc8d630a46c9673d36704453286a41504cc84742727fb0e8d129ed5f8a CITATION.cff
ea87ab99ee7120f85aba29aa86365691dbeb12a1c15ace2b07bd4cbf90942af2 CODE_OF_CONDUCT.md
d492cd4bc29540c5e21f11c313641d159974affd2dceef8d33f23ca82a2f809e CONTRIBUTING.md
Expand Down Expand Up @@ -125,43 +125,44 @@ d9e2700d66d92e870781ce74d0814aa5a1370a68f2aef230a6de577ef28d7a80 docs/security/
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 framework/__init__.py
37ab70ad90a853fc5d0a86d835ca2fa06f84080daabea7e598ca39bbd825812b framework/api_models.py
42cd8d1f79b0b8b1ef20e2c51ecbe050d10e062bb310b07d5ac21de3097796aa framework/audit.py
49f5a485c4fb070f5bc65e75c71ae7daa15c2652b55b112bc4ee6fec747fd24a framework/cache.py
95b9b126695b09d80eec99c21b32575e0d53b134b241e45b8a71930fc741b2d6 framework/cache.py
e49b20a66f3b8a07308b58417eef7dbec8384bdaa6456ba46f8da0eee356fa64 framework/cli.py
5748add336febceec488dd06dd9fab28f8497bbd37f58c6398df974ab1119ef2 framework/config.py
31ecdde6bbb38403812cccf4914b9f552f6bb2761d84e3a5e21c09ba8d2e20b0 framework/crud.py
304224a7a39e7dc9597bffca4532ebd26cdf1fc302e483620613630a998f036c framework/crud.py
835ed21a963fb0f5b756949eeecec083848ac4f9b10b37e718bcb4c5ddba28b7 framework/datasources.py
b5f28590f7d353bf8dbe2e5d1a7c6f807d8e3ded6bdc3350ed1c085400f07d79 framework/db.py
3e558e5ce7705bac95400393046a2387fdd07e3567935e1aec8bcaa79166e820 framework/doctor.py
a540621a90e0ed13fa0ea32431d04c2e1df2f6a1cf91fed8d1183ed4addbd754 framework/domain/__init__.py
1124ac68ae374e94a7b87841fcfc8547b792cb85058a9f359370558c705337d5 framework/domain/packs.py
eb96703d2fe201b509d51ca56744150c8df1537c8453e52dfe3f138bb56eb74c framework/editor_api.py
93338736a1c0065daf090be673ab113eb04fe70d9a63f69cc2eb34ab8f1d3ef4 framework/editor_api.py
4b026114e194a4127f2bf8d3218234085f37bc4e713177703f0ab5ce46ac2feb framework/editor_call_client.py
bec9cdc02bcc222a6bd7f0dc21c93553ef3b1fd640d2f3d7e777362dd7b1524d framework/editor_call_script.py
00cf05672dedd3f037ee6ced943b3640df9ded6976d6d97e3a974a28329ac651 framework/editor_database.py
9bea132ee2d88ab818c5a6752c2dfb4362006c73c2c58f7c770e5a0722e54ad4 framework/editor_identity.py
269d0ceb9b1535452453faa2754c3cb8570243cf6ab30711c50141418ce6a770 framework/events.py
2b817d7df232cf72cb072ed641205cd9cbaf3eed34cac7ebfd40fd30b20bb5f2 framework/events.py
5fe190becf047e082e54a08c66bd2980a2549aed2897fe1c4cacfbee724a4d37 framework/factory.py
626f782d6f296e1e631b911145ca8291ee8a09549c2e575d3386bc29291e7d91 framework/file_safety.py
de12b36ae9bea9d38563bf47daf10f215321678afd963963451aab379053c8bf framework/hooks.py
04a2d8133281331f4951ee2cc15c9aefdd8331616f48519ddfb0a23fe6c3b1bc framework/media.py
dfefc7926f3efc62150101d07cc6eb3694c9cfc6419dc8cc756de34237a72dc3 framework/io_tasks.py
a43df3967576db8af37f56f930f818910c5d0458de26647cf63c2c317eb02662 framework/media.py
1cc63e515ff54980079194704b368d15f6b0c80ce6a85c186f45d6e9eea10630 framework/mongo.py
1570831cbee5aa560f97898992e14527bb5d9707d4cf0fc1591d93b9ff82bdae framework/observability.py
c8137f29f61005cf2bd2081fa9a86a7e1b3c420b9a43262ac9e731feaa3d09b6 framework/operations.py
2e1a7740561a043622f4532802fa6f569713fea9ef078f9cab99e000fbfe242a framework/operations.py
5ae44f680bdd7b9968ce1df3f9b537aa316998bbba6f5fbd0257818aa6df0c98 framework/protection.py
19a4b9a999a871274ef95a375d6e3b705ebd38267a8dffe2e16522f5d688ee11 framework/rate_limit.py
4175e816deef70b29791c2e5ed2734ae6de9532ece470c448fd4be9d99c49458 framework/readiness.py
be55cabe848bf6c2bd6b45ba721766c5c3e85a013a0712d7da14a209ac975ed9 framework/reload.py
21be6652a548e7963b8a09ac0d5e7a5e0fe6eb4da13cc6d77b6553aac9d1c21e framework/responses.py
55df528b3b30dc303ce8e1d281e1971b6564ef450da70d5de66e92415d443733 framework/routers/__init__.py
1db8db23589e4b726d9f19366ec084e8725b370f6310abdf2dd26860a42d88ce framework/routers/project.py
261a18e1443cf6f3597494a4b4dd0070ba988c82fa2ba4709781f7be3946a4d6 framework/routers/project.py
5e2b98986d3920d7159995c69856de7a5fd27f58b567dd02704ea523738b12fd framework/runtime.py
0608b23a2ecf8ddc5364b1bde33f507187e4d93732327738af32d520999072bc framework/safe_regex.py
7a9f04faf18ed8ec3fe0c4a903b669e85d6e53d80b928804409131bdaa4aa743 framework/schema.py
84629be3c3d1aef5f3010f534b0566ff663d04dc3d88262e147a5aa2ae30e95d framework/security.py
b29b44fb165c4af28ae91f6aac480cf2e09c0a0568a1972bb3ab3ecc2a4c31b3 framework/services/__init__.py
768657f21a7357358618788ff64c855f3ad56a83646262b48aa82b7f569cdcee framework/services/http_client.py
29334372b577f0e7239c748a0e3377ff0e4ca293b6783c403476cabe41a614ea framework/services/http_client.py
0ef95ba1273e79f8045a047021a925b60dcb7d885c3d50ab43c6a7af511ac3f5 framework/settings.py
c97250eddde42c7dae3534aca5bb2ccdbc4402b64f1b34ac127c194457c50042 framework/validation.py
e0c57d514017045d890da00f742947a46ca683caf12eb2c9338bae99aafc9930 framework/validation.py
697e2fba33660b8c58b2cde37f3a28dd243cdd12481b169e11a12287b9c836bc framework/wsgi.py
238447dd865450eabe163e23c3f8367468ecf8497c7e75a898999ad09d9796fc main.py
dc5d3e115fb85134cfd39edec3fe7359ec8ec9bf7380197c521c5254425964d7 passenger_wsgi.py
Expand All @@ -180,8 +181,8 @@ f2e6c2c9864842c26d4ba67e63204b1a2adc57bb88f7cc23a95e9d36091a6af6 run.py
90f44bdbc981a23b398611c5029b46962a122645c2d7dda3bda40852323f3ef4 schemas/manifest.schema.json
3b4610afd882c42db781765a285761452e172e96ddc003830725933f5b4d2b85 schemas/project.schema.json
2cb417944d83c33467932de605fa2c2a4adeeac58787026027bc09ce14273b80 scripts/benchmark_latency.py
ec63ea4fc896b86b3ea894b2c7dc172fd5024c9ca089b900fbd5fe54a31223e8 scripts/check_critical_coverage.py
14acf8bec2d2f24a34e80fb2fe6393638d02a7fe6fc54f638a576d87ee71e332 scripts/check_manifest.py
96ac71c5d6311831c42716cdea61957cabd37ba1a098f0af0ea5d38949da39f9 scripts/check_critical_coverage.py
bba005a9a513f11a4b07ff189676b5b00084b58efa87beeb926644969f7c7375 scripts/check_manifest.py
0b6cd71bcb38b64df0d0adc10aba8bb1506dcee315d1e7010e46a18d693433de scripts/generate_secret.py
95b9f1187cce1c37da17f7ea2cd5adc3b3a1916d76922bd824848156f124fe3d scripts/install-release.ps1
cde242339b86093a02a7e130311fbb764f3ab3404da49f9d3a08589765c6d6e0 scripts/install-release.sh
Expand Down Expand Up @@ -213,9 +214,17 @@ ca8953b1d8d515c6b919b50d14941e74e454196a4532f5209785a27da2f023ed tests/fixtures
449ae8470143556c79adf7fe832b99a640dc0240ac932bfb3b5fc8d8c82b33a0 tests/fixtures/apps/__init__.py
1970df4c99aba901049f3fbf29f7b3043960922a31c0279398ee8414a37a3506 tests/test_cache.py
c0c671e93ad23c24d1082780c1410f4b0219938239bcbf5e92c32e40db6a54f6 tests/test_config.py
6581736e9024206fde993e13ca3ce57c5787521c455318afb43488126d7073cf tests/test_editor_attachment_io.py
53103079f5d98b5627c98206164ea6188d01fa2f0b225105c0a619e3d043d985 tests/test_editor_call_browser.py
0fbbcd8b59ee47772b1ac8903fd7f9cdf080f15f67a8b53ec44c7e1a5418ebfe tests/test_editor_save_lifecycle.py
744494d80a5a9fd8b4d27f173301d6bdd22cc75dbc45e33514a2d8ae8021b78b tests/test_external_services.py
dc54c07db9e5ddea03cfdf5357825f45cfa6a922e79c160dfc97e1d005186a85 tests/test_http_response_bounds.py
dbca4b023f17f72104d284ce430fc36ef4ed9cb7efc8e846973accadc2f3b4d3 tests/test_idempotency_json_types.py
57d01ed3bbb98e0abd909458a90d607a2bcc9f0db7dcde635cff55617700c644 tests/test_integration_v04.py
6c93c65deb30fbdbfe75912af5beb8230dc6550791807f9b15169e5cb9bd4fb4 tests/test_manifest_boundaries.py
6a95cc65623ea7f6d1ae71c10bcb7a53b6996ae3f85c9586cd8894486ca19643 tests/test_media_io_lifecycle.py
3532b3bf30a0d9087e8883f460e0fd927c95c4a4e955adb9087ca408e416e2ea tests/test_night_resilience.py
38d857b87d48bb1c1edb542324023122b4f4e4f8ff80b717f86befcf9ec27eb7 tests/test_nonfinite_writes.py
1cb158b7430fdf903feb6b8af97189e4f22f44e20770ec019bcc0f6300db5134 tests/test_rate_limit.py
fbdad48e2a0bc27edead5763efbfd73826e069ce1aba8ed89b8b4725f21927f0 tests/test_security.py
67bffeba2c2d79ffd51a29710655b9483a043e48c8c36bd5bf1724a1e6ed84dc tests/test_v03_features.py
Expand All @@ -237,5 +246,6 @@ d17a269986f18a61d111f47f5db3004262834b75960024e1ce61d39a3f601d19 tests/test_v05
6ecf9f91d80018a89ef35f33dba23db9bbab0637b2e91a1020aa21c48cee9461 tests/test_v052_code_scanning.py
b8a449a59138f6171f97a33b6a17fbee5b037a5ece50a05583443363d2e6679a tests/test_v052_latency.py
908e0c3f8a809e620cc66dd796865fab9c04c6bd8659674905a724cef857ebe8 tests/test_v052_publish_suite.py
0abe5dddbb0d9bd85b4229a2cb046956b1a0dcfa474d3cb4ae26354e0a95ecf6 tests/test_v052_regressions.py
1b9c0d8c3939aed7c8c966dd581c13e4287273ebc64ce61ebfc3348a8831f4eb tests/test_v052_regressions.py
892c76c9a6320ad52fa3c286a428fa1d08c8b403b4b2ee1ff4eb89db79986d1b tests/test_v052_release.py
c8a4ebc7ccbe2fb92f3fa317ab6f65fd0358fdb39c1220bfc80bf0bfdbdc05d8 tests/test_validation_error_budget.py
14 changes: 11 additions & 3 deletions framework/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import asyncio
import json
import logging
import math
import time
from collections import OrderedDict
from hashlib import sha256
Expand Down Expand Up @@ -208,15 +209,22 @@ async def get_json_state(self, key: str) -> tuple[Any | None, str]:
return None, "miss"
try:
parsed = json.loads(raw)
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError):
envelope = isinstance(parsed, dict) and parsed.get("__forge_cache_v") == 1
if envelope:
if "value" not in parsed:
raise ValueError("Cache envelope has no value")
fresh_until = parsed.get("fresh_until")
if isinstance(fresh_until, bool) or not isinstance(fresh_until, (int, float)) or not math.isfinite(fresh_until):
raise ValueError("Cache envelope has an invalid expiry")
except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError, OverflowError):
log.warning("Discarding malformed cache entry key=%s", key)
try:
await self.backend.delete(key)
except Exception:
log.warning("Failed to delete malformed cache entry key=%s", key, exc_info=True)
return None, "miss"
if isinstance(parsed, dict) and parsed.get("__forge_cache_v") == 1 and "value" in parsed:
state = "fresh" if time.time() <= float(parsed.get("fresh_until", 0)) else "stale"
if envelope:
state = "fresh" if time.time() <= fresh_until else "stale"
return parsed["value"], state
return parsed, "fresh"

Expand Down
40 changes: 33 additions & 7 deletions framework/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@

import base64
import json
import math
from datetime import date, datetime, timezone
from decimal import Decimal, InvalidOperation
from itertools import groupby
from typing import Any, Literal

from fastapi import HTTPException, Request
from sqlalchemy import DateTime, and_, delete, func, insert, or_, select, update
from sqlalchemy import DateTime, Float, Numeric, and_, delete, func, insert, or_, select, update
from sqlalchemy.exc import IntegrityError
from sqlalchemy.sql.schema import Table

Expand Down Expand Up @@ -44,6 +46,7 @@ def _write_payload(
*,
schema=None,
mode: Literal["create", "patch", "replace"] = "patch",
dialect=None,
) -> dict[str, Any]:
if not isinstance(payload, dict):
raise HTTPException(status_code=422, detail="Resource payload must be a JSON object")
Expand Down Expand Up @@ -71,12 +74,16 @@ def _write_payload(
elif not has_default:
raise HTTPException(status_code=422, detail={"missing_required_fields": [name]})
for name, value in list(data.items()):
if isinstance(table.c[name].type, (Float, Numeric)):
# Preserve the existing driver conversion of numeric strings, but
# reject values that would become NULL/Infinity after committing.
_coerce_for_column(table.c[name], value, dialect=dialect)
if isinstance(value, str) and isinstance(table.c[name].type, DateTime):
data[name] = _coerce_for_column(table.c[name], value)
return data


def _coerce_for_column(column, value: Any):
def _coerce_for_column(column, value: Any, *, dialect=None):
if value is None:
return None
try:
Expand All @@ -103,8 +110,18 @@ def _coerce_for_column(column, value: Any):
elif parsed.tzinfo is not None and not getattr(column.type, "timezone", False):
parsed = parsed.astimezone(timezone.utc).replace(tzinfo=None)
return parsed
return pytype(value)
except (TypeError, ValueError) as exc:
converted = pytype(value)
if (isinstance(converted, float) and not math.isfinite(converted)) or (
isinstance(converted, Decimal) and not converted.is_finite()
):
raise ValueError("Numeric value must be finite")
if dialect is not None and isinstance(column.type, (Float, Numeric)):
processor = column.type.dialect_impl(dialect).bind_processor(dialect)
bound = processor(value) if processor is not None else converted
if (isinstance(bound, float) and not math.isfinite(bound)) or (isinstance(bound, Decimal) and not bound.is_finite()):
raise ValueError("Numeric value exceeds the database driver range")
return converted
except (TypeError, ValueError, OverflowError, InvalidOperation) as exc:
raise HTTPException(status_code=422, detail=f"Invalid value for field {column.name}") from exc


Expand Down Expand Up @@ -322,7 +339,7 @@ def _inject_policy_fields(data: dict[str, Any], resource: ResourceConfig, princi


async def create_row(engine, table: Table, resource: ResourceConfig, principal: Principal, payload: dict[str, Any]):
data = _write_payload(payload, resource, table, schema=resource.create_schema, mode="create")
data = _write_payload(payload, resource, table, schema=resource.create_schema, mode="create", dialect=getattr(engine, "dialect", None))
_inject_policy_fields(data, resource, principal)
try:
async with engine.begin() as conn:
Expand All @@ -342,7 +359,9 @@ async def batch_create_rows(engine, table: Table, resource: ResourceConfig, prin
raise HTTPException(status_code=422, detail=f"Batch size must be 1..{resource.max_batch_size}")
values = []
for payload in payloads:
data = _write_payload(payload, resource, table, schema=resource.create_schema, mode="create")
data = _write_payload(
payload, resource, table, schema=resource.create_schema, mode="create", dialect=getattr(engine, "dialect", None)
)
_inject_policy_fields(data, resource, principal)
values.append(data)
try:
Expand All @@ -366,7 +385,14 @@ async def update_row(
*,
replace: bool = False,
):
data = _write_payload(payload, resource, table, schema=resource.update_schema, mode="replace" if replace else "patch")
data = _write_payload(
payload,
resource,
table,
schema=resource.update_schema,
mode="replace" if replace else "patch",
dialect=getattr(engine, "dialect", None),
)
if not data:
raise HTTPException(status_code=422, detail="No writable fields supplied")
pk = table.c[resource.primary_key]
Expand Down
Loading
Loading