All notable changes to Arabic Slug Schema Guard are documented here. The format follows Keep a Changelog, and the project adheres to Semantic Versioning.
- Layer-1 drift guard. The canonical-schema rewrite now tolerates backticks,
spacing, case and any current width, then asserts the outcome rather than the
attempt. Previously, if core ever changed its
CREATE TABLEstring the rewrite failed silently, dbDelta shrank the column, and every long slug was truncated. It now logs, recordsasg_l1_status, alerts, and surfaces inwp asg verify. Until now only Layer 2, the recoverable layer, self-tested. - Safety clamp on slug generation. Layer 2 could mint a slug longer than the
live column, manufacturing the very truncation this plugin prevents. Core ships
wp_terms.slugatVARCHAR(200), so any site that widened onlypost_namehit this by default. The budget is nowmin( ASG_SLUG_BYTES, live_width - ASG_SUFFIX_HEADROOM ), resolved lazily on first use and cached in an autoloaded option, falling back to core's 200 only when the width cannot be read. ASG_SUFFIX_HEADROOM(default24), bytes reserved for a collision suffix.- A test suite (
php tests/run.php): the Layer-2 fork diffed against core's real function, the slug classifier, and theData OK/ blindness gate. No WordPress, no database, no dependencies. Each suite extracts the functions from the plugin file rather than copying them, so a copied gate cannot re-encode the blindness it exists to catch and pass itself. The fixtures are Arabic, and model a short column and a capped generator together, because every bug this plugin shipped stayed green until those conditions co-existed.ASG_PLUGIN=<file>points the suite at another build. The gate's isolation is established by a purity walk over its transitive call graph, tokenized rather than regexed, since a back door needs no parameter (so arity proves nothing), can hide one call deep (so scanning the gate's own body proves nothing), and/\bglobal\b/matches the word "global" in a comment. The walk requires the good rather than enumerating the bad: all nine superglobals are rejected (a closed set), any construct that cannot be resolved statically is a violation by virtue of being unverifiable ($f(),$m['g'](),new,::,->,static), and the only permitted calls are plugin functions, which it follows, plus a short explicit allow-list of pure builtins. Bare constant reads are covered by the same argument: once calls and object syntax are flagged, a bare identifier istrue/false/null, a call name, or a constant, so an allow-list over that closed set of roles is complete.PHP_INT_MAXpasses; a bareif ( ASG_KILL )does not. Earlier drafts denied$GLOBALSbut not$_ENV, deniedgetenvby name while$f = 'getenv'; $f()walked past, and admitted every PHP builtin throughisInternal(), which letsgetenv,constant,ini_getandfile_get_contentsthrough. The walk runs before the first call, so a back door that happens to fatal cannot pre-empt the assertion meant to catch it, and the suite's extraction list is derived from the walk, so "add the helper toasg_test_load()" is not an available repair for a suite that a delegating door has broken. asg_row_is_unprovable( $slug_length, $ceiling ), lifted out of the$wpdb-bound scan loop so a test can pin the invariant it carries:unprovablekeys on the slug length, not the suffix-stripped stem, which is what makes the two blindness clauses interlock. Re-keying it on the stem, the obvious-looking improvement, would quietly make the ceiling clause load-bearing again with no test going red.wp asg verify --data. The tripwire watches the column, never the slugs, so it inferred a data conclusion from a schema observation. Measured against a real stack, only a cut inside an escape fails loudly (nginx returns 400 before WordPress runs); a cut on an escape boundary and a cut on a character boundary both serve 200, because routing byte-matchespost_nameand never decodes it. An encoding-only scan therefore finds the class that already screams and misses both silent ones. The scan now reports corrupt slugs (broken escape or invalid UTF-8) and truncation candidates (the stored slug is a strict prefix of the slug its title regenerates to, gated on length). CLI only, exits non-zero on any finding. Candidates are not verdicts: a hand-shortened slug is indistinguishable from a truncated one, and rows whose titles changed never match, so the count is a lower bound. Repairing candidates moves live URLs, so it must go throughwp_update_post()for_wp_old_slug301 redirects, never a raw$wpdb->update().
- All constants are overridable from
wp-config.php.ASG_COLUMN_LEN,ASG_SLUG_BYTESandASG_SCHEMA_SIGwereconst, so adefine()collided instead of taking effect. They now usedefined() || define(). - The tripwire no longer spams. It never cached "ok" while a column was short,
so it re-ran the
information_schemaquery and re-sent the alert email on every admin page load. Checks are throttled to hourly, emails to daily. - The tripwire classifies instead of crying breach. A column that was never
widened is a setup task, and raising
ASG_COLUMN_LENis a configuration change. Only a column narrower than a previously verified width is reported as a revert. wp asg verifyexits non-zero on failure. It only ever logged, so it always exited 0 and no cron monitor could fire.- The cached column width is now invalidated on Layer-1 drift, not just on a core update. A stale-high width would otherwise let Layer 2 mint oversized slugs for an entire throttle window, precisely when dbDelta is about to shrink the column.
- The live width is resolved only for
'save'context. Display-contextsanitize_title()calls (CSS classes, ids) are never stored, so they no longer trigger aninformation_schemaquery on a cold cache.
- The Layer-2 fork had itself drifted from core, since 1.0.0. It was described as
"core with a bigger byte budget," but it dropped core's entire "strip these
characters entirely" block and the
×→xmapping, keeping only the nbsp / en-dash / em-dash / slash conversions. So slugs kept%c2%ab/%c2%bb(guillemets, the standard Arabic quotation marks),%e2%80%8e/%e2%80%8f(LRM/RLM), curly quotes,©,®,°,…,™, combining accents, zero-width characters and the BOM, none of which core keeps. The character filter cannot clean them up afterwards, because%c2%abis made entirely of permitted characters. The block is restored verbatim, and the drift fixtures now cover every branch of it. The Layer-2 oracle was sound and flagged this the first time it ran, which means itsadmin_initcheck had never executed against core on a real install. ASG_L2_FAILSAFEwas the least safe option available. On drift it handed generation back to core, whose 200-byte cut lands on a character boundary and yields a valid, wrong slug that serves HTTP 200 and never reaches a log: the one truncation class nothing detects. A drifted fork merely applies stale cleanup rules to full-length slugs. The default on drift is now to keep the fork and shout. The fail-safe stays opt-in, recordsasg_l2_failsafe_active, emails once (deferred toinit, sincewp_mail()does not exist at mu-plugin load and the alert was being swallowed), and failswp asg verifyuntil it is unset.--dataflagged any title ending in a number as truncated. The stem was stripped of a trailing-<digits>before the comparison, so…-1985(a year in the title) or…-2became a strict prefix of its own faithful regeneration. On a real Arabic corpus this read 8268 candidates against ~280 real ones, an 82% false-positive rate on the sampled rows, and it distorted the length histogram, which recorded post-strip lengths. The classifier now compares first and strips last: exact match, then match-plus-genuine- suffix, then unstripped strict prefix, and only then the truncated-and-suffixed case, at lower confidence. Histogram lengths are unstripped.--datacalled Arabic percentage titles corrupt. Arabic writes the percent sign before the numeral, so a title opening%80 …makes core's "preserve escaped octets" step keep%80as an octet. The slug opens on an orphan continuation byte and fails to decode, yet it is the generator's faithful, reproducible output and has lost nothing. Undecodable bytes are now only corrupt when they also differ from the regeneration; when they match it, they are reported as a generator artifact and do not fail the command. The malformed-bytes test is also unanchored now, so it catches a broken escape hiding before a suffix (…%d9%8-2), which an end-anchored regex misses.- The blindness gate was dead. The prefix test needs a generator that can out-run the
stored slug, but blindness was anchored to
--min-length: with core's 200-byte cap and the 190-byte gate,200 <= 190is false, so the guard never fired while every 200-byte row was unprovable. It is now anchored to a probe of the live generator (an over-long title, measured output), and refuses "Data OK" while any scanned slug sits at or above that ceiling. Anchoring it to the corpus maximum would have been wrong too, since on a healthy site the longest slug equals its own regeneration. The gate no longer keys onasg_l2_failsafe_activeeither: that flag is only one of several routes to a capped generator (a theme's own fallback, a future core change). Regeneration deliberately runs through the livesanitize_titlefilter chain, so a Layer-2-off site is compared against the generator that actually minted its slugs, not against this fork. --dataprinted "Data OK" directly beneath its own findings. The success line was gated on the command's accumulated$failedcompared against a sentinel captured after the schema check. Once a short column had already set$failed, that comparison readtrue === truewhatever the scan found, so "Data OK: nothing corrupt, no truncation candidates, no blind spots" printed under 81 corrupt rows, 3949 truncation candidates and a partial blind spot. The exit code was right throughout; only the text lied, and the text is the half a human reads. The gate is nowasg_data_findings( $scan, $min ), derived from the scan's own buckets and wholly independent of the schema verdict. Artifacts and trashed rows remain informational.--datanow also reports how many rows it scanned, the generator's measured ceiling, and how many trashed slugs it skipped.- The fail-safe was invisible to plain
wp asg verify. Its banner lived inside the--datablock, so the invocation cron actually runs (wp asg verify || alert) returnedSchema OKand exit 0 while Layer 2 capped every new slug at 200 bytes and truncated it silently. An alarm that only sounds when you pass an optional flag is not an alarm, and silent truncation is the one thing this plugin exists to prevent. The fail-safe and Layer-2 drift now both fail the default invocation. wp asg verifyevaluates the Layer-2 drift oracle itself instead of trusting theasg_l2_statusoption. The oracle is bound toadmin_init, so a headless site (WP-CLI, REST, nowp-adminvisits) never fired it and could never learn its fork had drifted. That is exactly how the fork bug above survived from 1.0.0 to 1.2.0.- Docs: on MySQL 8 in strict mode the one-time widening
ALTERfails withInvalid default value for 'post_date', because altering the table re-validates WordPress's legacy zero-date defaults. Clearsql_modefor the session, or let the next core upgrade widen the column, since dbDelta-driven widening is unaffected. - Docs: the
asg_layer{N}_enabledfilters cannot be used from a theme or a plugin. They resolve while the mu-plugin loads, and during a database upgrade WordPress loads neither, sincewp_get_active_and_valid_plugins()andwp_get_active_and_valid_themes()both return early onwp_installing(). Thewp-config.phpconstants are the only reliable switch. - Docs: both columns must be widened, and the collision caveats are now stated.
Posts are shortened safely by
_truncate_post_slug(); terms are overflowed unsafely bywp_unique_term_slug(); trashing a post cuts its slug to 191 bytes one-way. A real Layer 3 must therefore cover terms, not just posts.
- Per-layer switches:
ASG_LAYER1,ASG_LAYER2,ASG_LAYER3constants and matchingasg_layer{N}_enabledfilters, so any layer can be turned off independently (for example, keep Layer 1's wide column but let another plugin or theme own slug generation). Layers 2 and 3 depend on Layer 1 and fail safe (stay off, and log once) if requested without it. Layer 3 stays reserved: its switch resolves, but the de-duplication handler is not shipped yet.
- Renamed the repository, plugin file, and Composer package to
wp-arabic-slug-schema-guardto make its WordPress scope explicit. Install withcomposer require mantekio/wp-arabic-slug-schema-guard; the oldmantekio/arabic-slug-schema-guardpackage is superseded. InternalASG_/asg_names are unchanged, so existingwp-config.phpconstants keep working.
- README: documented the Composer install requirements. Allowing the
composer/installersplugin, and the note that awordpress-mupluginpackage installs into amu-plugins/subfolder, which vanilla WordPress does not auto-load without a mu-plugins autoloader. No functional changes.
- Documentation and inline-comment cleanup in the README and the plugin source. No functional changes.
- Layer-2 self-test: the slug-generation fork now diffs itself against core's
live
sanitize_title_with_dashes()on short fixtures (once per core version), so a future core rewrite of that function is caught immediately instead of drifting silently. ASG_L2_FAILSAFEconstant: on detected drift, fall back to core's generator (slugs cap at 200) until the fork is re-synced, rather than run a copy that is known to have diverged.
- The Layer-2 install is now conditional on the drift status, and the
asg_l2_statusoption is autoloaded so the fail-safe is read on every request.
- Initial release. A WordPress must-use plugin that stops core database upgrades
from truncating long Arabic (and other non-Latin) slugs:
- Layer 1 (prevention): a
dbdelta_create_queriesfilter that keepswp_posts.post_nameandwp_terms.slugatVARCHAR(1024), so dbDelta never emits a destructiveCHANGE COLUMN. - Layer 2 (generation): a copy of
sanitize_title_with_dashes()that raises the byte cap, so new slugs are not cut at 200 bytes. - Tripwire: post-update verification of the real column widths (logs and
optionally emails on a revert) plus a
wp asg verifyWP-CLI command.
- Layer 1 (prevention): a