Releases: keyqcloud/kyte-php
Release list
kyte-php v4.15.1
Fix: make the kyte_locked migration portable to MySQL — KYTE-#325
migrations/4.15.0_datamodel_kyte_locked.sqlused MariaDB-onlyALTER TABLE ... ADD COLUMN IF NOT EXISTS, which is a syntax error on MySQL 5.7 / 8.0. Rewritten to guard with aninformation_schemacolumn check + a prepared statement, which is idempotent on both MySQL and MariaDB. Surfaced during the v4.15.0 rollout to the first MySQL-backed install (itskyte_lockedcolumns already existed, so nothing was harmed — the failed statement was a no-op — but a drifted MySQL install would have errored and never gained the column, leaving the model-level lock guard inert).- Migration-only change; no PHP behavior change. Installs already on v4.15.0 with the columns present need no action. Fresh or drifted MySQL installs now apply cleanly.
kyte-php v4.15.0
Feature: MCP schema-management tools (create/alter/drop models) + schema scope — KYTE-#325
A schema-scoped MCP token can now create and migrate data models from Claude, where each change applies a real DDL migration (CREATE/ALTER/DROP TABLE/COLUMN) against the application's own database. Same plumbing as the #322 site tools — the tools drive DataModelController/ModelAttributeController in internal mode. Shipped in two parts.
PR B — MCP schema tools:
- New
schemascope added toKyteMCPTokenController::VALID_SCOPES, held separately fromprovisionso a token can be granted model-migration rights independently of site spin-up/tear-down. Not hierarchical. src/Mcp/Tools/ModelTools.php(auto-discovered) write tools, all[schema]:create_model(CREATE TABLE),add_attribute(ADD COLUMN — decimal needs precision+scale, varchar needs size, FK viaforeign_key_model),update_attribute(CHANGE COLUMN —namerequired since the column def is rewritten in full),rename_model(RENAME TABLE); plus the destructiveremove_attribute(DROP COLUMN) anddelete_model(DROP TABLE), each gated behind a requiredconfirm_destructiveflag.read_model/list_modelsalready existed. Every caller-supplied model/attribute id is re-scoped to the token's account; foreign ids are rejected.rename_modelregisters the app's model constants viaApi::loadAppModelsfirst —DataModelController::updateguards ondefined($o->name), which the HTTP pipeline satisfies at routing but the MCP path (routing-bypassed) does not (mirrorsAppModelWrapperController).- Model-layer hardening surfaced by the partial payloads the MCP tools send:
DataModelController::prepareModelDefreads optional flags/defaults defensively (no undefined-property warning / null-to-strlen deprecation whenprotected/password/defaults/unsignedare omitted);Api::loadAppModelsskips rows with a missing/corruptmodel_definitioninstead ofdefine()-ing a null name, and no longer re-defines an already-defined constant. - Tests:
tests/McpModelToolsTest.phpextended with write coverage (create/add/update/rename, decimal precision/scale reaching the physical column, destructive-op confirm gating, FK-dependency block, cross-account isolation), driving real DDL against the test DB via a dedicated password-bearing app user.
PR A — model-layer fixes/hardening (platform; benefits Shipyard too):
DBI::renameTablereferenced an undefined$tbl_name_news(typo) → emitted a malformed RENAME; rename was effectively broken. Fixed to$tbl_name_new.DBI::dropTablenow usesDROP TABLE IF EXISTS(was a bare DROP that errored on an already-absent table).kyte_lockedenforced on the DDL path —DataModelController/ModelAttributeControllernow refuse update/delete of a locked model or attribute, guarding before any DB switch or DDL. Newmigrations/4.15.0_datamodel_kyte_locked.sql(idempotentADD COLUMN IF NOT EXISTS) guarantees the column exists on bothDataModelandModelAttribute— the original consolidated 3.x→4.8 upgrade added it but some installs applied it only partially (observed:ModelAttributehad it,DataModeldid not), leaving the model-level guard silently inert. The guard is only meaningful when the column physically exists.- FK-dependency guard before drop — dropping a model is refused while another model's attribute references it via
foreignKeyModel; dropping a column is refused while another attribute references it viaforeignKeyAttribute. Both account-scoped, with an error naming the blockingmodel.attribute. Replaces the two stale "external tables and foreign keys" TODOs. - Decimal (
d) support — newmigrations/4.15.0_modelattribute_decimal.sqladdsprecision/scaletoModelAttribute;prepareModelDefpasses them through for typed;DBI::buildFieldDefinitionnow throws on adcolumn missing precision/scale instead of emitting invalid SQL. The type was previously non-functional end-to-end. - Model-cache invalidation — both controllers call
Api::clearModelCache()after a schema change (create/rename/delete model, add/change/drop column), so the new struct is served immediately instead of the stale file cache (up to 1h TTL).
Migrations: migrations/4.15.0_modelattribute_decimal.sql (adds precision/scale) and migrations/4.15.0_datamodel_kyte_locked.sql (ensures kyte_locked on DataModel + ModelAttribute). Both additive, nullable, idempotent, no-op for existing rows. Roll with the batched 4.14.0 site-tools rollout.
kyte-php v4.14.0
Feature: MCP site-provisioning tools (create/delete/update/read) + provision scope — KYTE-#322
New MCP tools let a token holder manage a site's lifecycle from Claude, building on the #201 SiteProvisioningWorker (provisioning now runs in-process, so these tools just flip state and the worker does the AWS work out of band).
- New
provisionscope — gates infrastructure-creating tools, held separately from contentread/draft/commitso a token can author pages/scripts without the right to spin up or tear down sites. The dispatch path has no scope enum (RequiresScope/ScopeRegistry/the token CSV all accept arbitrary strings), so the only change to make it grantable was addingprovisiontoKyteMCPTokenController::VALID_SCOPES. src/Mcp/Tools/SiteTools.php(auto-discovered):create_site/delete_site/update_site[provision],read_site[read]. Mutations callKyteSiteControllerin internal mode (no session, mirroringDraftService's publish path);create_site/delete_siteflipKyteSite.statustocreating/deletingand return immediately — callers pollread_site(which surfacesstatus,provisioning_message, and the livecloudfront_domain) untilactive/deleted. Every caller-supplied id is re-scoped to the token's account; new sites are stamped with the caller account.- Guarded
KyteSiteController::delete()'sdeleted_by = $this->user->idfor the no-KyteUserserver-side path (isset() ? : 0). - Custom-domain (
aliasDomain) assignment is intentionally not exposed here — that's the ACM + DNS path (KYTE-#320). Infra ids (bucket/distribution) omitted from output;cloudfront_domainsurfaced. - Tests:
tests/McpSiteToolsTest.php(row-level create/read/update/delete + account isolation), wired into the CI unit suite. Code-only — no migration.
kyte-php v4.13.0
Migration: site provisioning moved from Lambda into a cron worker — KYTE-#201
KyteSiteController published S3/CloudFront/ACM actions to SNS_QUEUE_SITE_MANAGEMENT → the kyte-lambda-site-management self-chaining state machine did the work, writing back to the DB via the kyte-lambda-database-transaction Lambda. Both Lambdas are replaced by a cron worker that writes the DB directly. Completes the #201 migration off SNS/Lambda (after #1 + #2 in 4.12.0).
SiteProvisioningWorker(src/Cron/SiteProvisioningWorker.php,CronJobBase, interval 30s) scansKyteSiterows increating/deletingand advances each one actionable step per tick. Sub-state is inferred from the populateds3*/cf*columns (no separate state machine), and every AWS op is idempotent so a tick is safe to re-run:- CREATE: website bucket (create [us-east-1-aware] → drop public-access-block → public-read policy → website config) → media bucket (CORS instead) → website CloudFront → media CloudFront (each distribution id persisted the instant it's created, since
createDistributionisn't idempotent) → poll both untilDeployed→status=active. - DELETE: empty+delete each bucket → disable+delete each distribution (polled across ticks) → delete ACM certs +
Domain/SANrows (after the distributions are gone — a cert can't be deleted while attached) →status=deleted. - CloudFront create/delete take minutes, so deployment is polled across ticks with
heartbeat()— never a blocking sleep. A site that errorsMAX_ATTEMPTStimes flips tostatus=failedwithprovisioning_messageinstead of looping.
- CREATE: website bucket (create [us-east-1-aware] → drop public-access-block → public-read policy → website config) → media bucket (CORS instead) → website CloudFront → media CloudFront (each distribution id persisted the instant it's created, since
KyteSiteControllernew/deleteno longer publish to SNS — they just setstatus=creating/deleting(+ the existing content-record cleanup) and let the worker take over. Domain/cert teardown moved to the worker (ordering depends on the distribution being deleted first).Kyte\Awswrapper fixes:S3::createBucketno longer sends aLocationConstraintforus-east-1(it was failing there) and is idempotent onBucketAlreadyOwnedByYou; newS3::emptyBucket()(paginated, version-aware) sodeleteBucket()works;CloudFrontnow appliesDefaultTTL(86400) and the worker setsMinTTL=3600 for parity with the old Lambda; newCloudFront::isEnabled().- New:
migrations/4.13.0_site_provisioning.sql(addsKyteSite.provisioning_message+provisioning_attempts),bin/register-site-provisioning-job.php. - Unlocks native MCP
create_site/create_app. After this soaks, all 3 Lambdas +SNS_QUEUE_SITE_MANAGEMENTcan be decommissioned.
kyte-php v4.12.0
Cleanup: remove KYTE_USE_SNS flag + dead SNS invalidation branches — KYTE-#201
Direct CloudFront invalidation (shipped + verified in 4.11.1) is now the only path. With every install already running KYTE_USE_SNS=false, this removes the flag and the now-dead SNS publish branch at every invalidation site — behavior-neutral cleanup.
- Dropped the
if (KYTE_USE_SNS) { <SNS publish> } else { <direct> }fork at all 10 sites, keeping only the directKyte\Aws\CloudFront::createInvalidation()call inside its existing best-effort try/catch:ApplicationController(republish-all),KytePageController×3,KyteScriptController×2 (invalidateCloudFront/invalidateCloudFrontForDeletion),KyteLibraryController×2,KytePageDataController,NavigationController,SideNavController. - Removed
'KYTE_USE_SNS' => falsefromApi::$defaultEnvironmentConstantsand thedefine('KYTE_USE_SNS', ...)fromsample-config.php+ docs. - Kept
SNS_REGION,SNS_QUEUE_SITE_MANAGEMENTand theKyte\Aws\Snsclass — still used by site-provisioning (migrated in later #201 work). Updated the PHPStan baseline accordingly. (SNS_KYTE_SHIPYARD_UPDATEis no longer referenced after the shipyard-update migration below.)
Fix: per-app DB connections (DBI::connectApp()) now use SSL when KYTE_DB_CA_BUNDLE is set
DBI::connectApp() — the per-application/tenant connection used by Api::dbappconnect() — opened a plain mysqli connection with no TLS, unlike DBI::connect(). Against a database with require_secure_transport=ON this fails with "Connections using insecure transport are prohibited": the control-plane connection (already SSL) succeeds so login works, but opening any application backed by a dedicated per-app database fails. Latent until a deployment runs on an SSL-required server — surfaced migrating the dev server from Aurora (require_secure_transport=OFF) to a MariaDB RDS with it ON.
connectApp()now mirrorsconnect(): whenKYTE_DB_CA_BUNDLEis defined it connects viassl_set()+MYSQLI_CLIENT_SSL, with the same non-SSL fallback on failure. Gated on the constant, so deployments that don't define a CA bundle keep identical non-SSL behavior (no-op) — verified against ORB/ORT and ETOM, which are unaffected.- Fixed the
Api::dbappconnect()per-app routing guard: it compared the main connection's statics ($dbUser/$dbName/$dbHost) instead of the app statics ($dbUserApp/$dbNameApp/$dbHostApp), so the "already connected to this app" short-circuit never fired. Corrected the comparison and addedDBI::closeApp()when the app target changes, so in-process app switching reconnects to the right tenant DB instead of reusing the previous app's cached handle. Latent cross-tenant hazard, previously masked by single-app-per-request + the cron worker's fork/reconnect()(which clears the app handle). - Removed dead method
DBI::dbInitApp()— it could never run (called non-existentsetCharsetApp()/setEngineApp()), had no callers anywhere, and duplicated the liveApi::dbappconnect()path.
Migration: Shipyard self-update moved from Lambda into a kyte-php cron worker — KYTE-#201
KyteShipyardUpdateController::new previously published current_version to SNS_KYTE_SHIPYARD_UPDATE and the kyte-lambda-update-shipyard Lambda did the work. That Lambda had two production bugs: mimetypes.guess_type() returned None for .map/directory entries → boto3 upload_file(ContentType=None) failed (source maps never uploaded), and its kyte_shipyard_cf env var pointed at a nonexistent distribution → the invalidation threw NoSuchDistribution and the dashboard served stale.
The update now runs out-of-band in a cron worker, not synchronously in the request. The download/extract/upload/invalidate routinely exceeds the ~100s Cloudflare non-enterprise request ceiling (and ALB idle timeouts), so a synchronous controller action would 524 on Cloudflare-fronted installs. Both Lambda bugs are fixed inherently.
- New
KyteShipyardUpdatemodel + table (migrations/4.12.0_shipyard_update.sql) tracks each request and its outcome (statuspending→running→complete/failed,requested_version,deployed_version,files_uploaded/files_failed,cloudfront_invalidated,message). KyteShipyardUpdateController::newdoes a fast inline CDN version check (~100ms) and returns "up to date" immediately when current; otherwise it enqueues aKyteShipyardUpdaterow (status=pending) and returns it. The dashboard pollsGETon the model by id for live status.ShipyardUpdateWorker(src/Cron/ShipyardUpdateWorker.php, aCronJobBasejob, interval 60s) claims the oldest pending row, downloads + extracts the stablekyte-shipyard.zip(ext-zip /ZipArchive), uploads every file viaKyte\Aws\S3::write()with an explicit per-extension Content-Type (JS→application/javascript, CSS→text/css; unknown types like.mapare omitted rather than crashing — fixes bug #1), then invalidates the distribution from configKYTE_SHIPYARD_CF(fixes bug #2, best-effort).heartbeat()extends the lease during large uploads.- Idempotency, two layers: the controller refuses to enqueue while a row is pending/running (request dedup); the worker registers with
allow_concurrent=0(lease lock) and claims rows via a guardedpending→runningUPDATE keyed onaffected_rows()==1(execution dedup).
Setup: run the migration, php bin/register-shipyard-update-job.php, and set KYTE_SHIPYARD_S3 / KYTE_SHIPYARD_CF (+ optional KYTE_SHIPYARD_REGION, default us-east-1) in config.php. New dependency: ext-zip. SNS_KYTE_SHIPYARD_UPDATE is no longer referenced (the Lambda + SNS topic are decommissioned in the final #201 step).
kyte-php v4.11.1
Fix: CloudFront invalidation CallerReference collision (direct/KYTE_USE_SNS=false path) — KYTE-#201
Kyte\Aws\CloudFront::createInvalidation() built its CallerReference as time().$distributionId — second precision. Any two invalidations against the same distribution within the same second reused that reference with a different path batch, which CloudFront rejects with InvalidArgument. The wrapper then swallowed it as a generic "Unable to create new invalidation". This made the direct invalidation path (KYTE_USE_SNS=false) flaky under rapid or bulk publishes — a/the reason invalidation was historically routed through SNS→Lambda instead.
Fix: append uniqid('', true) (microsecond entropy) so successive CallerReferences never collide, and surface the real AWS error message instead of the generic one. Measured latency of a single createInvalidation is ~150–190 ms, so the synchronous direct call is well within request budgets (the old "timeout" symptom was this failure, not the call duration). Unblocks the #201 move off SNS for cache invalidation: fix lands first, then installs flip KYTE_USE_SNS=false, then the dead SNS branches get removed.
Best-effort invalidation hardening. With KYTE_USE_SNS=false the invalidation runs synchronously inside the publish request, so a transient CloudFront error (throttling, a missing distribution) would otherwise fail a publish whose content already wrote to S3 successfully. Wrapped all 10 invalidation sites (KytePageController ×3, KyteScriptController ×2, KyteLibraryController ×2, KytePageDataController, NavigationController, SideNavController) in best-effort try/catch — log and continue, never fail the publish — matching the pattern ApplicationController already used.
kyte-php v4.11.0
Feature: JWT-mode anonymous/public API access (AppContextStrategy) — KYTE-#229
Lets a site running in JWT auth mode (endpoint + appId, no embedded HMAC key/secret) serve requireAuth=false controllers to anonymous visitors (public/catalog browsing before any login) — something only HMAC mode could do before. Server-side half of the two-repo change (the kyte-api-js anonymous fall-through ships alongside).
New AppContextStrategy (src/Core/Auth/AppContextStrategy.php), slotted in AuthDispatcher::buildDefault() after JwtSessionStrategy and before HmacSessionStrategy:
matches()is strict and header-only — claims a request only when anx-kyte-appidis present and there is noAuthorizationBearer, nox-kyte-signature, and nox-kyte-identity. Mutually exclusive with every authenticated flow, so it cannot shadow HMAC or JWT.preAuth()resolves the application's account for query scoping but never resolves a user and never setshasSession. That is the security invariant:ModelController::authenticate()throws unless both$api->user->idand$api->session->hasSessionare set, so everyrequireAuth=truecontroller keeps returning 403 to anonymous requests — onlyrequireAuth=falsecontrollers are reachable.
Defense in depth:
- Per-app tri-state opt-in. New
Application.allow_publicflag (default0; migrationmigrations/4.11.0_application_allow_public.sql):0(default) — anonymous appid-only requests are rejected inpreAuth(); anonymous access is never implicit.1— read-only:ModelControllerrestricts anapp_contextrequest toGETregardless of the controller'sallowableActions(public catalog/storefront browsing).2— controller-governed: the controller's ownrequireAuth=false+allowableActionsdeclaration governs, including writes — needed for pre-login flows like password reset (new/updateare POST/PUT). This matches the contract controller authors have always written against: under HMAC, anonymous visitors to a public site can already reach everyrequireAuth=falseaction (the signing endpoint mints anonymous signatures from the embedded public key alone), so2exposes nothing HMAC does not.requireAuth=truecontrollers still 403 in every mode (the no-user/no-hasSessioninvariant is independent ofallow_public).
- Shadow harness.
AuthShadowHarnessskipsapp_context(no legacy equivalent to diff against during dispatcher rollout).
Audit attribution for anonymous requests uses user_id=null / session '0' (ActivityLogger already tolerates a null user). Existing HMAC and JWT-Bearer flows are unchanged. Tests: tests/AppContextStrategyTest.php (matches() truth table; preAuth resolves account but not user/hasSession; tri-state opt-in enforcement incl. unknown values treated as off).
Feature: platform-level password reset for JWT mode — /jwt/password-* (KYTE-#268)
Shipyard is platform-level (no x-kyte-appid — applicationId is deliberately null), so its anonymous password reset can ride neither HMAC anonymous (gone in JWT mode) nor AppContextStrategy (appid required). Result: password reset was broken on JWT-mode Shipyard installs. Fixed the same way /jwt/login solved appid-less login — dedicated unauthenticated endpoints on JwtEndpoint:
POST /jwt/password-reset{email}→ always{ok:true}(no-reveal); known email gets a timestamped token (raw, in the password column — login disabled while pending) + SES mail.POST /jwt/password-validate{token}→{valid:bool, email}(password.html pre-check; the email is only disclosed to a live-token holder, who received it at that inbox).POST /jwt/password-update{token, password}→ consumes the token, stores the new password hashed, and revokes every refresh-token family for the user (a reset invalidates all sessions);401 invalid_tokenon expired/unknown.
Token/email mechanics are extracted to Kyte\Core\Auth\PasswordResetFlow and shared with KytePasswordResetController (behavior-identical refactor — same token format, 1-hour TTL, no-reveal logging), so the HMAC/app-scoped path and the JWT platform path cannot drift. App-scoped sites keep using their own reset controllers via app_context mode 2. kyte-shipyard reset.js/password.js call the new endpoints when in JWT mode (ships in the Shipyard release alongside).
Tests: tests/JwtEndpointTest.php gains the /jwt/password-* suite (no-reveal, pending-token login lockout, validate/consume round-trip, refresh-family revocation, expired/unknown → 401).
kyte-php v4.10.1
Fix: MCP commit_draft published raw bzip2 bytes into page HTML (header/footer section CSS not decompressed)
Publishing a page through the MCP commit_draft flow could inject raw bzip2-compressed binary (magic BZh9…) into the published HTML, inside <style>/<footer> blocks, producing a browser UTF-8 decode error and garbled header/footer CSS. Surfaced on FrameVTO / doctor.etometry.com (page 58, published to v13).
Root cause. A page's header/footer are FKs to KyteSectionTemplate, which stores html/stylesheet/javascript/block_layout bzip2-compressed. getObject()'s FK expansion returns those fields RAW. The page-assembly path (createHtml → buildHeaderFooterStyles etc.) concatenates them straight into the output, so they must be decompressed first. The human publish path (HTTP update, state=1) was saved only incidentally — KytePageController::hook_response_data() runs first and decompresses $r['header']/$r['footer']. The MCP commit path (DraftService::commitDraft → publishForSurface → KytePageController::publishFromContent) calls getObject() and goes straight to publishPage(), never invoking that hook — so the compressed bytes shipped. Deterministic per-path (not a race): any page with a populated header/footer section template was affected; the "re-publish cleared it" report was a re-publish via the human path.
Fixes (KytePageController, S3):
- Path-independent decompression. New
decompressSectionTemplate(&$section)decompresses header/footer content viaBz2Codec::decompressIfBz2, called from BOTHhook_response_data()andpublishFromContent(). The page's own html/stylesheet/javascript were already correct (decompressed byDraftService::versionContent); only the section templates leaked. - Latent guard bug fixed. The old
hook_response_datablock required all four fields (html,stylesheet,javascript,block_layout) to be set or it decompressed none — a nullblock_layoutwould have leaked the other three even on the HTTP path. The new helper decompresses each field independently. - Output integrity guard.
publishPage()now runshasBinaryContamination()on the assembled HTML (bzip2 stream magicBZh[1-9]1AY&SY, or invalid UTF-8) and aborts before the S3 write (returns false → MCP commit reportscommitted:false, draft left intact) rather than shipping corrupt HTML. - Charset. Published HTML objects are now written with
Content-Type: text/html; charset=utf-8(S3::write()gained an optional content-type passed through the stream-wrapper context; other callers unchanged).
Tests: tests/PublishIntegrityTest.php covers the per-field decompression (incl. the null-block_layout regression) and the contamination detector (embedded bzip2 stream, invalid UTF-8, and no false-positive on literal "BZh" prose).
kyte-php v4.10.0
Feature: MCP draft/write — AI can draft and commit pages, controller functions, and scripts
Phase 2 write tools for the MCP server. Until now the MCP tools were read-only; this release lets an AI client propose changes as drafts and commit them live, across all three content surfaces — without ever touching the live resource until an explicit commit.
Model. A draft is a pending version row (draft=1, is_current=0) on the existing version tables — the live content and the current version are untouched until commit, which flips the draft to is_current=1 and publishes. A new migration migrations/4.10.0_version_draft_flags.sql adds draft + draft_source to KytePageVersion, KyteFunctionVersion, and KyteScriptVersion (additive, migration-first, inert on older code).
Engine. Kyte\Mcp\Service\DraftService is surface-generic, driven by a per-surface descriptor (version model, content model, parent model, content fields), reusing the same sha256 content-hash + bzip2 + reference_count dedup conventions as the existing controllers so drafts de-duplicate against existing version content.
Tools (Kyte\Mcp\Tools\DraftTools):
- Writes (require
draftscope):write_page_part(page_id, part, content),write_function_code(function_id, code),write_script_content(script_id, content). Repeated writes on the same resource accumulate into one open draft. - Review (require
read):list_drafts(application_id)spans all surfaces (each row tagged with itssurface);read_draft(surface, draft_id)returns content + which parts differ from live. - Lifecycle:
discard_draft(surface, draft_id)(draftscope);commit_draft(surface, draft_id)(commitscope) — the only action that changes the live resource. Pages/scripts publish to S3 + invalidate CloudFront; functions write the live code and regenerate the controller's compiled code base. On a failed publish, commit returnscommitted:false+ an error and leaves the draft intact (publishes first, so a failure never half-applies).
Supporting changes. KytePageController::publishPage is now public static and returns the S3 write result (existing void callers unaffected); it gains publishFromContent() for commit. KyteScriptController gains publishFromContent(). ModelController gains an optional internal constructor flag that skips the session authenticate() check, so a trusted server-side caller (the MCP commit flow) can use a controller with an account context but no HTTP session.
Token scopes (read / draft / commit) on KyteMCPToken already existed; new tokens stay draft-only by default, so committing live is opt-in per token. Sequenced by risk: pages and scripts (fully versioned) and controller functions; model/schema drafting is intentionally out of scope (deferred to the data-model initiative).
kyte-php v4.9.0
Fix: ActivityLogger no longer bloats KyteActivityLog, and the admin log list stops dragging blobs (KYTE-#182)
KyteActivityLog grew to 10GB on one install and OOM'd the admin log query. Two independent causes, both fixed here — pure code, no schema change, instant rollback.
-
Write-cap (the durable bloat fix).
request_dataandchangesareLONGTEXTandActivityLoggerstored the full json-encoded request body / diff. A single page or script save carries 300KB+ of HTML/JS/CSS, so each logged row could be hundreds of KB — multiplied across every write, that is what filled the table.ActivityLogger::log()now caps each of those two fields atKYTE_ACTIVITY_LOG_MAX_FIELD_BYTES(default 16384 bytes, auto-defaulted inApi). When the encoded value exceeds the cap it is replaced with a small audit-preserving marker —{"_truncated":true,"_original_bytes":N,"_fields":[...]}— so you still see what was sent or changed (the top-level field names and the original size), just not the megabyte of content. Set the constant to0to disable the limit. Redaction (SensitivityPolicy + the hardcodedSENSITIVE_FIELDSbaseline) is unchanged and still runs before the cap. -
List-view projection (the OOM fix). The framework
SELECT *(DBI::select) pulls bothLONGTEXTcolumns for every row, and the admin log list never uses them — only the single-record detail view does.KyteActivityLogControllernow detects a by-iddetail fetch inhook_prequeryand, on every other (list) response, omitsrequest_data/changesand skips the JSON decode. The detail view (Shipyardget("KyteActivityLog","id",idx)) still returns the full decoded payload, so no Shipyard change is required. Severity/action colors are still added to both list and detail rows.
Not in this release (deferred to the data-model initiative, KYTE-#190): index coverage on KyteActivityLog's filter/sort columns and a retention policy. The earlier one-off ETOM purge (10.4GB → 1.56GB) was the band-aid; the write-cap above is what prevents recurrence.