`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>
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>
4431 was measured on a dev box that has four git-ignored extension copies
installed under backend/app/plugins/ (cloud-provision, ftp, remote-access,
status). test_builtin_extension_drift.py parametrizes over the on-disk
intersection of builtin-extensions/<ext>/backend and app/plugins/<ext>, so
those four contribute 3+3+5+3 = 14 cases that no clean checkout can collect
— CI collects 4417, and the ratchet went red for a suite that had lost
nothing.
This is the failure mode check_test_count.py's own docstring warns about (a
floor --update'd above what a clean checkout can reach). 4417 is still a
raise of 108 over main's 4309, matching the 107 tests this branch adds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two nets, deliberately complementary, because neither alone would have caught
the advisory:
- `test_route_authz_sweep.py` fires every mutating route as a viewer and
fails on any 2xx outside a reviewed `VIEWER_WRITABLE` list. It proves
*enforcement*, but a route that 400s or 404s on a dummy request before its
gate runs is invisible to it — `POST /workspaces/` 400s on an empty body,
so the net would never have seen the bug it exists for. Hence the targeted
Part A: the fixed routes must answer a viewer and a developer with 403, and
must not block an admin.
- `test_route_authz_static.py` proves *coverage* from the source instead: every
mutating route in `url_map` either carries a `_sk_authz` decorator, reaches
an authorization primitive through its module's call graph, or sits in
SELF_SCOPED / PUBLIC_ALT_AUTH / STATELESS with a written justification.
Staleness checks keep an allowlist slot from outliving the route that
earned it, and a SELF_SCOPED entry must still resolve the caller's identity
— calling `get_current_user()` is necessary but not sufficient, which is
exactly how `create_workspace` looked self-scoped while creating a global
row.
Both nets are blind below the API layer and to public routes, so
`test_authz_round3_guards.py` covers the three fixes whose shape they cannot
express (API-key scope widening, demo-deploy job creation, nginx diff
containment), the webhook cases land beside the rest of the gateway surface,
and the shared-resource tag gate lands with its own blueprint's tests. Each
comes with its non-vacuity case: the same call succeeds for a caller the gate
admits.
`migrations/history` gets tests because it had none — which is why the
NameError in it shipped.
Ratchets: JWT-only census 605 -> 577 (the routes that moved to policy
decorators), the controller-boundary baseline loses the one persistence
crossing `complete_onboarding` no longer makes, and the collected-test floor
rises to 4431.
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>
test_docker_probe_exception_is_unknown_not_absent monkeypatched
ips.subprocess.run — reaching through the service's namespace to the global
subprocess module, which only worked while install_profile_service still
carried its orphaned 'import subprocess'. The probe execs through
run_checked() since the door migration; the test now fails that door
directly, exercising the same except-Exception -> None (unknown) path.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The guard ran a BARE pytest --collect-only from backend/, so --update on a
dev box swept git-ignored dev-data/ into the count and wrote a floor (4591)
that no clean checkout can collect — CI collects 4309, and the ratchet job
has been permanently red since 2026-08-18, which is a guard nobody can act
on. The floor's history shows the inflation entered at e6e605d1 (+314 with
only +46 test defs in the range it spanned).
Collection is now scoped to tests/ (matching what the CI shards run), a
non-zero collection exit refuses to report a partial count instead of
silently deflating it, and the floor is LOWERED 4591 -> 4309 — the value a
clean checkout actually collects; a dev checkout with sibling-repo
extension tests collects 4324, still above the floor. This is a deliberate,
documented decrease: no tests were removed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Three stub-kit dispatch tests used ['ufw', ...] as an arbitrary example
argv and one popen-guard test expected the real Popen to fail on it. Both
assumed ufw is absent on the running machine — true on every dev box, false
on CI's runners, where ufw exists sbin-only: the sbin guard fired inside
the stub path (SbinPathError instead of the scripted result) and the real
exec succeeded instead of raising FileNotFoundError. Red on every CI run
since the stub kit landed; invisible in local runs.
The dispatch tests now pin the guard's view of ufw to 'absent' (they test
dispatch mechanics, not the guard), and the guard test execs a name that
exists on no machine.
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>
All workstreams A-G executed 2026-08-19; open questions resolved
(D1 read-edge normalize, C2 permanent dual-read on the one key, B2
full-dict + _query shaping, E1 dual-emit); deliberate tail named with
owners (Server soft-delete pass, template/security_feed remote-index
non-adopters, the two raw log channels). BASELINE_COUNT 4460 -> 4591.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
G3: session-scoped route_rules serves url_map checks without booting a
second app (each boot re-exposes the plan-64 state-leak classes);
test_fixture_scope_guard freezes the 13 remaining direct create_app
callers as a shrinking baseline (config_checkpoint_alias migrated as
proof). G4: assert_requires_auth/assert_admin_only promoted into
tests/factories.py; test_raw_infra_authz delegates to the shared shape.
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>
~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>
config.py's parsers move to app/utils/env.py and are imported back; the
three incompatible truthiness variants collapse to one TRUTHY tuple.
Fixes the measured bug: SERVERKIT_ALLOW_PRIVATE_DOWNLOADS=on silently
read false in plugin_service (its tuple lacked 'on'); same for the pip
gate, SERVERKIT_STAGING, and SERVERKIT_DEMO_MODE. Ratchet: no new
os.environ truthiness tuples outside utils/env.py.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
utils/sensitive_data_filter.SENSITIVE_KEY_PARTS is now the union of the
four diverging lists (response filter, audit_service, middleware/audit —
previously the only sink covering totp/otp/csrf/session — telemetry);
all sinks delegate to the shared is_sensitive_key(), and mask_payload()
gives log sinks a scope-free recursive masker. Safe-key exceptions keep
identifier-shaped fields (private_url, certificates, totp_enabled, …)
readable. Ratchet: no SENSITIVE list literals outside the one door.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
models/status.py defines the six lifecycle constants, TERMINAL/ACTIVE
sets, and normalize() mapping every measured legacy spelling (succeeded/
completed/done/failure/error/canceled/scheduled) home at the read edge —
stored strings migrate per-domain later, never wholesale.
RunLifecycleMixin centralizes mark_running/succeeded/failed/cancelled
transitions (status + started_at/completed_at/duration), with per-model
spelling overrides so adoption never silently rewrites a domain's stored
vocabulary. Parity gate: every measured spelling normalizes canonical.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
agent_registry.authenticate_and_register owns the full handshake
(rate-limit -> HMAC -> allowed-IPs/anomaly -> new-IP -> version ->
register) and ingest_agent_state owns heartbeat/sysinfo/capability
side-effects; the WS namespace and long-poll endpoints become thin
adapters translating error codes to emit/disconnect vs jsonify/status.
The per-IP auth limiter moves to the registry (re-exported from the
gateway for existing callers/tests). Gate: parametrized ws/poll matrix
(bad HMAC, blocked IP, rate limit, new-IP anomaly) + a word-anchored
ratchet that the adapters never re-grow the sequence.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
TimestampMixin (byte-identical to the hand-written pair — zero schema
migrations) adopted by 52 classes across 44 model files; the utcnow
tz-aware migration is now a one-file edit. uuid_pk() replaces the 13
pasted String(36) uuid4 lambdas across 7 files (ai.py's hex variant
stays grandfathered, documented). Deviant shapes (indexed created_at,
sanitization_profile's default-less updated_at, created-only models)
deliberately keep their own columns.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
tests/factories.py owns make_user/headers_for/make_application/
make_server/make_workspace; conftest personas (scoping_rbac,
new viewer_headers/developer_headers fixtures) ride them. The
byte-identical _seed_app pair (container scale/sleep), two variants
(image_update, waf) and three server fixtures (fleet doctor/repair)
migrate as proof. Ratchet freezes the 44-file hand-minted-JWT and
65-file direct-Application() populations.
Note: test_fleet_doctor.py::test_dns_fail_when_unresolved fails
pre-existing on this machine (warn vs fail), unrelated to this change.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Six more models own their *_encrypted columns through the descriptor
(linked_panel, source_connection, container_registry, managed_database,
registrar_connection, pending_agent); eight service/api files stop
importing utils.crypto for them. legacy_plaintext mode mirrors
decrypt_secret_safe so pre-encryption rows keep reading until rewritten
— this also fixes registrar/source-connection reads that used the
RAISING decrypt variant on columns that may hold legacy plaintext.
Ratchet test freezes the remaining service-level crypto importers.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One crypto path for model-owned *_encrypted columns: assignment encrypts
and RAISES on failure (server.py's accessors print()-ed the error and
silently stored nothing — an agent could 'pair' yet never verify a
signature); read decrypts tolerantly (None + logged warning). is_set()
standardizes the has_secret masking flag. Server adopts it for
api_secret / api_secret_pending; legacy method names kept as delegates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The WireGuard tunnel-reconcile hook lived only in the WS on_capabilities
handler, so an agent connected via the long-poll fallback never
self-healed its tunnels after a restart. Move the hook into
agent_registry.update_capabilities — the ingest path both transports
already share — and prove it with poll-path + registry-path tests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Names the resolved A and C decisions, the 2026-08-19 commit set, the
intentionally retained semantic duplication with owners and removal
conditions, and the deferred H items. Collection floor 4430 -> 4460.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
docs/MIGRATION_INVENTORY.md is generated from the live censuses and the
frontend boundary checker's new --inventory JSON mode — no hand-typed
numbers, so the plan cannot claim completion while a door is bypassed. A
freshness test fails whenever a ratchet number moves without regenerating
the document, making the regeneration part of every migration commit.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Backend: hand-shaped {'error': ...} bodies in app/api get an AST census
and ceiling (1159) — milestone B's migrate-when-touched policy made
enforceable. Frontend: check-frontend-boundaries.mjs gains adoption
ceilings for raw api.* calls in pages (460, E1), per-page toast.error
extraction (264, E1), form-group blocks (343, F2), and unencoded query
interpolations in services/api (97, C4-interim) — totals that may only go
down, with a slack guard so a ceiling cannot drift above reality.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Milestone A's open question is decided: JWT-only stays the deliberate
default. Converting a route to auth_required() grants API-key access, so
it happens one route at a time when a use case exists, in a commit that
says so — never as a sweep. The new census/ceiling (605) enforces both
directions: no new bare @jwt_required() routes, and no silent
conversions. The identity ratchet's third assertion keeps each deliberate
conversion safe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
QueryParseError is now a ValidationError subclass — the global handler
answers it as a 400 and per-route translations become unnecessary (the
existing ones keep working). PageQuery parses, defaults, and bounds
page/per_page in one place; admin audit-logs, activity feed, error logs,
and telemetry events adopt it (telemetry keeps its 200 cap). git.py and
source_connections.py keep hand-parsed params deliberately — they proxy
pagination to Gitea/GitHub/GitLab/Bitbucket, not to DB queries.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
DbConfigTunerService raises ValidationError (preconditions/settings),
DependencyUnavailableError (engine/container operations, with the
restore-then-report semantics preserved in the message), and NotFoundError
(nothing to roll back to); db_tuner.py drops its three error-key
ternaries. The census docstring now records the completed migration and
corrects its own stale 'eight callers' blocker. Census 3 -> 0 — held as an
invariant from here.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
SecretVaultService, SecretService, and WebhookGatewayService raise typed
errors and return bare data; secrets_webhooks.py drops all twelve
{'success': bool} translations including its two prose sniffs. Bulk
create keeps per-item error collection (catching ApplicationError).
Replay now answers 200 with success:false when the forward target refuses
— previously a failed forward was a 404 with error:null — and the
WebhooksTab toast distinguishes the two. The public /webhooks/receive
wire protocol is intentionally unchanged (external senders depend on it).
Sniffing census 6 -> 4.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>