Официальный сайт SLAED CMS
Журнал изменений
Batches 8, 9 and 10 of docs/UPLOAD-2026.md: admin upload moves onto the Upload class and the procedural pipeline is deleted, the 2026 format set goes live across every configuration and render path, and the final sweep proves nothing legacy survived. Two audits of that sweep are folded in, each of which found real defects rather than style.
Core changes:
- Admin upload and settings (admin/modules/uploads.php, config/uploads.php):
uploadsave() publishes local first and remote second through the class
- every limit comes from the all record, nothing is hardcoded at the call site
- the all record carries 104857600 bytes and 1600x1600 to match
configsave() validates extensions against Upload::getSupportedTypes()
- an unsupported extension is dropped, reported and never stored as enabled
- both extension fields show the supported list as a hint
- upload(), check_file() and check_size() are gone with their last caller
- The 2026 format set (config/uploads.php, config/filetype.php, config/files.php):
the canonical 21 formats replace the old 26
- ten formats withdrawn, each on its own evidence, two spellings corrected
- modules gain only webp and avif; the full set reaches typ and the all record
config/filetype.php becomes five family templates over 21 keys
- audio renders through audio, video through video, pdf through object
- the dead screens class is gone and the lightbox binds to sl-attach
- Upload class hardening (core/classes/upload.php):
images must decode, not merely parse a header
- a truncated png passed getimagesize() and was published before this
- a missing decoder answers unsupported instead of passing the file through
- the remote write callback rejects a short write instead of counting it whole
- the address policy blocks every IPv6 range IANA marks as not globally reachable
- the class owned naming grammar is matched at its fixed salt length
- Configuration writer (core/system.php, setup/index.php):
a value whose line would pass 180 characters is written as a concatenation
- the split is deterministic, so an unchanged save round trips byte for byte
- the installer copy follows, or a fresh install would write a different file
- Verification (tests/, tools/upload-route-check.php):
UploadIntegrationTest drives the resolver and the accessor against real config
- all fourteen module records survive resolve and serialize byte for byte
- the five adapters are held to the ordering rule of the plan
tools/upload-route-check.php walks 47 rows of the route matrix over real HTTP
- credentials come from the environment, TLS verification is on by default
- every scenario compares tree and database around itself and cleans up
Benefits:
- one publication primitive, one rule parser, one type policy, one lock location
- every publishing flow validates by content rather than by extension alone
- the acceptance of the plan is reproducible from the repository, not by hand
Technical notes:
- ext-gd joins ext-curl and ext-fileinfo in the composer require block
behavior changes: content stored with a withdrawn format renders as a plain link, a guest no longer sees historical files in the editor panel, and a build without a GD decoder refuses that image format instead of accepting it
nine other configuration files still hold over-long lines and will be rewritten in the wrapped format the first time each is saved from its own settings tab
stored reference formats are unchanged: avatar filenames, project relative _files.url values and the editor JSON shape all keep their existing contracts
setContentActive() still runs before the checked UPDATE of the admin files handler; that ordering predates this migration and is left to its own task
The last three findings of the backup and scheduler plan, plus the comment rule applied to the code that work produced. The plan document is removed now that every batch of it lives in the code.
Core changes:
- Crash reconciliation reports its own failure (system.php, scheduler.php):
updateSchedulerCrash() returns null when the repaired state cannot be stored
- unlock() shows the state directory instead of its success message
- addSchedulerRun() refuses to start rather than running an unrecorded job
- Two regression tests drive it through a read-only state file
- POST-only moderation (comments.php):
The six write handlers read their payload from the body alone
- typ is read once, so a valid typ=0 is no longer swallowed by ?: and
replaced by a crafted query value
- status, modul, search and chng lost their query fallbacks as well
- A source-level test asserts no handler reaches for get or req
- Isolated mail probe (mail_probe.php):
The disposable schema is registered for cleanup before any table is copied, so a failure during setup cannot leave it behind
- The class docblock states what the probe really works against
- Comment placement (system.php, helpers.php, comments.php, probes, tests):
- Comments moved out of function bodies into the headers above them
- Two functions that had no header comment received one
- Overlong comment lines shortened instead of wrapped
Benefits:
- An operator is never told a crashed job was cleared when it was not
- The admin boundary cannot be steered by the address bar
- The test suite no longer depends on the site mail queue
Technical notes:
- No storage or configuration format changes
Backward compatible: the removed query fallbacks were unreachable from the rendered forms, which have posted since the boundary was introduced
Batches 6 and 7 of docs/UPLOAD-2026.md. Every remaining visitor-facing publisher now reaches the filesystem through Upload::addUploadedFile() after its own method, token and business checks have passed, so a preview, a refused form or a failed row write can no longer leave a file nobody references.
Core changes:
- Account avatar (modules/account/index.php):
saveavatar() gates the whole operation behind POST and the account token
- the old line validated the token only when op arrived in the POST body,
so GET ?op=saveavatar&avatar=<preset> wrote users.avatar with no token
- the preset branch now sits inside that gate and still calls no upload
the upload publishes through the class and stores the returned filename
- the rule is built from the $conf['users'] avatar keys, maxquota 0
- adirectory loses exactly one leading uploads/ before it is passed on
- a failed profile write deletes the exact path this request published
- Avatar preset grid (templates/lite/fragments/table-row.html, theme.css):
- the is_avatar_link branch gained a form/button variant keyed on action
sl-avatar-link moved from the anchor onto the form and got the two rules that strip the button chrome; line-height: 0 keeps the row height intact
- Frontend file module (modules/files/index.php):
send() calls the class only when the save passed every check
- a file counts as submitted when its error is not UPLOAD_ERR_NO_FILE
- a refused file no longer falls back to the typed external URL
- the gate also reads $conf['files']['upload'], which the old code ignored
- the row write is checked and compensated against the returned path
- Admin file module (modules/files/admin/index.php):
save() publishes straight into the selected path field, else the configured one, so the publish-then-rename branch no longer runs for a new upload
- relocation of an already stored file with no new upload is unchanged
- preview and delete publish nothing at all now
both row writes are checked; a failure deletes the published path and logs a stranded file through Logger::addFile()
- Plan record (docs/UPLOAD-2026.md):
last completed batch, the decisions of batches 6 and 7, and what the route tests actually proved on the stand
Benefits:
- no upload path can publish before authorization, CSRF and validation pass
- stored size and path come from the class instead of a probed request path
- one publication primitive, lock-guarded and collision-free, for five flows
- the admin file handler writes each file exactly once
Technical notes:
stored reference formats are unchanged: users.avatar keeps the filename only and _files.url keeps the project-relative uploads/<dir>/<file> shape
Breaking: avatar presets are POST-only now, so a bookmarked GET link that set a preset stops working
Breaking: an operator publishing through the frontend file form no longer gets a name without an owner suffix; the flow stores the user id or 0
config/files.php still lists gzip, which the type map does not know, so a .gzip upload is refused until batch 9 corrects the spelling to gz
admin upload and the format switch are untouched; upload(), check_file() and check_size() stay until batch 8 removes their last caller
The procedural upload pipeline is being replaced by a single Upload class that owns validation, transfer, naming, quota and atomic publication for every publication flow. This lands batches 1 to 5 of docs/UPLOAD-2026.md: one configuration parser, the image pipeline the new formats need, the class itself with its local and remote halves, and the Toast UI editor moved onto it.
Core changes:
- Upload rule resolver (core/system.php, admin/modules/uploads.php, core/admin.php, core/classes/parser.php, core/helpers.php, plugins/editors/toastui/driver.php):
getEditorUploadData() renamed to getUploadRuleData() and widened to all twelve named fields
- every consumer reads named keys, so no call site splits the configuration string any more
- setUploadRuleData() assembles the same twelve fields and configsave() writes through it
- getUploadModuleList() derives the settings tab module list from the records themselves
- checkEditorUploadAccess() takes the resolved rule array instead of raw field indexes
- Image pipeline (core/system.php, core/classes/parser.php, templates/admin/assets/css/base.css, templates/lite/assets/css/base.css):
create_img_gd() renamed to getImageThumb() and its WBMP/IMAGETYPE_SWF branch removed
- IMAGETYPE_WEBP and IMAGETYPE_AVIF added behind function_exists(), so an older GD build degrades
- the image lists and the data-URI pattern accept webp and avif
- .sl-attach and its alignment modifiers added to both themes, built from existing spacing tokens
- Upload class (core/classes/upload.php, core/system.php):
new class with addUploadedFile(), addUploadedFiles(), addRemoteFile() and deleteStoredFile()
- the configured allowlist plus an explicit extension to MIME map; executable and web-active formats are refused whatever configuration says
- transfer into .upload-<hex>.part, then the destination lock, the stale partial sweep, the quota recheck, collision retry and one rename
- remote fetch over ext-curl only: address policy, redirect revalidation, pinned address, connect and total timeouts, header and streaming byte limits
- getUploadService() builds the single instance over UPLOADS_DIR and LOGS_DIR/uploads
- Editor adapter (core/system.php, index.php):
addEditorUpload() is a thin adapter over addUploadedFiles()
- the result codes map onto the existing language constants, so no new constant is introduced
- the owner is the site user id, no suffix for a privileged moderator and the guest value otherwise
- getEditorFileJson() answers a guest with an empty list instead of every file of the shared guest suffix
- the generic go=4 default answers 400 with the editor JSON instead of reaching the old upload()
- Tests and dependencies (tests/Unit/UploadContractTest.php, tests/Support/upload_probe.php, tests/Unit/ImageThumbTest.php, tests/Support/image_probe.php, composer.json):
- the contract suite covers every local result code, the naming grammar, the lock, quota boundaries, the partial sweep and every remote rejection path without opening a socket
- ext-fileinfo and ext-curl added to require, because the class fails closed without them
Benefits:
- one validation, naming and publication path instead of a rule copy per call site
- uploads are verified by content rather than by extension alone
- remote fetch fails closed for private, reserved, redirected and rebound targets
- concurrent publications to one directory can neither overwrite each other nor oversubscribe the quota
Technical notes:
- stored reference formats are unchanged: users.avatar filenames, project relative _files.url values and the editor JSON shape
- lock files live in storage/logs/uploads, an existing ignored tree; no new directory for user files
- behavior change: a guest no longer sees previously uploaded editor files and receives only the files of the current upload response
- the source path of an upload must stay exactly as the SAPI reported it, or move_uploaded_file() refuses it on Windows
- account avatar, both file modules and admin upload still run the procedural pipeline; batches 6 to 10 of docs/UPLOAD-2026.md migrate them and delete it
Replaces the procedural database dump with a Backup class that proves what it produced, puts the scheduler on an operating system lock with a defined crash state, and closes the admin mutations that were reachable by a plain GET carrying a credential.
Core changes:
- Database backup (core/classes/backup.php):
New Backup class replaces addBackupTask(), which is deleted without an alias or wrapper
- Preflight fails before any output: settings, compressor, backup root, staging, link() probe, privileges
- Export runs in one consistent snapshot, streams unbuffered, checks every write and fsyncs the dump
- The archive is decompressed again and compared against the dump before it may become an artifact
- Publication uses link(), which refuses to replace an existing name; the mode is set before linking
- Retention ages both naming schemes together and is off by default
Absence of an object class is proven by effective privilege, never inferred from an empty catalog
- Role-derived grants and MySQL partial revokes are resolved per vendor branch
- Views are counted through information_schema.TABLES, which needs no SHOW VIEW
- Scheduler protocol (core/system.php):
The operating system lock is the only authority on whether a job runs; JSON is status
- A crashed run is reconciled to a defined state and becomes due again without file surgery
- last_run survives reconciliation, so a crashed job resumes at its next slot
- A scheduled run re-checks its slot while holding the lock, so one slot cannot run twice
- A state that cannot be written refuses the run instead of reporting success
- Job state now carries the contract fields plus the metrics of the run that just finished
- Request boundary (core/security.php, admin/modules/*.php, index.php):
- checkAdminPost() requires POST and reads the token from the body, scoped to its module
- Fifteen admin modules migrated; row actions render through the dial POST contract
- The direct scheduler endpoint accepts only pseudo and cron, and the isAdmin() bypass is gone
- The pseudo trigger carries its credential in a body instead of an address
- Consumers and documentation (admin/modules/scheduler.php, monitor.php, admin/info/scheduler/ru.md):
- The list derives its state from the lock and the JSON together and shows a crashed job as such
- A running job shows live elapsed time instead of the previous run's duration
- The monitor measures published artifacts instead of the whole directory tree
- The help page gained the backup section, a cron example with a POST body and the storage deny rule
- Tests (tests/Unit, tests/Support):
- Pure contract tests for the class, integration probes against a disposable schema, lock protocol tests
- The mail probe now runs in its own schema, because claiming a batch took rows from the live queue
Benefits:
- A backup either exists as a verified, restorable artifact or fails before producing anything
- Two instances of one job cannot overlap, and a crashed one recovers without manual intervention
- An admin action can no longer be triggered by a link, a prefetcher or a link preview
Breaking changes:
- Cron must call the scheduler with POST; a GET invocation is refused
- addBackupTask() is removed; addSchedulerLock() and deleteSchedulerLock() are renamed
- addSchedulerTrigger() returns an array instead of a URL string
Three denormalised numbers and one denormalised pointer were maintained by whoever happened to touch them last. The comment counter is now recomputed rather than nudged, the forum tells one story about its last message wherever that story is written, and the two forum forms that wrote without a token no longer do. The nightly sweep that existed to notice the drift is gone with the drift it watched for.
Core changes:
- Comment counter recomputed instead of nudged (core/classes/comment.php):
setTargetCount() writes the live count in one statement, and it is the only place that statement exists
- updateCountDrift() loops over it rather than repeating it
- the visibility rule lives here once: published and not deleted
addTargetCount() queues the recompute after the response through addDeferredTask(), because the subquery is a locking read of the comment table and two visitors commenting on one target would wait on each other
updateTargetPoints() keeps the author points inside the transaction, where a rollback still has to take them back
- The nightly job is gone (config/scheduler.php, core/system.php, setup):
commentsync removed from the config, the dispatch map, the dispatch branch and the installer, which now takes it out of an upgraded site as well
what a write cannot reach is a target nobody comments on again; the first tab of the comments section reports those and repairs them on a click
- One answer for the last message of a forum section (core/system.php):
getForumLast() answers the newest visible activity of a whole branch, as the topic it belongs to; a reply used to be answered as itself
setForumLast() repairs whoever advertised a topic that has gone, matched on the stored value rather than walked from the category the request named
delete(), move() and the hide action call it; each of them used to decide on its own, from one leaf, and write that answer over every ancestor
the synchronisation tab asks the same function, compares before writing, and repairs the reply count of a topic, which nothing had ever recounted
- Forum forms carry a token (modules/forum/index.php):
- send() refuses without one, and the quick reply and the full form now hold it
- delete() and move() refuse as well
removal is submitted rather than followed, so it cannot happen on a prefetch: the dial renders a form for it and the button owns it through the form attribute, which keeps the fan layout intact
- the quick edit link stopped carrying its token in the address
- Quick edit is the editor it was written in (core/helpers.php):
getTplAjaxTextarea() renders the real editor with an id carrying the record, because the page it lands on already holds an editor of its own
- the forum message edit opens at ten rows like the comment edit
- Defects found on the way:
updatePost() appended to a string, which is fatal in PHP 8, and measured the last word in bytes rather than the longest in characters
is_acess() asked is_moder(isset($conf['name'])), a boolean, so an administrator of the module fell through to the visitor check
- the alert component wanted lines in one theme and messages in the other
Benefits:
- A counter that drifted is corrected by the next comment on its target
- A section advertises the newest message of its branch, not of one child
- No forum write happens without the token of the page that offered it
- One definition each for the comment count and for the last message
Technical notes:
The recompute runs after the commit, so a lost deferred task leaves the count one behind until the next write rather than blocking a visitor
- Removal follows the recycle setting: a topic moves rather than disappears
The completed plan documents were removed; twenty-seven citations of them remain in surviving documents and test headers
Stages 4 and 5 of the comment redesign land together with the machinery that keeps them honest: replies grow real branches, deletion leaves a tombstone while a living reply hangs under it, and the denormalised comment counter on every target gains a sweep that repairs drift instead of letting it rot. The four help pages the panel serves were rewritten against the code rather than against memory, because every one of them described a version that no longer exists.
Core changes:
- Threaded discussion (core/classes/comment.php):
getBranch() answers one branch whole, so a reader may open a capped thread
- getTreeRows() caps replies per root and reports what it withheld
- getParentPath() resolves a parent inside the target it belongs to
- MAXDEPTH bounds a branch at twenty levels
- deleteUser() detaches a departed author without erasing the discussion
- getBodyFormat() is private again; nothing outside the class chose a format
- Counter drift (core/classes/comment.php, core/system.php):
getCountDrift() compares every stored counter against its live count
- the visibility predicate lives in one place: published and not deleted
- a target with no counter and no comment is never examined
updateCountDrift() recomputes inside the UPDATE, so a comment written between the report and the repair cannot be overwritten by a stale figure
- addCommentTask() dispatches the sweep and names the modules it repaired
- Scheduler job (config/scheduler.php, core/system.php, setup/index.php):
- commentsync runs nightly, writes nothing when every counter agrees
- the installer seeds the job so an upgraded site is not left without it
- Manual sweep (tools/comment-recount.php):
- report shows the drift, fix repairs it, both over the same class
- Targeted schema patch (setup/sql/update6_3_patch.sql):
one file for a site already running 6.3: thread columns, their indexes and the path backfill, the admin and user column types, the counter sweep
guarded by procedures that add a column or an index only when it is absent, so the file may be run twice without a second thought
- Panel help rewritten (admin/info/*/ru.md):
- comments: threads, tombstones, capped replies, soft deletion, counters
- newsletter: the queue tab, the eight campaign states, the real policy fields
- config: the mail section, transport, queue and the address a reader sees
scheduler: all seven system jobs with their keys, defaults and schedules
- Mail delivery was missing entirely, and nothing in the panel said that
the site stops sending mail when it is off
- Newsletter was documented as disabled by default, which it is not
- headings match the house scheme; the decorative icons are gone
Benefits:
- A wrong counter is now found and repaired rather than displayed forever
- An upgraded site can be patched from the admin panel, without the installer
- The help a reader opens describes the version they are running
Technical notes:
- Storage: pid and path on _comment, ascii_bin, ten-digit padded segments
The counter counts published, undeleted comments; a tombstone is excluded while the visible replies below it are counted
- The thread patch is idempotent and carries no destructive statement
Backward compatible: comments written before the upgrade stay roots, and no attempt is made to rebuild branches out of quoted names in their text
Stages 4 and 5 of docs/COMMENTS-REDESIGN-2026.md. A comment action answers the one comment it touched instead of repainting the whole list, every mutation is a POST with its token out of the URL, and a comment may now answer another one: the reply is stored as a parent id and a sortable path, the page paginates root comments and carries each root with its branch, and a removed comment that still has a live reply stays as a tombstone.
Core changes:
- Fragment responses (core/user.php, core/system.php, index.php):
addComment() answers one comment fragment and names its own placement
- HX-Reswap and HX-Retarget decide afterbegin, afterend or the status zone
- a full slice sheds its far-end row out of band, branch included
- a pending comment answers the moderation notice, never a row in the list
- getCommentView() renders one stored comment for the list and for a response
addComment, updateCommentStatus and deleteComment are POST only, refused in one place before any handler runs
- deleteComment is a new frontend route
- status and delete name their swap in the response, so a refusal removes nothing
- updateComment() writes its refusal instead of returning it into a discarded value
- the form clears only on a stored comment, through HX-Trigger and the shared script
- Tokens and idempotency (core/user.php, core/helpers.php, plugins/system/slaed.js):
no comment action carries a token in its URL any more
- the form carries a hidden token from getPageToken()
- the moderation actions inherit one X-CSRF-TOKEN header per comment
- getTplAjaxTextarea() moved to the same shape, which takes the forum editor too
- a comment that offers no action declares no token at all
- the idempotency key is minted in the browser and renewed only after a stored comment
- Reply threads (core/classes/comment.php, setup/sql/table.sql, setup/sql/table_update6_3.sql):
- pid and an ascii-binary path of ten-digit segments, with two indexes for them
getParentPath() refuses a parent of another target, a removed one, one the writer cannot see and one already at twenty segments
- getList() counts and paginates roots and answers each with its whole branch
- getBranch() answers one branch with an explicit limit
a removed comment with a live reply stays as a tombstone, by predicate rather than by a stored flag
- existing comments become roots of their own and nothing is ever re-parented
- Pagination transport (core/user.php, templates/lite/fragments/link.html):
- the rows moved into a container of their own and the pager renders after it
- one control appends the next page and replaces itself with the answer
- its href is the ordinary page URL, so a reader without HTMX follows a plain link
getCommentPage() answers rows and the next control, and refuses a page past the last instead of answering the last one a second time
- Links that name one comment (core/classes/comment.php, core/user.php):
- getRootPage() answers the page a comment is rendered on
the notification and the other-page notice carry at=<comment id>, which setComShow() resolves, so an anchor no longer dies when newer comments arrive
- Upgrade defects found and fixed on the way (setup/sql/table_update6_3.sql):
_users dropped network in the same file that modified it, so a re-run failed and discarded thirteen other column changes with it
_admins.editor was declared BOOLEAN while table.sql defines VARCHAR(32): it failed on any installation carrying an editor name, and would have destroyed those names had it ever passed
- _users.points was declared twice and the second declaration undid the first
- table.sql aligns points to NOT NULL DEFAULT 0
- Page cache (core/classes/comment.php, index.php):
- the five writes of the class invalidate after their own writes succeeded
- a refusal, a no-op transition and a replayed key invalidate nothing
- the ajax router no longer decides invalidation by route
Benefits:
a comment action costs one fragment instead of a 51 KB list repaint, and the reader keeps scroll position and focus
- no token is baked into a URL or served to a reader who cannot act
- a link to a comment resolves for as long as the comment exists
- a discussion carries structure instead of a naming convention
- an upgrade no longer discards its own column changes without saying so
Technical notes:
_comment gains pid INT UNSIGNED and path VARCHAR(255) ascii_bin plus modul_cid_pid_time and modul_cid_path; the backfill runs before the indexes
a fresh install and the upgrade produce an identical table definition, rehearsed twice against the pre-6.3 shapes
SchemaUpdateValidationTest compares column definitions and rejects a column declared twice with two definitions
- four new language constants in all six locales; _PCOPEN removed with its last reader
CommentTransportTest (15 cases) and CommentThreadTest (10 cases) both fail against the pre-change tree
comment pages stay canonical to their target page, so pagination adds no indexable URLs
Stages 3 and 4 of docs/MAIL-2026.md. A mailing stops being a list of addresses rendered into a select and posted back, and becomes a criterion the scheduler expands into the queue in resumable slices, guarded by a suppression registry, a canary slice and a circuit breaker. The queue it writes into gains the admin view that was missing, so an installation can no longer accumulate mail silently.
Core changes:
- Campaign state and the suppression registry (setup/sql/table.sql, setup/sql/table_update6_3.sql):
{prefix}_newsletter carries the campaign state machine
- status, audit, apar, cursor, expect, total, fails, note
- mails is dropped, after setup/index.php has moved anything still pending into the queue
{prefix}_maildead added, keyed by the normalised address
- email uses a binary collation so case folding lives in the normaliser alone
- delcol joins the idempotent migration helpers beside addcol and addidx
- Delivery, verification and campaign control (core/classes/mail.php):
checkAddress() runs the ladder: syntax, recorded outcomes, cached domain resolution
- every step fails open, so no check can become a precondition for delivery
failures are classified by the phase that produced them
- only a permanent verdict at RCPT TO may touch the suppression registry
- a transport-wide refusal stops the run instead of spending an attempt per row
setCampReady(), setCampFree(), setCampAbort() and the circuit breaker
- the sample is drawn one address per recipient domain and never releases the rest
- getStats(), getList(), setQueueRetry() and deleteQueueRows() serve the queue view
- Audience producer (core/system.php):
- updateNewsletter() expands one criterion in slices under a time budget, writing its cursor
- getMailAudience() and getMailAudienceNum() resolve a criterion to its query and its count
- addAdminMail() no longer rewrites _admins.modules; the admin screen owns that write
- addMailTask() reports a transport-wide refusal as a failed run
- Admin screens (admin/modules/newsletter.php, admin/modules/config.php):
- the audience selector stores a criterion and never a list of addresses
- the queue tab filters by kind and status and offers retry and delete
- every state change is a POST carrying checkSiteToken()
- the mail tab gains the verify and dnsttl rows
- Documentation and locales (docs/, admin/lang/*.php, README.md and the root documents):
- both stages recorded in the plan with their decisions, defects and open blockers
- 21 constants added to all six admin locales
- root documents drop their "last updated" lines and state the 8.4 requirement
Benefits:
- a mailing of any size stores one row per recipient and one copy of its body
- a damaged list is measured before it is sent to rather than after the damage
- a failed message is visible, retryable and explains itself in the operator's words
- a queue behind a stopped drain now says so on the screen that lists it
Technical notes:
- config/newsletter.php holds campaign policy alone; count and active are gone
- the newsletter scheduler job is now the producer and ships active
- the fresh schema and the upgraded schema were diffed and are identical
- breaking: {prefix}_newsletter.mails is removed, so the 6.3 upgrade must run first
Both plans keep their scope and gain the contracts their reviews found missing: the backup plan pins the scheduler boundary it depends on, and the upload plan pins the storage contracts its callers already rely on.
Core changes:
- Backup plan (docs/BACKUP-2026.md):
the scheduler boundary is specified rather than assumed
- every state-changing action is POST; GET only displays state
- browser actions validate the SLAED CSRF token, cron authenticates by bearer
token in the body and never through the URL
- an OS flock keyed by the canonical job name owns a run; JSON status and
heartbeat are diagnostic only and unlock may not break a live lock
dbbackup.settings defaults are synchronized across both config sources, and the 6.3 upgrade fills only missing keys
the nine migration helper procedures are a release prerequisite; Backup fails closed while they exist and never deletes schema objects
- schema fingerprinting excludes AUTO_INCREMENT counters and table statistics
- scheduler row actions render through the real dial fragment
- the verification matrix names upgrade, route, concurrency and restore tests
- Upload plan (docs/UPLOAD-2026.md):
deleteStoredFile() joins the public surface for the orphan case
- a database write that fails after publication removes that exact result
- containment and the destination lock are re-checked on the delete path
paths are root-relative on both ends, and the stored name keeps the owner suffix the editor ownership filter reads
- go=4 stays as the editor route with two explicit cases and rejects the rest
DNS resolution, cURL execution and the clock sit behind replaceable internal methods so the security cases are testable without a network
Benefits:
- both plans can now be handed to an implementer without a second review round
- the backup plan no longer depends on scheduler behaviour nobody wrote down
- the upload plan states the contracts a caller would otherwise rediscover
Technical notes:
- documentation only; no code, schema or configuration changes
- neither plan is started, and both keep their no-compatibility-wrapper rule