~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>
The inbound PR webhook must keep answering 200 (providers disable failing
hooks), but its preview-settings crash only reached logger.debug — the
last route swallowing an exception without putting it on the record. It
now goes through record_unexpected before the in-band answer.
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>
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>
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>
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>
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>
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>
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>
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>
The recorded 56 was a measurement artefact — the probe counted any return it
could not resolve to a status, including the unexpected_response() calls the
migration had just added. The real count is 1. The remeasurement instead found
a hole in milestone B's ratchet, now closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Plan 76 milestone G, last item. 151 hex literals live outside the token files
across 35 partials. A theme is a JSON map of CSS custom properties (plan 60), so
only values written as var(--token) change when someone switches skin — every
literal is a spot that stays the default palette on every theme, which is a
functional hole in the themes feature rather than untidiness.
Per-file exact counts rather than a ban, for the reason milestone G gives
explicitly: legitimate brand and data-visualisation colours stay. They just have
to be listed, so the number is honest and can only go down.
Not migrated: picking the right token per site is a semantic judgement (is this
`--green` or `--success`?) with visual consequences, not a substitution — the
same reason .empty-state was ratcheted rather than merged.
Verified non-vacuous: adding one literal to _terminal.scss fails it (20 vs 19),
and it passes again on revert.
Gate: boundary check green, npm run lint 0 errors / 953 warnings, build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Also records why the remaining milestone A work (605 @jwt_required routes) is
not a mechanical swap: auth_required() accepts API keys where @jwt_required()
does not, so converting a route expands the authenticated surface and needs a
decision about which routes should be API-key reachable — not just an edit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Plan 76 milestone G. 114 top-level class definitions share 50 names across
partials, so those classes have no owner: every definition applies, merged
per-property in main.scss import order, and editing one file changes only the
properties that file happens to win.
`.empty-state` is the worked example the audit named. Three partials define it —
components/_empty-state.scss (flex column, min-height 200px), _cards.scss (card
background, solid border, radius), _users.scss (padding, colour) — and the
compiled sheet carries five competing top-level rules. What renders takes its
background and *dashed* border from one, its padding and display from another,
and its colour from a third. No author wrote that element.
Ratcheted rather than merged, on purpose. Reproducing the cascade's computed
result has no byte-identical-output proof available (unlike the @keyframes
sweep, where deleting seven dead copies left the CSS unchanged to the byte), so
it needs eyes on the affected pages — plan invariant 9, no repo-wide mechanical
rewrite without behavioural tests.
The ratchet counts DEFINITIONS, not names: counting names would let an
already-shared class spread to a fourth and fifth partial without moving the
number, which is the failure mode worth stopping. Mutation-checked both ways —
adding a definition of an existing shared class and introducing a brand-new
shared name each fail it, and both pass on revert.
Wired into `npm run lint`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone G. A @keyframes name is global and last-definition-wins across
every partial main.scss pulls in, so a duplicate name is not wasted bytes — one
copy is silently dead, and which one depends on import order.
`spin` was defined 8 times. The build already emitted exactly one, so seven were
dead: deleting them left the compiled CSS byte-for-byte identical (same content
hash, 958878 bytes), which is the proof rather than an argument. Three of the
seven also differed — `to`-only rather than `from`/`to` — so anyone editing
those was changing nothing.
`pulse-dot` was the one that actually mattered: components/_uptime defined it
with a scale (1 -> 1.2) and pages/_services defined it opacity-only, and
_services is imported later, so the uptime dot has been running the services
animation. Renamed the uptime one to `uptime-pulse-dot`; service-detail keeps
the shared `pulse-dot` it already got.
**Visual change**: the uptime status dot now scales as its own stylesheet always
said it should. Nothing else moves.
`pulse` (identical twice) and `drawer-fade`/`drawer-slide` (identical in
_docker and _file-manager) are consolidated into base/_utilities.scss, so
neither page owns the other page's animation.
Ratchet: the boundary check now scans every .scss partial and fails on any
@keyframes name defined more than once. 66 unique names, zero duplicates.
Verified non-vacuous: an injected second `spin` fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 0bfcf69c. Ratchet 16 sites / 12 files -> 11 / 10.
Dashboard's remote-metrics poll and its socket-down HTTP fallback, ImportWizard's
active-import poll, and TestSandbox's run poll plus its open-log fan-out.
TestSandbox's log refresher fanned out one request per open log per tick and
returned nothing, so a tick reported done before any of them had come back. It
now returns a single Promise.all, which is what the in-flight guard needs to
mean anything for a fan-out.
That leaves 11 raw setInterval sites, and they are deliberate:
- 5 are clock ticks with nothing to overlap and nothing to fetch — an elapsed
timer, a countdown, a board refresh pulse, two wall clocks.
- 3 are the socket-fallback pollers in useMetrics, useContainerStatus and
useDeployJobStream, driven by imperative startPolling()/stopPolling() from
socket handlers with a 3s grace period. Expressing that as a declarative
`enabled` is a real restructure of each hook and they only poll when the
socket is already down.
- 2 belong to the serverkit-gui extension, whose source is the sibling repo;
its frame loop already carries its own in-flight guard.
- 1 is ServerGui's user-pausable capture interval.
The ratchet keeps all of them at exact counts, so the reasoning above has to be
revisited rather than forgotten if one changes.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 89c3896e. Ratchet 22 sites / 15 files -> 16 / 12.
DoctorPanel's fleet sweep and ScannerTab's malware scan were imperative
pollers: a function started an interval, and the interval cleared itself once
the job reached a terminal status, with a second clearInterval in an unmount
cleanup to catch the case where it never did. Both now hold the job id in state
and let usePolling own the schedule —
const pollSweep = useCallback((jobId) => setSweepJobId(jobId), []);
usePolling(tick, SWEEP_POLL_MS, { enabled: Boolean(sweepJobId) });
which is what makes the visibility pause and the in-flight guard apply to them
too. A job poller is the shape that most wants both: it runs while someone
watches a spinner, and its endpoint is doing real work.
Also ContainersTab's stats poll and log tail, and OnboardingWizard's status
poll (its 1s elapsed-time clock stays a plain interval — nothing to overlap).
Two `useRef`s and one `clearInterval` in an unmount cleanup went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f9657d38. Ratchet 27 sites / 18 files -> 22 / 15.
Terminal's three tab components (file logs, journal, processes), ServicesTab's
unit log tail, and the GUI extension's synthetic desktop. All were the
auto-refresh tail shape: poll only while the toggle is on, never fetch on mount
from the interval effect.
These are the sites where a hidden tab mattered most — a log tail at 3s left
open in a background tab is the single most wasteful poller in the panel, and
every one of them also re-ran on `lineCount`/`priority`/`sort` changes via a
dependency array with `// eslint-disable-line` on it. That silent restart is now
explicit: the tail's own load effects still handle those, the poll handles the
repeat.
Four `intervalRef`s and one `logIntervalRef` went with them.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing 3ac566cb. Ratchet 31 sites / 22 files -> 27 / 18.
QueueDetail and QueueOperations each fired two requests per tick. Their ticks
now return a single awaited Promise.all, because the in-flight guard only knows
a tick has finished when the promise it was handed settles — a bare `.then()`
inside the callback reports done immediately and lets the next tick start on top
of a request still in flight, which is the exact overlap the hook exists to
prevent.
ServerDetail and LinkPanelForm keep their mount-time load as its own effect;
the poll is `immediate: false` so nothing fetches twice.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings, npm test 72
passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, continuing f21abe7a. Poller ratchet 41 sites / 31 files
-> 31 / 22.
Migrated: Jobs, Monitors, MonitorDetail, DeliveryLog, Deployments, Databases,
VulnerabilityTab, and the service-detail Logs/Metrics/Overview tabs.
The trap flagged in the plan bit on four of them, so it is worth restating: the
interval effect's dependency array was doing double duty as the "reload because
the query changed" trigger —
useEffect(() => {
load(); // <- reload on filter change
pollRef.current = setInterval(load, POLL_MS);
return () => clearInterval(pollRef.current);
}, [isAdmin, load]); // <- load() is a useCallback of the filters
usePolling holds the callback in a ref precisely so it does not restart on every
render, which means a naive swap silently drops that reload and nothing fails to
compile. Each of those four keeps an explicit `useEffect(() => load(), [load])`
alongside the poll, with `immediate: false` so mount does not fetch twice.
Six magic interval literals became named constants while their lines were being
touched (LOG_REFRESH_MS, METRICS_REFRESH_MS, SCAN_POLL_MS, DEPLOY_POLL_MS,
ENGINE_POLL_MS).
Not migrated, and not mechanical: useMetrics, useContainerStatus and
useDeployJobStream are socket-fallback pollers driven by imperative
startPolling()/stopPolling() calls from socket event handlers, with a 3s grace
period before falling back. Expressing that as a declarative `enabled` flag is a
real restructure of each hook, and they only poll when the socket is already
down. Left for when they are next touched.
Gate: npm run build clean, npm run lint 0 errors / 953 warnings (one below the
954 baseline — four orphaned useRef imports removed), npm test 72 passed,
boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone F3 (the blob half) plus the C4-interim dead-file deletion the
audit called out.
The six-line "save this to disk" anchor ritual was pasted into 14 components,
and the copies had drifted in two ways that decide whether the download
actually happens:
- only 3 of 14 appended the anchor to the document. Chrome dispatches a click
on a detached anchor; Firefox has not always done so, which makes "the
download button does nothing" a browser-specific bug that never reproduces
for whoever wrote it.
- most revoked the object URL on the very next line after click(). The download
starts asynchronously, so revoking immediately can cancel it — showing up as
a flaky empty-or-missing file rather than a clean failure.
utils/downloadBlob.js does both correctly (append, click, remove, revoke on a
later task) and accepts either a string or an existing Blob, which is what
FleetCapacityPanel needs since its CSV arrives from the API already blobbed.
Also deletes src/services/wordpress.js: 494 lines, zero importers, and a stale
duplicate of the live src/services/api/wordpress.js that the ApiService barrel
actually wires in. Verified by import graph and a clean production build.
Ratchet: the boundary check now fails on any `.download =` assignment outside
the helper — a zero baseline, not a shrinking one, since every site is
migrated. Verified non-vacuous: an injected anchor fails it, revert passes.
Gate: npm run build clean, npm run lint 0 errors / 954 warnings (unchanged),
npm test 72 passed, boundary check green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
a7d5a0b9 replaced hand-shaped 500s in docker.py and notifications.py with
unexpected_response(), which rolls the session back itself, so those two routes
no longer call db.session.rollback() directly. Controller-boundary persistence
crossings 519 -> 517; no new entries.
Caught by the full per-file sweep rather than the focused gate, which did not
include this ratchet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names why the sniffing row is ratcheted rather than migrated: file_service owns
12 of the 23 sites and has eight callers, so converting it to raise ahead of the
envelope decision would leave two contracts on one service — the failure mode
the plan exists to remove.
Adds a status table for the five drift/bug rows and the five ratchets now
holding them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone C, drift/bug row 3 — the ratchet only. 23 sites choose an
HTTP status by searching an error message:
status = 403 if 'denied' in result.get('error', '').lower() else 400
That is what `{'success': False, 'error': '<prose>'}` forces on a caller: the
service knew the failure was a denial, discarded it, and the route rebuilds it
from the sentence. The status is then coupled to the wording — rephrasing
"Access denied: path not in allowed directories" to "You do not have access to
this path" turns a 403 into a 400 and nothing fails, not a test, not a lint
rule.
The migration is NOT done here on purpose. Converging these means services
raising typed errors instead of returning result dicts, and file_service alone
(12 of the 23 sites) has eight callers. Converting it to raise while its other
callers still expect a dict leaves two contracts, which is the disease rather
than the cure — that is milestone C's envelope decision, sequenced after A and
B by the plan's own execution order.
So the ratchet lands first, per second-wave rule 1, to stop the pattern
spreading in the meantime. Ceiling 23. Counts a conditional choosing between
two HTTP status codes whose test inspects string content (`in`, `.lower()`,
`.startswith()`); a status chosen from a typed exception, an enum, or a boolean
is not counted, since that is the thing being asked for. Verified non-vacuous:
an injected site fails it and it passes again on revert.
Gate: tests/test_status_sniffing_ratchet.py, 8 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Corrects the audit's framing of the fix: the in-flight guard is not what stops
the schedule stacking — arming the next tick from the previous run's completion
is. Also flags the trap for the remaining 41 migrations: several sites use the
interval effect's dependency array as an out-of-band reload trigger, which
usePolling drops silently.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone E2, drift/bug row 4. 45 hand-rolled setInterval sites across
35 files, with `visibilitychange` handled in exactly one of them and an
in-flight guard in exactly one of them — the documented poller-stampede shape.
Two failures every hand-rolled `setInterval(load, 5000)` shares:
- it starts a request every 5s whether or not the previous one returned. A slow
endpoint does not slow the poller down, it makes it stack; each extra request
makes the server slower, which makes the next overlap more likely. This is
what a DevTools capture of /domains showed as a wall of pending rows.
- it keeps polling a tab nobody is looking at, where browsers throttle timers
unevenly anyway, so it is not even a reliable refresh.
utils/pollScheduler.js holds the rules as a plain object so they are testable
without a DOM (the repo has no jsdom — this follows formState.js/useForm).
hooks/usePolling.js is the React binding, and useServerQuery gains
`refetchInterval` built on the same scheduler, so there is one implementation
rather than a second one for query users.
Two design points worth stating, since both differ from what the sites did:
- the next tick is armed by the previous run *finishing*, not by setInterval.
With setInterval the gap is measured tick-to-tick regardless of how long the
work took, so slow work silently turns a 5s gap into no gap. The in-flight
guard therefore protects the out-of-band paths — refresh() and the visibility
catch-up — rather than the schedule, and the test says so instead of implying
a guard the schedule can no longer exercise.
- the callback is held in a ref, so an inline arrow re-created each render does
not restart the timer. That is the usual reason a hand-rolled poller runs far
faster than its stated interval.
Migrated four as reference implementations, per the plan's "migrate a
representative flow before broader adoption": DeployPill (which had worked all
of this out by hand and loses ~20 lines), MonitorsSummary, appdetail/LogsTab,
and ProcessListPanel. MonitorsSummary keeps its `refreshKey` prop as an explicit
out-of-band refresh rather than folding it into the schedule.
Ratchet: check-frontend-boundaries.mjs gains LEGACY_POLLERS with exact per-file
counts, now 41 sites in 31 files, so every future cleanup must shrink it and a
new bare setInterval fails lint. Genuine non-fetching timers (clock ticks,
countdowns) are listed too rather than pattern-matched away — they are harmless
but the exact count is what makes the ratchet trustworthy.
Gate: npm test (72 passed, 11 of them new), npm run lint (0 errors; 954
warnings, one fewer than the 955 baseline), npm run build clean, boundary check
green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the three ways the audit's measurement was off, and names the 56
swallow-and-answer-200 handlers as still open so B cannot read as finished.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Plan 76 milestone B, drift/bug row 4. 43 API handlers answered HTTP 500 from
their own `except Exception`, so the global 500 handler never ran — and that
handler was the ONLY caller of error_log_service.record_error(source='backend').
Every one of those crashes was absent from /monitoring/errors, and the page read
empty precisely because someone had "handled" them.
35 of the 43 also returned str(exc) to the caller. Two returned the full
traceback (templates.py, which also print()ed it), and several logged with a
locally-minted uuid4 'ref' that correlated with nothing but a log line.
The recording side of the 500 handler moves to app/error_reporting.py, so it is
importable rather than reachable only by not-catching:
record_unexpected(exc) -> logs, rolls the session back, records, returns
the request id
unexpected_response(exc) -> the above plus the documented crash body
The 500 handler now calls the same pair, so there is one recorder rather than
one recorder and 43 routes that quietly opted out of it.
Caller-visible change on those endpoints: the body is now the documented
{'error': 'Internal server error', 'status': 500, 'code': 'internal_error',
'request_id': ...} instead of the exception text. That is the point — an
unexpected exception's text is written for whoever reads the log and routinely
carries connection strings, absolute paths, and SQL. The request id is the part
a caller can act on, and it matches the row in the error log. No frontend code
read the 'ref' or 'trace' keys this drops (checked).
Two sites keep their own wording and call record_unexpected instead: sso.py's
login failures should not read as a generic crash on the login page.
docker.py's bulk endpoint keeps its per-item `results` — the caller's half-done
work survives, only the exception text is dropped.
Ratchet: tests/unreported_crash_census.py + its test, held at **0**, not a
legacy baseline to shrink. It counts blanket handlers that answer 500 without
calling either reporter, and is scoped to app/api — a service catching an
exception and returning a value is making a domain decision, not declaring an
HTTP crash. Verified non-vacuous: an injected handler fails it, and it passes
again on revert.
Measured and deliberately still open: 56 API handlers swallow a crash and
answer 200. A failure reported as success is worse, but converging it is
milestone C's response-envelope work, not this row.
Gate: tests/test_unexpected_error_reporting.py (11, including the two that pin
why the old shape lost the crash), test_unreported_crash_ratchet.py (9), and
the 66 test files covering every touched module: 1105 passed, 7 skipped, 1
pre-existing Windows-only failure (test_fleet_doctor's socket.gaierror).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>