444 Commits
Author SHA1 Message Date
Juan Denis 2825c536a8 fix(i18n): address review — reset locale readiness, typed error for bad language, correct parity test reference
- 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)
2026-08-20 19:04:09 -04:00
Juan DenisandClaude Opus 5 9c0ae9e957 feat(extensions): enforce the SDK gate at install; localize the GUI builtin
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>
2026-08-20 00:31:42 -04:00
Juan DenisandClaude Opus 5 965ea5c27f feat(i18n): translate onboarding and auth; map server error codes to keys
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>
2026-08-19 23:49:32 -04:00
Juan DenisandClaude Opus 5 b851f48ef0 feat(i18n): locale runtime, precedence, and a per-user language column
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>
2026-08-19 23:16:09 -04:00
Juan DenisandClaude Opus 5 0d42baf678 fix(tests): set the test-count floor to the clean-checkout number
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>
2026-08-19 22:09:59 -04:00
Juan DenisandClaude Opus 5 7aa3286932 test(authz): default-deny backstops plus proving tests for the gates
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>
2026-08-19 21:44:45 -04:00
Juan DenisandAlex a92d40a042 fix(workspaces): require admin role to create workspaces
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>
2026-08-19 19:16:42 -04:00
Juan DenisandClaude Fable 5 de454527f5 fix(tests): docker-probe test patches the door, not the removed subprocess import
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>
2026-08-19 10:35:23 -04:00
Juan DenisandClaude Fable 5 1a503240ba fix(ci): scope the test-count guard to tests/ and correct the inflated floor
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>
2026-08-19 10:09:21 -04:00
Juan DenisandClaude Fable 5 0e8ab745fb fix(tests): stub-kit and popen-guard samples no longer depend on the host's ufw
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>
2026-08-19 10:08:00 -04:00
Juan DenisandClaude Fable 5 c803ee5012 fix(census): measure only tracked files — the instruments count what ships
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>
2026-08-19 10:06:38 -04:00
Juan DenisandClaude Fable 5 3dbd234eb6 fix(ratchets): reconcile plan-77 additions with the standing gates
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>
2026-08-19 06:04:59 -04:00
Juan DenisandClaude Fable 5 a3e907e97f docs(plan-77): completion record — decisions, scope calls, retained tail
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>
2026-08-19 05:50:35 -04:00
Juan DenisandClaude Fable 5 b07b988261 test(one-doors): route_rules fixture, create_app ratchet, authz one-liners (plan 77 G3/G4)
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>
2026-08-19 05:46:25 -04:00
Juan DenisandClaude Fable 5 f237d4f3b4 feat(databases): ManagedDatabase adopts SoftDelete into the Recycle Bin (plan 77 B5)
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>
2026-08-19 05:43:53 -04:00
Juan DenisandClaude Fable 5 9860c6f344 refactor(crypto): fold the parallel Fernet stacks into utils/crypto (plan 77 C2)
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>
2026-08-19 05:37:32 -04:00
Juan DenisandClaude Fable 5 2e3a735d5a refactor(models): SerializableMixin — mechanical to_dicts become introspection (plan 77 B2)
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>
2026-08-19 05:33:17 -04:00
Juan DenisandClaude Fable 5 57c0dc3956 feat(utils): CachedRemoteIndex — one remote-catalog engine (plan 77 F1)
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>
2026-08-19 05:27:10 -04:00
Juan DenisandClaude Fable 5 01b82955f9 feat(cache): ttl_cached decorator on CacheService; two TTL dicts migrate (plan 77 F2)
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>
2026-08-19 05:20:59 -04:00
Juan DenisandClaude Fable 5 b0589e95db feat(realtime): generalized run envelope — one stream door for every run kind (plan 77 E1)
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>
2026-08-19 05:13:02 -04:00
Juan DenisandClaude Fable 5 bae6e18578 test(deploy-console): drive the registry-generated subscribe handler (plan 77 E2)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-19 05:05:11 -04:00
Juan DenisandClaude Fable 5 c8a5d3a39c refactor(realtime): declarative channel registry; dead pipeline_* retired (plan 77 E2)
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>
2026-08-19 05:04:28 -04:00
Juan DenisandClaude Fable 5 ff501b51ad feat(realtime): BackgroundLoop helper; sockets broadcasters adopt it (plan 77 E5)
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>
2026-08-19 05:01:10 -04:00
Juan DenisandClaude Fable 5 00eac38d5d feat(realtime): one room grammar + mirrored event constants (plan 77 E3)
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>
2026-08-19 04:58:49 -04:00
Juan DenisandClaude Fable 5 e810016005 refactor(models): finish JsonColumnMixin adoption — no hand-rolled json.loads (plan 77 B4)
~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>
2026-08-19 02:15:09 -04:00
Juan DenisandClaude Fable 5 5d69154002 feat(utils): env one-door — env_str/env_bool/env_int (plan 77 F4)
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>
2026-08-19 02:12:45 -04:00
Juan DenisandClaude Fable 5 a66c4f8164 refactor(security): one sensitive-keyword union for all four redaction sinks (plan 77 F3)
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>
2026-08-19 02:10:22 -04:00
Juan DenisandClaude Fable 5 1b49352f27 feat(models): canonical status vocabulary + RunLifecycleMixin (plan 77 D1/D2)
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>
2026-08-19 02:06:05 -04:00
Juan DenisandClaude Fable 5 745bc2b5d9 refactor(agent): transport-neutral handshake + state ingest doors (plan 77 A2)
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>
2026-08-19 02:04:29 -04:00
Juan DenisandClaude Fable 5 4919ff1cfe refactor(models): TimestampMixin + uuid_pk() one-door shapes (plan 77 B1/B3)
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>
2026-08-19 01:59:10 -04:00
Juan DenisandClaude Fable 5 9b9aa34f01 test(seeds): one-door factories + persona headers + adoption ratchet (plan 77 G1/G2)
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>
2026-08-19 01:55:18 -04:00
Juan DenisandClaude Fable 5 d2525717a8 refactor(crypto): EncryptedSecret adoption sweep + service-level crypto ratchet (plan 77 C1)
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>
2026-08-19 01:49:42 -04:00
Juan DenisandClaude Fable 5 a3f1cc9766 feat(models): EncryptedSecret descriptor; server.py stops swallowing encrypt failures (plan 77 C1)
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>
2026-08-19 01:45:12 -04:00
Juan DenisandClaude Fable 5 99d1c86ebb fix(agent): tunnel reconcile fires on both transports (plan 77 A1)
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>
2026-08-19 01:42:13 -04:00
Juan DenisandClaude Fable 5 5aab23c19c docs(plan-76): completion record — decisions, commits, retained duplication
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>
2026-08-19 00:21:20 -04:00
Juan DenisandClaude Fable 5 3953090526 feat(conformance): generated remaining-migration inventory (H closure)
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>
2026-08-19 00:16:39 -04:00
Juan DenisandClaude Fable 5 39a6e35d42 test(contracts): ratchet the four remaining adoption populations
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>
2026-08-19 00:13:59 -04:00
Juan DenisandClaude Fable 5 eb83d332e7 test(auth): ratchet the JWT-only population and record the A decision
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>
2026-08-19 00:11:16 -04:00
Juan DenisandClaude Fable 5 5d7fa225a6 refactor(list-queries): one PageQuery door for the page/per_page dialect (C3)
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>
2026-08-19 00:10:09 -04:00
Juan DenisandClaude Fable 5 b7b8b38773 refactor(db-tuner): typed errors close the status-sniffing census at 0
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>
2026-08-19 00:03:50 -04:00
Juan DenisandClaude Fable 5 6337066fad refactor(secrets): vaults + webhook gateway onto the typed-error contract
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>
2026-08-19 00:00:36 -04:00
Juan DenisandClaude Fable 5 28dcd63f4d refactor(themes): registry install raises typed errors
theme_registry_service.install returns the theme dict and raises
NotFound/Validation/DependencyUnavailable; the route's not-found prose
sniff is gone. import_theme keeps its tuple contract for the import
route. Sniffing census 7 -> 6.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:56:15 -04:00
Juan DenisandClaude Fable 5 fbc36831b1 refactor(ddns): typed errors replace the token-prose sniff
update_ip raises AuthenticationError for a bad/disabled token (401 via the
global handler, as before) and ValidationError for a bad IP; the route's
except-ValueError message sniff is gone. Sniffing census 8 -> 7.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:55:17 -04:00
Juan DenisandClaude Fable 5 0b1fbb0d6d refactor(security): malware-scan enqueue raises typed errors
enqueue_scan/resolve_app_path raise NotFoundError/ValidationError; the
scan routes drop their except-ValueError prose sniffs and let the global
handler map. Sniffing census 9 -> 8.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:54:06 -04:00
Juan DenisandClaude Fable 5 05c61dd271 refactor(image-updates): typed errors replace the not-found prose sniff
check_application raises NotFoundError/ValidationError and returns the
check dict bare; the route stops re-deriving status codes from message
text. Sniffing census 10 -> 9.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:52:40 -04:00
Juan DenisandClaude Fable 5 7c1edf595e refactor(views): saved views onto the typed-error contract
saved_view_service raises ValidationError/NotFoundError/ConflictError and
returns bare dicts; views.py drops the (value, err) tuple translations and
its not-found prose sniff. Duplicate names are now 409. Sniffing census
11 -> 10.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:51:34 -04:00
Juan DenisandClaude Fable 5 dee7c33486 refactor(files): file manager onto the typed-error contract (C reference)
The C1/C2 reference conversion: FileService raises typed errors
(PermissionDenied/NotFound/Conflict/Validation) and returns bare data;
files.py stops translating {'success': bool} envelopes and stops choosing
status codes by sniffing error prose. Denials are now 403 regardless of
wording, missing paths 404, already-exists 409. Success bodies keep the
compatibility 'success': True key so consumers are unchanged. The plan's
'eight callers' blocker was a substring-grep artifact — FileService has
exactly one consumer. Sniffing census 23 -> 11.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:49:09 -04:00
Juan DenisandClaude Fable 5 e6e605d1f4 refactor(remote): close the dispatch_agent_command seam
Migrate all 20 direct agent_registry.send_command call sites (api/servers,
terminal, fleet doctor/repair/fleet incl. the upgrade Thread target,
deployment_runner, survey, tunnel broker/publish, plugins SDK) onto the
shared dispatcher, and widen the ratchet from remote_*_service.py to a
tree-wide AST scan covering builtin-extensions/. The survey test's
transport stub gets the transport's real keyword signature.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-18 23:31:46 -04:00
Juan DenisandClaude Opus 5 dadc0c4ccd fix(queue-bus): report the crashes a local error-mapper was hiding
Follow-up to a7d5a0b9, and a hole in its ratchet.

queue_bus.py answered 21 crashes through its own `_handle_error(e)`, which
returned 500 with str(e) for anything that was not a QueueBusError. Those are
exactly the unreported crashes milestone B set out to remove, and the census
missed every one of them: it looked for a literal 500 in the except handler, and
here the 500 lived one call away in the helper.

A ratchet that a one-line helper defeats is not a ratchet, so the census now
follows one level of indirection — a return of a same-module function that
itself answers 500 without reporting counts as answering 500. Verified
non-vacuous with a probe module using exactly that shape.

`_handle_error` now keeps a QueueBusError's own message and status (it is an
expected, caller-actionable failure) and routes anything else through
unexpected_response, fixing all 21 sites at once.

Worth recording how this was found: measuring the "handlers that swallow and
answer 200" population, which the plan had at 56. The real number is 1 — the
earlier figure counted any handler whose return the AST could not resolve to a
status, which included every `return unexpected_response(exc)` the migration had
just introduced and every `return _handle_error(e)`. The plan is corrected.

Gate: -k queue_bus (66 passed), crash ratchet + reporting tests (20 passed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:12:48 -04:00
Juan DenisandClaude Opus 5 107fdbdfde refactor(auth): remove the last inline role checks
Plan 76 milestone A, the third row. 23 handlers decided admin access inline,
all in two files, in two shapes.

18 were the mid-route gate this milestone already built a door for:

    current_user_id = get_jwt_identity()
    user = User.query.get(current_user_id)
    if user.role != 'admin':
        return jsonify({'error': 'Admin access required'}), 403

which is four lines to say require_admin_user(), and carried the two-line
identity lookup with it. Migrating them closes both rows at once: the identity
census drops 87 -> 69 and the controller-boundary baseline loses 18 persistence
crossings (528 -> 510), with no new entries.

It also fixes a latent 500: where the check omitted the `not user or` guard, a
token naming a deleted user hit `None.role` instead of answering 403.

The other 5 are `not user.is_admin and app.user_id != user.id` — admin OR
owner, which is a different question. Those keep their shape and only converge
the truth source from `role != 'admin'` to `is_admin`; routing them through
can_access_app would silently widen access, since that also honours resource
grants.

The migration initially left `current_user_id` referenced but unbound in four
places. Two were caught by tests; the other two (create_manual_app,
upload_app_archive) have no coverage and were caught by an AST scan for names
used-but-never-assigned. Worth remembering that a mechanical removal needs the
scan, not just the suite.

Gate: -k "system or apps or app_ or host_snapshot or manual_app or upload",
304 passed / 13 skipped; all seven ratchets green (52 passed).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-18 22:06:35 -04:00