You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
This commit was created on GitHub.com and signed with GitHub’s verified signature.
Added
Distribution-archive validation gate (scripts/validate-dist-archive.sh, dist-archive CI job). Composer installs this package as a dist archive: for a GitHub-hosted package, Composer downloads GitHub's zipball/tarball, which is produced by git archive and honors .gitattributes export-ignore. The export-ignore list is therefore the live filter deciding what every Composer consumer downloads (it keeps tests/, CI config, tooling, and the committed composer.lock out), and nothing in CI validated it against the archive it actually produces — a typo'd or missing line silently ships dev cruft, an over-broad line silently drops a runtime file and ships a dead package. The new script reproduces the shipped archive (git archive HEAD) and asserts four things: no export-ignored path leaked in, every committed runtime asset (src/, config/, routes/, resources/, database/, composer.json) is present, every top-level entry is on an explicit fail-closed allowlist (a new top-level file/dir fails CI until it is either export-ignored or added to the allowlist in a deliberate commit), and the archive stays under a documented size cap (~2x the measured ~0.35 MB clean archive). Mirrors the gate scolta-drupal already runs against its drupal.org tarball; the precedent is the scolta-wp 13 MB zip incident and the WordPress.org dist-cruft flags. Validation only — .gitattributes and the shipped contents are unchanged.
Changed
scolta:build builds synchronously by default; queue dispatch is now opt-in via --queue. The PHP-indexer build used to dispatch to the queue by default and only build inline with --sync. That made the common deploy invocation (php artisan scolta:build in a build pipeline / initContainer / CI step) asynchronous, so it returned before — or entirely without — building the index (see the false-success fix below). The deploy-safe default is now a synchronous, verified build: a bare scolta:build exits 0 only when the index is built and live on disk. Pass --queue to defer the build to the queue for the large-corpus / background case (it prints that the index is not yet built and returns a distinct deferred exit code on an async connection — see below). --sync is now a deprecated no-op alias for the default and is rejected when combined with --queue. The content-edit observer (ScoltaObserver → TriggerRebuild) is unchanged and keeps using the queue. ⚠️ Upgrade note: if a deploy script relied on scolta:build returning immediately (fire-and-forget to a worker), add --queue to keep that behavior; otherwise the command now blocks until the index is built, which is almost certainly what a deploy wants.
The health endpoint now returns status-only to anonymous callers.GET /api/scolta/v1/health previously exposed the full diagnostic payload — AI provider, index integrity, tracker counts, asset staleness, version detail — to anyone, since health_middleware defaults to the public ['api'] group. Monitoring endpoints keep working: anonymous requests still get HTTP 200 with {"status": "ok"|"degraded"} (the status is still computed from the full report, so integrity degradation remains visible to uptime monitors). The detail moved behind the new scolta.health-detail Gate, which defaults to any authenticated user and can be redefined in the host app's AuthServiceProvider. The route registration and health_middleware default are unchanged — reachability stays. Matches the status-only anonymous shape of the WordPress, Drupal, and Django adapters. Covered by HealthPayloadShapeTest.
Extracted an abstract AiController base for the three AI endpoints.ExpandQueryController/SummarizeController/FollowUpController duplicated cache-driver resolution, the generation counter, enricher wiring, and the error response shape. The base class owns them; the follow-up controller keeps its no-cache overrides. The shared scolta_expand_generation key is intentionally not renamed (a rename would orphan deployed counters mid-flight); its definition now documents that it is the shared AI generation counter for both expansion and summaries.
Extracted AssetStatus service for published-asset checks. The "are assets published / do they match the package checksum" logic was triplicated across HealthController, scolta:status, and the provider's publishable registration. All three now use one service. scolta:build also stops force-republishing assets on every build when the published files already match the package checksum (force-rewriting bumped the mtime-based ?v= cache busters for no reason), and uses the imported Artisan facade instead of the root \Artisan alias.
Build auto-resume log moved from the system temp dir to storage_path('logs/scolta-resume.log') so it survives tmp cleaners and lands where Laravel logs are expected.
composer.json PHP floor raised to ^8.2. The previous >=8.1 was unsatisfiable with illuminate/* ^11|^12|^13 (Laravel 11 requires PHP ≥ 8.2) and only appeared to work because CI installs with --ignore-platform-req=php.
Doc accuracy:scolta:cleanup docblock no longer claims dry-run-by-default (deletion is the default; --dry-run previews); scolta:memory-budget docblock no longer claims --set writes .env (it prints the instruction); config/scolta.php reunites the AI-provider docblock with the ai_* keys and moves the stranded sortable_fields key next to its own docblock; the default AI model string is defined once as ScoltaAiService::DEFAULT_MODEL (previously duplicated in the provider and config, where the Amazee auto-model check would silently rot when the default bumps).
ProgressController now extends the base controller like every other controller, and HealthController carries the standard @since/@stability annotations; both ProgressController and RebuildNowController are enforced by ControllerStructureTest.
Fixed
scolta:build no longer reports success for an index that was only enqueued and may never be built. With the PHP indexer (the default), scolta:build without --sync called dispatchToQueue(), which dispatched the ProcessIndexChunk… + FinalizeIndex chain and then unconditionally returned exit 0 with "Rebuild dispatched to queue" — regardless of the effective queue connection. On an async connection (database/redis/sqs) with no worker running, the chain sat in the queue forever, FinalizeIndex (and therefore the atomic swap) never ran, and the operator/initContainer/CI read exit 0 as a built, live index. On a redeploy this served a stale index reported as fresh; on a first deploy it served no index at all reported as a successful build. The fix makes synchronous, verified building the default (exit 0 ⇒ index built and live; the PHP-indexer path runs IndexBuildOrchestrator::verifyIndexComplete() via the orchestrator before reporting success) and makes the opt-in --queue path honest about the effective connection: on the sync connection the chain runs inline so the index is verified and SUCCESS is reported; on an async connection the jobs are only enqueued, so the command prints "Index NOT yet built — a worker must drain N jobs" and returns a distinct non-success exit code (BuildCommand::DEFERRED, 3) instead of SUCCESS. The same false-success was also fixed on the post-indexing memory-recovery branch (index_only_complete), which dispatched FinalizeIndex to the queue and returned SUCCESS — it now fails on the sync connection (no worker can finalize) and returns DEFERRED on an async one. Covered by BuildDeploySafetyTest (no built index / DEFERRED on a worker-less async connection; synchronous default builds and verifies; an interrupted finalize leaves the prior index byte-identically intact — degrades to stale, never empty).
The queue rebuild chain now initialises the build manifest, so a drained chain actually produces an index.ProcessIndexChunk commits each chunk via BuildState::recordChunk(), which only updates the manifest's chunks_written counter when a manifest already exists; FinalizeIndex then reads that counter via BuildCoordinator::chunkFiles(). The pure-queue path (QueueRebuildDispatcher → ProcessIndexChunk → FinalizeIndex) never called prepare() to create a manifest, so recordChunk() silently skipped the counter, chunkFiles() returned empty, and FinalizeIndex failed with "No chunk files found" — the chain dispatched but produced no index, even with a worker draining it (this is why the regression run used --sync). QueueRebuildDispatcher::dispatch() gained an opt-in $prepareBuildState parameter that calls BuildCoordinator::prepare() + releaseLockOnly() before dispatching; scolta:build --queue passes it true. It was initially opt-in ($prepareBuildState, set only by scolta:build --queue) because prepare() on a fresh intent runs cleanup(), which would wipe an in-flight build's chunk files if a second dispatch arrived mid-chain — and the observer path (TriggerRebuild) can fire repeatedly across the debounce window with no serialization against a draining chain. That gap is now closed by the cross-process build lock below, which makes the manifest init safe on every entry point; the $prepareBuildState parameter is removed (dispatch() always prepares). Covered by BuildDeploySafetyTest::test_queue_on_sync_connection_builds_inline_and_succeeds, which drives the full chain end-to-end.
The default queue rebuild paths (first-run auto-build + the content-edit observer) now actually produce an index. This closes the known follow-up from the manifest-init fix above. The manifest init (BuildCoordinator::prepare() + releaseLockOnly()) was gated off for TriggerRebuild — the job dispatched by the provider's first-run auto-build and by ScoltaObserver on every content change — because prepare()'s cleanup() could wipe an in-flight build's chunk files if a second dispatch raced it, and the observer has no serialization against a draining chain. So with the shipped default (auto_rebuild => true, PHP indexer), a fresh install built no index on first visit and never reindexed on content change; the only working build was an explicit CLI scolta:build. Root cause that blocked the simple "just call prepare()" fix: BuildState's build lock is an flock() bound to the acquiring process, and each chained job (ProcessIndexChunk, FinalizeIndex) runs in a separate worker process — so an flock acquired at dispatch is gone before the next job runs. The synchronous CLI build works only because its whole pipeline runs in one process holding the flock throughout. The fix gives QueueRebuildDispatcher::dispatch() a cross-process lock (Cache::lock('scolta_build_chain', 3600), an owner token) acquired before any build state is touched and held across worker processes: FinalizeIndex releases it via Cache::restoreLock() when the chain ends — on success and failure — and a TTL-bounded stale lock self-heals a dead chain (mirrors BuildState's 1h window). With concurrent dispatch now impossible, prepare() is safe on every entry point, so dispatch() always initialises the manifest (the $prepareBuildState opt-in is gone) and all three queue entry points — first-run, observer, and CLI --queue — share the one correct path. A second overlapping dispatch (observer debounce race, first-run + a manual --queue, concurrent edits) finds the lock held and returns the new STATUS_IN_PROGRESS (CLI reports DEFERRED, never a false SUCCESS) instead of clobbering chunk state. FinalizeIndex also resets the build manifest to a clean slate on failure (it previously left status: building, blocking nothing on a fresh dispatch but lingering in status queries), and only ever touches the state dir — the published pagefind/ index is untouched, so an interrupted rebuild still degrades to stale, never empty (#96's atomic-swap fail-safe holds). The #96--sync-default and honest --queue/DEFERRED exit codes are unchanged. Covered by QueuedRebuildProducesIndexTest (a drained chain publishes a non-empty pagefind-entry.json with fragments; first-run yields an index on a draining worker and stays honest-DEFERRED on a worker-less async connection; an overlapping dispatch no-ops without corrupting the in-flight manifest; the lock frees after a build so the next rebuild proceeds). No scolta-php change was needed — prepare() + releaseLockOnly() already expose the manifest-only/lock-released init this path requires.
Amazee credentials provisioned without resolved model names now self-heal instead of breaking AI permanently. The Amazee.ai trial provisioner persists credentials (LiteLLM token + URL) and resolves the model names in two steps; when the /model/info step fails, credentials land in scolta_config with no resolved models (storeModels() never runs). AutoProvisioner::ensureAiAvailable() then no-opped forever on the stored credentials, the ScoltaAiService factory built the client with the shipped dated default claude-sonnet-4-5-20250929, and the Amazee LiteLLM gateway rejects that dated name with HTTP 400 — so summarize silently returned nothing and expand ran unexpanded, with no path back. (A distinct failure class from the expired-key recovery below: a 400 invalid-model from a half-provisioned store, not an auth failure.) ScoltaServiceProvider now (1) passes scolta-php's new hasResolvedModels predicate to ensureAiAvailable() — keyed off the dedicated models store, which is empty in the unresolved state (the clean signal, no dated-default exclusion needed) — so when credentials are stored but models are not, the library re-resolves against the already-stored key (never a fresh trial) and persists them via the existing storeModels() callback; (2) keeps a half-provisioned state (amazeeHalfProvisioned()) out of the once-per-install scolta_amazee_provisioned cache flag, so the heal actually re-runs on the next request instead of being stranded by the flag (the explicit-key / no-credentials cases still settle the flag, so a fresh trial is never re-attempted per request); and (3) degrades the AI-service factory when credentials are stored but no model is resolved — it does not inject the Amazee key, so a key-less client throws ApiKeyMissingException (degraded to an unexpanded/no-summary HTTP 200 by the controllers) rather than sending the gateway the dated default (HTTP 400). $amazeeActive stays true on the degraded path (credentials are stored), so /health and key-expiry recovery still see the Amazee path. The explicit-key path is unchanged (a dated model is valid on the real Anthropic API); only the Amazee branch degrades. Requires scolta-php ≥ 1.0.4 (unreleased) (AutoProvisioner::ensureAiAvailable()'s $hasResolvedModels parameter); the committed composer.lock stays pinned at the stable release and is bumped once scolta-php 1.0.4 is tagged. Covered by ModelResolutionSelfHealTest (predicate reports unresolved for an empty store; the real AutoProvisioner re-resolves against the stored key driven by that exact predicate; a naive creds-present predicate provably would not heal; the degraded service throws ApiKeyMissingException without calling the gateway).
Expired Amazee trial keys now recover instead of silently killing AI, and /health tells the truth. An Amazee.ai trial key is revoked server-side when the trial ends; the next AI call then returned 400 expired_key while the adapter no-opped on the stored (dead) credentials — so expand echoed the query, summarize returned nothing, and GET /api/scolta/v1/health still reported ai_configured: true (the django demo outage, 2026-06-09). ScoltaServiceProvider's AI-service singleton now wires scolta-php's KeyExpiryRecovery into the call path only on the auto-provisioned Amazee path ($amazeeActive — stored credentials, no explicit key): an auth-class failure triggers a one-shot re-provision of a fresh trial key (guarded to one attempt per window; markers in the Illuminate\Cache store) and a single retry. The explicit-key branch leaves the service unwired, so a user's own SCOLTA_API_KEY is never re-provisioned behind their back; budget-exhaustion is excluded by KeyExpiryRecovery so it can't reset the spend ceiling. HealthController hands the same LaravelCacheDriver to HealthChecker, so a recorded auth failure surfaces as ai_usable: false / ai_auth_failing: true (status degraded) while the key is known-bad. Requires scolta-php ≥ 1.0.4 (unreleased) (AiServiceAdapter::setKeyExpiryRecovery(), KeyExpiryRecovery, HealthChecker's $cache parameter); the committed composer.lock stays pinned at the stable release and is bumped once scolta-php 1.0.4 is tagged. Covered by KeyExpiryRecoveryWiringTest (recovery wired only on the Amazee path, once-per-window re-provision through the Laravel cache bridge, health truthfulness).
Default build paths no longer index unpublished content. The synchronous PHP-indexer path (scolta:build --sync), the queue dispatch path (scolta:build without --sync), and the observer-driven TriggerRebuild job all gathered content via bare Model::all()/Model::cursor(), bypassing the documented publish filters — scopeSearchable() and shouldBeSearchable() — that only the binary pipeline's ContentSource applied. Since the default 'auto' indexer resolves to 'php' and the provider's first-run auto-build dispatches TriggerRebuild, out of the box draft/unpublished content landed in the public search index. All gathering now flows through ContentSource::getPublishedContent(), which applies both filters (it previously skipped the per-record shouldBeSearchable() check); TriggerRebuild::gatherItems() and BuildCommand::streamContentItems()/gatherContentItems()/gatherItemCount() are deleted. Note: the non-trait array-return fallback those private methods carried is gone — models must use the Searchable trait, as the config has always documented. Regression tests cover both filters on the sync path (SyncBuildFilteringTest), the queue path (QueueRebuildDispatchTest), and ContentSource itself (ContentSourceFilteringTest).
Queue rebuild payloads no longer embed the whole corpus.TriggerRebuild (and scolta:build's queue dispatch) chunked full bodyHtml content into ProcessIndexChunk job payloads — a dispatch-time RAM blowup and a queue-driver payload-cap hazard (SQS caps payloads at 256 KB). Content is now streamed into JSON chunk files under {state_dir}/queue-payload/ and jobs carry file references; each job deletes its file after committing. The shared logic lives in the new QueueRebuildDispatcher service, used by both dispatch entry points so they cannot diverge again. ⚠️ Upgrade note:ProcessIndexChunk's constructor now takes an $itemsFile path instead of an $items array — drain or flush queued Scolta jobs before deploying this version.
The observer rebuild path now respects the configured memory budget.TriggerRebuild hardcoded chunk size 50 and dispatched ProcessIndexChunk/FinalizeIndex without the memory budget, so SCOLTA_MEMORY_BUDGET was ignored on the observer path and job offset computation matched the dispatcher's chunking only by coincidence. Both now derive from the same MemoryBudget resolved via MemoryBudgetConfig::fromCliAndConfig(), exactly like scolta:build. Covered by a non-default-profile regression test.
FinalizeIndex no longer swallows finalization failures. A failed finalize report was silently discarded, so the job chain looked successful while no index was published. The job now logs the report error and throws, landing the failure in failed_jobs/Horizon; the AI cache generation is only bumped on success. Covered by FinalizeIndexFailureTest.
Amazee.ai auto-provisioning no longer runs in provider boot.attemptAmazeeAutoProvisioning() ran on every boot: when eligible it made a blocking provisioning HTTP call inside a user-facing request; with SCOLTA_API_KEY set the settled cache flag was never written, so the check (and its DB lookup) re-ran on every request; before migrations it report()-ed an exception on every request. Provisioning is now deferred to the first resolution of ScoltaAiService (i.e. the first AI request, matching the documented behavior), the settled flag is cached for every settled outcome including the explicit-key branch, and the unmigrated-DB path logs once per hour instead of reporting every request. Covered by AmazeeProvisioningFlagTest.
scolta:export deletions now go through the manifest-aware exporter. Deletions were performed by manually concatenating {buildDir}/{id}.html and File::delete()-ing it — wrong (or no) deletion when the export manifest maps non-flat paths (the nested URL layout introduced in 1.0.1), plus an $id-in-filesystem-path smell. ExportCommand now calls ContentExporter::deleteById() exactly like BuildCommand always did.
scolta:build --indexer (and scolta.indexer config) now reject unknown backends. Any typo (e.g. --indexer=pphp) silently selected the binary pipeline; the command now errors with the valid choices, matching how scolta:memory-budget validates --set.
scolta:rebuild-index no longer interpolates the Pagefind binary path unescaped. Both scolta:build and scolta:rebuild-index now share the new PagefindRunner service (binary escaped via escapeshellcmd, paths via escapeshellarg, 300s timeout, recursive HTML count, cache-generation bump). Side effect: scolta:rebuild-index now counts exported HTML recursively like scolta:build, instead of a flat glob that missed the nested export layout.
ScoltaObserver auto-rebuild fallback default aligned with config.maybeDispatchRebuild()/afterBulkUpdate() used config('scolta.auto_rebuild', false) while config/scolta.php defaults to true — behavior differed when the package config wasn't merged. The fallback is now true, and afterBulkUpdate() delegates to maybeDispatchRebuild() instead of duplicating it line-for-line.
/rebuild-now lock race fixed and moved to an invokable controller. The route closure acquired the build lock, released it immediately, then dispatched — two concurrent requests could both dispatch a rebuild. The new RebuildNowController (replacing the repo-convention-violating logic-bearing closure) holds the lock through the dispatch and returns 409 while it is held. Covered by RebuildNowControllerTest.
scolta:clear-cache increments the generation counter atomically. The previous Cache::get() + Cache::put() pair could lose an increment racing a concurrent build finish; it now uses Cache::increment() like every other call site.
Security
Amazee.ai admin settings routes are no longer registered with the default configuration.routes/scolta-amazee.php (settings page, POST /scolta/amazee/trial, DELETE /scolta/amazee/disconnect, …) was registered behind config('scolta.amazee_middleware'), which defaults to ['web'] — no authentication. Any anonymous visitor could wipe the stored AI credentials via disconnect or bind a trial to an arbitrary email via trial; the README's "protect the route in production" note was the only safeguard. The routes are now registered only whenscolta.amazee_middleware is configured beyond the bare ['web'] group (e.g. ['web', 'auth']); with the shipped default they do not exist and requests return 404. Added AmazeeAdminRouteSecurityTest (booted-kernel feature tests): anonymous trial/disconnect requests 404 by default, a configured guard middleware blocks anonymous access, and a satisfied guard restores access. ⚠️ Upgrade note: if you use the admin UI at /scolta/amazee, set 'amazee_middleware' => ['web', 'auth'] (or your own guard) in config/scolta.php — with the previous default your routes were publicly reachable, so adding a guard is required anyway. CLI provisioning (artisan scolta:amazee:provision) and first-request auto-provisioning are unaffected.
scolta:download-pagefind now verifies the tarball against the upstream-published SHA-256 checksum before extracting. Pagefind publishes a .sha256 asset next to every release tarball; the command fetches it and fails closed — refusing to install — when the checksum cannot be fetched, is malformed, or does not match. Covered by DownloadPagefindChecksumTest.
The public follow-up endpoint now caps conversation payload size.POST /api/scolta/v1/followup validated messages.*.content with no max: while the sibling endpoints cap their inputs (expand 500 chars, summarize 50,000 chars), so an anonymous client could POST arbitrarily large conversation payloads straight into an LLM prompt. Validation now enforces 10,000 chars per message, 50,000 chars combined across the conversation (matching the summarize cap), and at most 25 messages. Added FollowUpValidationTest covering all three caps plus the in-cap happy path.
Internal
Block-ignored two new laravel/framework 11.x security advisories on the EOL Laravel 11 CI matrix row.PKSA-m5cs-t1y6-qpcs (temporary signed-URL path confusion, patched 12.61.1 / 13.12.0) and PKSA-3r5d-mb8f-1qw9 (CRLF injection in the default email rule, patched 12.60.0 / 13.10.0) were both reported 2026-06-17 with affected ranges covering all 11.x; Laravel 11 reached EOL 2026-03-12 so no 11.x fix will ship. Added to config.audit.ignore with apply: block (matching the existing [PERMANENT:laravel-11-eol] entries) so the resolver stops blocking the EOL 11 matrix row while composer audit still reports them. Supported 12.x/13.x rows already install patched versions and are unaffected.
CHANGELOG duplicate-header guard added to CI. A merge artifact had left two ### Fixed and two ### Changed headers under ## [Unreleased] (Keep-a-Changelog renderers then show two of each in cut release notes). The duplicated pairs are merged into one section each (no bullet content lost; Added / Changed / Fixed / Security / Internal ordering), and the antipatterns CI job now asserts the [Unreleased] section carries at most one of each ### sub-header so the artifact cannot recur.
Dropped the AiEndpointHandler PHPDoc shape-gap entry from the larastan baseline. scolta-php #208 added the missing limit/retry_after keys to handleFollowUp()'s @return shape, so the Offset 'limit' does not exist baseline entry no longer matches when CI analyses against scolta-php dev-main and reds the analyse job. Note: local analyse against the committed lock (scolta-php 1.0.3, which predates the docblock fix) reports that offset error unbaselined until the post-release lock bump; CI against dev-main is the authority.
Larastan ratcheted from level 5 to level 6 and tests/ added to the analysed paths. The 14-entry baseline is burned down to 6 entries: the CheckSetupCommand match expression gained a defensive default arm; ScoltaTracker::getPending()'s return PHPDoc no longer claims static; the Searchable trait is now analysed (used by test fixtures) with typed scopeSearchable() generics; the four controller isset.offset entries disappeared with the AiController extraction; and ~30 missing iterable-type PHPDocs were added across src and tests. The remaining entries are the BuildCommand/TriggerRebuild gathering methods (deleted by the content-gathering-unification PR), a larastan namespaced-view false positive in AmazeeSettingsController, and one upstream AiEndpointHandler PHPDoc shape gap. Level 7 jumps to 229 errors (mostly framework generics in tests) and is left for a future pass.