The PUT route guarded description with 'is not None', so JSON null never
reached EnvService.update_env_var — whose _UNSET sentinel explicitly
supports clearing. Key-presence check now forwards the null; absent keys
still leave the field untouched. Raised in PR #118 review.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RestorePoint hand-rolled json.loads for payload/coverage — now reads
through JsonColumnMixin._json_read like every other model, keeping the
models-json ratchet green.
test_restoring_the_last_domain_re_enables_the_site mocked create_site
and counted enable_site calls, but since write_vhost became the one
enabling door (enable=True re-links sites-enabled and create_site goes
through it), write_app_vhost no longer calls enable_site separately —
test_site_domain_writer_does_not_enable_twice pins that. The restore
test now asserts through write_vhost's enable flag instead, still
failing if a restore path ever opts out of enabling.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The three restore-point read routes authenticated with bare
@jwt_required(), pushing the census to 579 against a 577 ceiling.
They now use the auth_required() policy door like the file's mutating
routes (which already accept API keys via @developer_required), keeping
read access consistent with write access for API-key callers; scope
authorization stays in-route. test_restore_point_env.py hand-rolled
Application/User seeds — switched to the make_application/make_user
factories. Migration inventory regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The curated serverkit-recipes index is consumed like the themes registry
(cached remote index, bundled fallback, SERVERKIT_RECIPES_REGISTRY_URL
override). GET /recipes/registry[/<slug>] browses it; POST /recipes/runs
now accepts a registry_slug plus server_id — targeting a server finds or
creates the per-workspace Recipes project — and validated non-secret
params that pre-supply handoff inputs so only true secrets pause the run.
Frontend: Recipes catalog page (cards, category chips, install drawer
with running-server picker, preflight table, up-front inputs and an
asked-during-the-run secrets preview) under new Services and Marketplace
tabs; runs land in the deployment console.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RecipeExecutionService encrypted handoff inputs with a direct
app.utils.crypto import, tripping the service-level crypto ratchet.
SecretService gains upsert_internal_secret — create-or-update without
the human display-name grammar, for machine-named secrets in a
dedicated vault slug — and the recipe service routes through it.
Expose disk_reclaim_service behind /api/v1/system/disk/reclaim: a
read-only report and a confirm-gated POST whose keys are re-validated
against a fresh scan server-side (safe candidates only — telemetry
stays CLI-only). ?wait=false runs it as a background job; both paths
are audit-logged.
The curated fix shows up where the problem is reported: a failing
disk.headroom doctor check and an alerting disk tile each offer
'Reclaim space', which previews measured candidates before handing
the run to Operations.
The attachment round added two hand-shaped `jsonify({'error': str(exc)}), 400`
returns in api/ai.py, which pushed the error-shape census to 1146 against a
ceiling of 1144 and turned shard 2 red. The census policy is not "raise the
ceiling" -- new endpoints raise, and these are new.
AttachmentValidationError now subclasses app.exceptions.ValidationError, so the
global handler owns the 400 body, the `code`, and the X-Request-ID correlation.
It is still a ValueError (ValidationError subclasses one), so every existing
`except ValueError` caller and the pytest.raises assertions in
test_ai_attachments.py are unchanged, and the two try/except blocks in the
routes disappear entirely.
Census back to 1144 = ceiling. Verified: test_ai_attachments, test_ai_assistant,
test_error_shape_ratchet all green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The raw subprocess census caught disk_reclaim_service as a 25th call
site against a ceiling of 24. The ceiling only goes down, and this is a
case where the shared door is genuinely the better call rather than a
box to tick.
_run's own `except OSError` turned a missing binary into an empty
result, which is exactly the sbin-PATH failure mode: a unit whose $PATH
omits sbin cannot exec a bare `journalctl`, and this sweep would have
reported "nothing to reclaim" on a host that had plenty to reclaim --
a false fact about the operator's disk rather than a visible error.
run_checked resolves argv[0] the way privileged_cmd does, and separates
"never ran" (returncode None) from "answered no". _run keeps its
(ok, stdout, stderr) shape, so callers and tests are unchanged; a
missing binary still degrades to a zero-sized candidate instead of
aborting the scan, but now the reason reaches stderr.
Census back to 24/24; all six ratchets pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`jobs` has had retention since day one, but the three tables that grow
alongside it never did. `queue_messages` and `system_events` gain a row
per scheduler tick and `api_usage_logs` one per request; measured on a
single-app box that is ~25k rows/day, or ~11 MB/day of database forever.
That is what filled a 25 GB host from nothing but routine updates: the
database reached 809 MB in two months, and every update copies it twice
as its safety net (a pre-upgrade snapshot plus a tree backup containing
it), so each update cost ~1.6 GB.
Adds builtin.telemetry_retention on a 6h tick, keeping
`telemetry.retention_days` (default 30); 0 disables it. It reuses the
prune from disk_reclaim_service, so the same guards apply -- terminal
rows only, and a queue message still referenced by a surviving job is
kept.
It deliberately does NOT vacuum. VACUUM needs an exclusive lock and free
space equal to the whole database, which is never acceptable on a
background tick. Steady-state pruning leaves the freed pages on SQLite's
freelist for reuse, so the file stops growing; `serverkit disk` remains
what actually shrinks it after a backlog.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A panel host filled to 100% (0 bytes free) purely from routine
`serverkit update` runs, and recovering it meant hand-running du and
sqlite. `serverkit disk` measures every reclaimable item and offers them
for selection: numbers, "safe", or "all".
Upgrade snapshots are grouped by timestamp and offered individually with
age and size, since one update writes both a tree backup and a database
copy under one stamp -- half a snapshot is not a restore point. They can
be picked by number, by age (--older-than N, or "older-than N" in the
prompt), or left to the "all-but-newest" default.
Two ordering rules are enforced regardless of selection order:
* filesystem reclaimers run BEFORE the database VACUUM. VACUUM rewrites
the database into a temp file beside it, so it needs free space equal
to the database -- on a full disk it is the one step that cannot go
first. It refuses with a clear message rather than failing halfway.
* `jobs` rows are deleted before `queue_messages`, and any message still
referenced by a surviving job is kept, so jobs.queue_message_id never
dangles.
in_flight messages and pending/failed jobs are never touched. /tmp is
only swept for entries matching mktemp naming, owned by the invoking
user, over 24h old AND holding an unpacked ServerKit tree. Docker prunes
dangling images and build cache only -- never tagged images or volumes.
Sizes are measured rather than estimated wherever measurement is
possible: docker reports its own reclaimed tally (shared layers make
dangling-image sizes overlap), and the journal and package caches report
before/after deltas -- apt-get clean empties the cache before a naive
count could see it, which would otherwise report 0 freed for real work.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- LocaleContext: reset ready on each language change so non-default
locale bundles trigger a rerender once active
- auth PUT /me: raise typed ValidationError (auth.unsupported_language)
instead of a hand-shaped error body; error-shape ceiling 1145 -> 1144
- i18n.py docstring: point at the real parity test
(test_user_language_preference.py)
Follow-up audit of the extension surface, across all of them rather than one.
THE COMPATIBILITY HOLE. Two places documented an install-time `sdk_version`
check as existing -- app/utils/sdk.py's docstring ("checked at install (manifest
lint) and at load") and the runtime loader's comment, which named
`_assert_manifest_sdk_compatible` -- but `sdk_version_satisfies` had ZERO
production callers; only its own unit test. Consequences split by extension
kind: a runtime ESM extension installed cleanly and failed later at the load
gate, while a builtin/in-repo extension never passes that gate at all, so its
range went unchecked everywhere. The function now exists and runs at install,
beside the min/max_panel_version check, and refuses with what the operator
needs to act: what the extension wants, what this panel offers. Fail-open on a
missing range, matching the load gate's 'grace'. Proved both directions.
This matters now because SDK 1.3.0 added the localization surface and made
i18next/react-i18next shared singletons: an extension that externalizes them
needs a panel at 1.3.0+. Pinning `^1.3.0` is what turns a white screen on an
older panel into a readable message. The bump is additive, so anything pinned
to ^1.2.0 keeps installing and loading. Documented as a version table.
THE AUDIT. Measured, not assumed -- both censuses now take `--root` so they can
run against any extension repo (and refuse `--update` there, since the ceilings
belong to the panel):
2,105 untranslated literals across 13 extension repos (WordPress alone 1,099
in 44 files), plus 78 direct toLocale*/Intl calls that follow the BROWSER's
locale (WordPress 56). None of it was visible to any check before, because
every census stopped at frontend/src.
serverkit-gui -- the one builtin with real UI -- is converted as the worked
example, importing useTranslation/useFormat from 'serverkit-sdk' exactly as a
third-party extension would. That also closes its own toLocaleTimeString.
Ceiling 8055 -> 8034, intl door 124 -> 123.
The probe now asserts the claim the SDK surface rests on: an extension
resolving i18next through the shared vendor gets the HOST's INITIALISED
instance, and an extension-style t() returns Spanish. A second instance would
be uninitialised and every t() in that extension would render English forever
with nothing reporting it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SDK 1.2.0 -> 1.3.0 (additive).
Auditing the extension surface for plan 79 found the localization gap I
expected and two pre-existing bugs I did not.
The gap: extensions had no way to translate anything. The SDK now re-exports
the HOST's translation surface -- useTranslation, Trans, t, useLocale,
useLabel, useFormat and the intl door -- and i18next/react-i18next join the
shared vendor singletons. That sharing is the load-bearing part: an extension
bundling its own i18next would get a separate, uninitialised instance, so
every t() in it would render its English default forever and nothing would
report it. Exactly the "two React copies" hazard vendorShare already documents,
and exactly the failure mode this plan hit twice in core. They also join
dedupe/optimizeDeps for the same reason React is there.
The bugs, both caused by the same duplication:
- The import map listed `react` and `react-router-dom`, but no shim existed
for either. A runtime extension importing bare `react` 404'd at load.
- The serverkit-sdk shim re-exported 26 of the SDK's 71 exports. An extension
importing Button, Modal, EmptyState, formatBytes or timeAgo from the SDK got
`undefined`. The shim's own comment claimed parity was "asserted by the
frontend build + backend test_sdk_contract"; nothing asserted it.
Three files independently restated the same module list -- the import map, the
namespace capture, and one hand-written shim per module -- so they drifted.
Now vendorManifest.js is the list, generate-vendor-shims.mjs derives every
shim's exports (from the real package's namespace, or from the SDK entry's own
export statements -- never typed by hand), the import map is built from the
same manifest, and `--check` fails the build on drift. 26 -> 85 exports on the
SDK shim, 6 -> 8 shims, all present.
Manifest contributions gain an optional `labelKey` beside `label` for nav, tabs
and command-palette entries, resolved at render by the same translateLabel core
uses -- data cannot call t(), and a label resolved at import would translate
once and never follow a switch. Omitting it renders `label` verbatim, which is
what every existing extension does, so nothing breaks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 §E wave 2 + F1.
Login, Register and Setup go to zero untranslated literals. These screens run
before anyone is signed in, so they are what proves the precedence chain: with
no user row to read, the locale comes from localStorage, then the panel
default from /auth/setup-status, then the browser.
F1 turned out smaller than the plan assumed, and in a good way. The plan
proposed inventing an error-code contract; the repo already has one -- plan 76
milestone B's typed-error door emits { error, status, code, request_id }. So
the work was to USE it: fourteen auth returns become typed raises carrying
stable codes, and the API client maps the code to a translation key with the
server's own English as the default. The 257 `toast.error(err.message)` sites
keep working untouched and gain translations as codes land. Error-shape
ratchet 1159 -> 1145.
The first cut mapped `t('errors.' + code)`, which is wrong in a way worth
recording: a computed key is invisible to the extractor, so those strings
would never reach en.json and a translator would have no way to learn they
exist. The registry is an explicit literal table instead -- it doubles as the
reviewable list of which server errors are actually translated, and anything
absent falls through to English, which is today's behaviour.
Only auth carries codes so far. Plan 76 §C measured 23 sites that choose an
HTTP status by grepping their own error prose; translating that prose would
turn those 403s into 400s, so the rest waits on milestone C rather than
shipping a silent status regression.
test_login_links asserted the bare {'error': ...} body by equality. Updated to
assert the typed contract including `code` -- asserted rather than tolerated,
since dropping the code would silently make the message untranslatable again.
Ceiling 8087 -> 8055.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 79 A1/A2/A4 + B1/B2.
Delivery deliberately avoids the usual http-backend fetch. `en` is bundled;
other locales are dynamic imports Vite emits as chunks from the app's own
static origin. The sign-in and setup screens render before any API call
succeeds, so a fetched locale either flashes English or -- when the backend
is the thing that is broken -- never arrives, on the one screen whose job is
to say so. It also keeps air-gapped and subpath installs working with no
second path to 404.
Precedence (user row -> localStorage -> panel default -> navigator -> en)
mirrors ThemeContext's, including the split provider: LocaleProvider sits
outermost so a toast raised during theme init is already translatable, and
LocaleSync bridges auth state down, exactly as ThemeSync does.
users.language is nullable on purpose -- "follow the panel default" is a
different state from an explicit choice of English, so existing rows are not
backfilled. The backend restates the supported list to validate writes; a
parity test fails the build if it drifts from the frontend manifest, which
stays canonical.
Two bugs the tests caught, both real rather than test artifacts:
- normalize_language('ES') returned None. The shape gate was case-sensitive,
so two of the three forms browsers actually send were rejected.
- PUT /me is throttled 3/minute for credential edits, and language rides the
same route. Switching language three times returned 429 -- and because the
client applies the change locally first, the preference silently failed to
persist and reverted on the next sign-in elsewhere, defeating the column.
Cosmetic-only payloads are now exempt from that limit; credential edits
stay throttled (proved both ways) and the app-wide 100/minute still holds.
eslint moves to ecmaVersion 'latest' because import attributes are ES2025 and
Node's test runner requires them for the manifest imports.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The inbound gateway receiver is public by design — the HMAC signature IS the
credential — but the reject branch read `if not signature_valid and
signature_header`, so it only fired for a request that bothered to sign. A
delivery with no `X-Hub-Signature-256` at all skipped the branch entirely and
was logged, filtered and forwarded to the endpoint's `forward_url`. Every
endpoint row carries a non-null secret, so there is no legitimate unsigned
caller to preserve; the check is now unconditional and reports which of the
two cases it refused.
`preview_diff` joins a request-body `domain` straight onto SITES_AVAILABLE
and returns the current file's contents inside the diff, which makes a
traversing value an arbitrary file read. The guard lives in the service, not
only in the route, so every future caller inherits it, and the route now
surfaces the refusal as a 400 instead of handing the error dict back as a
200.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
GHSA-r4q7-x795-vr4j was one forgotten check, but the audit behind it found
the same shape across the surface: `@jwt_required()` authenticates, it does
not authorize, and ServerKit has no default-deny to catch the difference.
This closes the routes that audit left open — fleet-monitor alert
acknowledge/resolve, recycle-bin restore, nginx test/diff, registrar
sync/test, server-template drift checks, uptime tracking on/off, server ping
and remote compose ps/logs, refresh-capabilities, template
test-db-connection, deploy branches, buildpack detect — plus three inline
gaps: a viewer could widen their own API key's scopes, the demo-deploy flag
was doing double duty as an authorization one, and tagging a shared resource
had no write check at all while attaching one did.
`_ensure_group_mutable` only ever blocked *system* queue groups, so every
queue_bus mutation now also calls `_ensure_group_accessible` (owner-or-admin)
— renaming, draining or deleting another user's group was open to any
authenticated caller.
The rbac policy decorators authenticate through `auth_required()`, so they
replace `@jwt_required()` rather than stack under it; stacking rejects
API-key callers before the policy can authorize them
(tests/test_auth_decorator_boundaries.py enforces this). Where a route then
needed the caller it reads `get_current_user()` — `get_jwt_identity()` raises
for an API-key request. The decorators also carry a `_sk_authz` marker so a
static test can see the decision from the source.
`migrations.get_migration_history` moves to `require_admin_user()` for the
same reason its siblings did: the inline `User.ROLE_ADMIN` check it kept was
already reading two names this module no longer imports, so the route raised
NameError for every caller, admin included.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
POST /api/v1/workspaces/ was gated only by @jwt_required(), so any
authenticated user - including the read-only viewer role - could create
a workspace, automatically become its owner, then add arbitrary members
and mint workspace-scoped API keys (GHSA-r4q7-x795-vr4j, CWE-862).
Add @admin_required, matching the owner/admin checks every other
mutating workspace route already enforces, plus a regression test
asserting viewer and developer JWTs get 403 while an admin still
creates.
Co-authored-by: Alex <134992700+campguyuz@users.noreply.github.com>
The last hand-rolled truthiness read in the app factory: it accepted only
the literal '1', invisible to the env-door ratchet's pattern. env_bool()
keeps '1' working (the updater's contract) and adds the door's documented
true/yes/on spellings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Twenty-one imports whose last caller left during the plan-76/77 sweeps:
mostly 'import subprocess' in services now routed through the shared
runners, plus time/requests in the CachedRemoteIndex adopters, threading
in agent_gateway, os in api/system, User in api/bandwidth, run_privileged
in nginx_advanced_service, and the dead SENSITIVE_SETTING_PARTS alias in
audit_service (zero importers). Each removal AST-verified unused before
deletion; all modules compile and the app factory boots.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The raw-subprocess census and the thread-ownership guard walked the
filesystem under backend/app, which on a dev box also holds *installed*
extension copies under app/plugins/ that git does not track. The census
therefore measured 28 on dev machines but 24 on CI's clean checkout — four
sites of invisible ceiling slack, a stale thread classification for the
locally-installed analytics extension, and a red migration-inventory
freshness test on every CI run since the range landed.
Both instruments now intersect their walk with git ls-files (falling back
to walking everything when git cannot answer), the analytics thread entry
is removed (extension repos own their own threads), the ceiling ratchets
down 28 -> 24, and the inventory is regenerated. Proving test:
test_untracked_files_are_not_counted plants an untracked file with a raw
call inside app/ and asserts the census does not see it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The full-suite run tripped four ratchets on plan-77's own additions —
exactly their job: /api/v1/runs moves onto auth_required (the JWT-only
ceiling holds at 605) and behind run_log_service.list_run_logs (no
model access in the controller); the core-blueprint snapshot records
runs_bp deliberately (108 -> 109 + sha); sso_service joins the crypto
allowlist (C2 dual-read lives there by design); the controller-boundary
baseline re-records the two pre-existing ManagedDatabase crossings under
their B5 query_active spelling (count unchanged).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
First of the regret-ranked adoptions: a plain delete now tombstones the
row (migration 088; no unique constraints, so no partial-index rewrite).
The database content and its managed BackupPolicy survive the bin window
— restore brings everything back intact; PURGE removes the policy but
NEVER drops database content; the explicit ?drop=true path stays
immediately destructive. Active read paths (list/get/adopt/manifest/env
references/app detail) switch to query_active(); the backup executor
deliberately stays tombstone-blind so bin-parked data keeps backing up.
Server (98 query sites, fleet enrollment + HMAC secrets) is the next
ranked adoption — deliberately its own pass.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
env_variable's class-level SECRET_KEY-derived Fernet (reached
cross-model by shared_resource) and sso_service's private stack now
WRITE through the one key path (SERVERKIT_ENCRYPTION_KEY) and dual-READ:
one path first, legacy key second — permanent window, so rows never
rewritten keep decrypting and no bulk decrypt-all/re-encrypt-all pass
ever runs (PR #94 postmortem). The '[DECRYPTION_ERROR]' sentinel and
None-on-failure token semantics are preserved.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Opt-in to_dict over __table__.columns: auto-isoformat datetimes,
*_encrypted always dropped, __serialize_exclude__ for redacted columns,
serialize_extra() for computed fields (named to dodge ServerMetrics'
literal 'extra' column). 20 purely-mechanical to_dicts across 15 files
convert; genuinely custom shapes keep their hand-written methods (plan
75 §F's ruling stands). Field selection stays api/_query.py's job.
Gate: key-for-key parity against the pre-conversion key sets frozen
from git HEAD (only the Timestamp pair may be additive).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fetch -> TTL -> last-good -> bundled pipeline that four services
re-implemented (and that produced the bundled-index and proxy-rewrite
incidents) now lives once in utils/remote_index. The extension registry
and theme registry become thin normalizers over it; the registry gains
the failure-retry throttling the theme copy had already hardened to
(last-good under full TTL, bundled held only error_ttl). Deliberate
non-adopters, documented: template_service (keyed per-repo cache with
targeted invalidation) and security_feed (DB-durable last-good for a
daily job). Engine unit tests pin the semantics; registry/theme/signing
suites stay green (test seeds updated to the shared cache seam).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One TTL-memoization shape (Redis-backed where configured, in-memory
otherwise, None results never cached, .invalidate() per key).
install_profile_service capability probe and resource_tier_service tier
probe drop their hand-rolled module-level timestamp dicts. Ratchet with
a documented allowlist for caches whose semantics genuinely differ
(lock-guarded non-JSON snapshot, cache-forever catalog, the F1
remote-index family).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
RunLogStream accepts any RunLike (__run_kind__ + id + to_dict, optional
result/step members): one room grammar run_<kind>_<id>, one batched
persisted run_log/run_status event pair, and a GET
/api/v1/runs/<kind>/<id>/logs?after_id polling twin over the new
run_log_entries table (migration 087). Deploys keep DeploymentJobLog and
dual-emit the legacy deploy_log/deploy_status during the migration
window. The unified Job runtime — which had NO live channel — adopts it
in the consumer (start/succeeded/failed frames; handlers can log via
stream_for(job)). The dead pipeline_* scaffolding was retired in E2.
Gate: emit -> persisted row -> after_id resync contract test; consumer
adoption test. MIGRATION_INVENTORY.md regenerated (census moved).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
register_channel() generates each subscribe/unsubscribe pair with ONE
auth model (connected-identity base check + optional per-channel gate)
and rooms built on sockets_rooms; metrics, container_status, terminal
and deploy convert. The pipeline_* channel (zero callers anywhere) is
deleted. Gate: per-channel auth tests driving the generated handlers;
ratchet freezes the two remaining raw handlers (file/container logs,
which own per-sid OS resources).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One shape for process polling loops: Event-based stop (immediate, not
sleep-bounded), lock-guarded idempotent start/stop, optional app-context
per tick, real logger error handling (the sockets loops reported errors
via print() — unreadable in prod), and a run_while predicate for
subscriber-scoped loops. The metrics and container-status broadcasters
convert; thread_ownership now carries ONE entry for the helper's spawn
site instead of a row per hand-rolled thread.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
app/sockets_rooms.py owns every Socket.IO room-name builder (the grammar
lived in ~25 scattered f-strings); app/constants.py SOCKET_EVENTS is
mirrored verbatim by frontend/src/constants/events.js and a contract
test parses the JS so a renamed event fails in CI instead of silently
killing a stream. sockets.py / agent_gateway / notification bus and the
two frontend components that hand-derived rooms now use the builders;
ratchet forbids new room f-strings in the realtime layer.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The 11 hand-written status+completed_at(+error_message) transition
blocks in deployment_job_service collapse into mark_running/
mark_succeeded/mark_failed. The model keeps its stored 'succeeded'
spelling via the mixin override until this domain's data migration;
error text routes through __error_attr__ = 'error_message'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
~60 call sites across 24 model files now ride _json_read with each
site's exact default preserved; 34 classes newly adopt the mixin
(registry contract test expanded to 36). Reverses the old 'bare sites
stay raising' ruling: a corrupt row degrades to its default instead of
500ing every serializing endpoint (docstring updated). Ratchet:
json.loads( appears only in json_column_mixin.py.
Known pre-existing: test_host_inventory enumerate_filesystems failures
on this machine (verified via clean-tree stash rerun).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>