Changed
- Re-wrapped standalone prose comments throughout
classes/to keep every physical line within column 80 without changing comment meaning or executable tokens, removed vertical alignment from PHPDoc parameter columns, and disabled the conflicting Squiz parameter-spacing requirements in PHPCS (#59, #60).
Fixed
- The restricted-path deny-list now covers the editor-dropping and modern OpenSSH key shapes ADR-0011 already claims, and checks both a selected path's submitted spelling and its resolved identity at create and packaging time (#37, ADR-0029). An innocuously named symlink can therefore no longer package
wp-config.php, and a file replaced after validation fails hard rather than becoming a skip.api_versionstays7: this is the existing loud refusal applied more completely, not an artifact-contract change or an undetectable client misinterpretation. - ADR-0026's open client check is answered: the installed client freezes the
201response'sskipped_files, so a mid-run skip makes its strict unseal fail after downloading the completed extraction.api_versionnevertheless stays7because no already-shipped client population needs ADR-0018's interlock; Kntnt/kntnt-wp-skills#78 is the client-side fix that prevents the failure and must be installed before this widened-strictbuild reaches production (ADR-0030). strict: falsenow covers a file that vanishes during packaging, not only one that was already gone when the job was created (#31, ADR-0026, and a dated addendum on ADR-0003). The mitigation was applied once, at create time, so the gap it closed was the one between theGET /fileswalk and thePOST— while the much larger gap, between thePOSTand the last chunk, stayed fatal. That is the gap that ended a production run at 97.8 % after six hours, with 186 of 186 tables and 47,504 of 48,559 files already packaged and every byte of it discarded at fail time (docs/measurements/2026-08-18-production-run.md§3). Astrict: falsejob now drops a file that is gone when its chunk is packaged, appends it to the sameskipped_filesthe create reports, and carries on;strict: trueis unchanged, an out-of-bounds path is still never a skip, and a file that exists but cannot be read is still an error in both modes. A file that vanishes between two of its own parts also still fails the job, deliberately: its earlier bytes are already in the container, and skipping there would publish a silently truncated file thatdocs/container-format.mdgives a reader no way to detect. What this does not fix: it does not make the failed run recoverable, it does not stop a file from vanishing, and — the one that matters to a caller — it does not make a vanished file's absence harmless to the copy. The file is still missing from the artifact; the caller is still told which, and must still act on it. This widens where thestrict: falsecontract applies, not what it guarantees. It also leaves the per-part cost that made that run six hours long (§1 of the same measurement) untouched.Status_Controller::HONOURED_BEHAVIOURSgainspackaging_skips, so a caller can tell a build whose skip reaches the whole run from one whose skip stops at create time.strictandskipped_fileswere both already on the list against a build where a mid-run deletion still killed the job, which is thestrictdiscoverability failure ADR-0017 exists to close, one layer up.API_VERSIONstays7and the job record'sSCHEMA_VERSIONstays8.
Added
- A packaging tick can time the phases it spends its wall clock in (#39), so the ~229 ms an ordinary small file costs per chunk on the measured production host can be attributed rather than bounded. Across ~48,500 files that is about three hours, and since #27 lowered the file-part default it is very nearly the whole run — yet nothing had ever said where it goes, because the build could not see inside a tick. Define
KNTNT_EXTRACTOR_PHASE_TIMING(or return true fromkntnt_extractor_config_phase_timing) and every completed chunk records, separately, the container's open or resume, the path resolution and itsstatcalls, the source file's open-read-close, the seal, the container's suspend, and the record-split save — with the chunk's owntotalas the denominator the unattributed remainder is read off. The separation is the point: the leading hypothesis is filesystem latency, and a single per-chunk number can neither confirm that nor kill it. It is off by default and a run that did not ask for it reads no clock at all — no timer is constructed, and the job record is byte for byte what it was. The series is the attempt log's shape (ADR-0016): the newest eight chunks, onstate.jsonand projected ontoGET /extractions/{id}astimings?, so it never grows with the selection, and it holds no path, no SQL and no secret. Timing is a debug surface rather than part of the artifact contract, soapi_versiondoes not move and the behaviour is named inhonoursastimingsinstead (ADR-0017). It measures and changes nothing else: what the numbers turn out to mean, and what to do about it, is #30's. - A golden-artifact check on the sealed container's byte-compatibility claim (ADR-0025). ADR-0014 asserts that an artifact produced by this release is byte-compatible with one produced by 0.5.1, and
docs/container-format.mdrecorded that nothing here could check the sentence.composer gatenow does:tests/Fixtures/container-0.5.1.b64is a container the 0.5.1 writer produced,tests/Support/Sealed_Reader.phpis a reader of the format written fromdocs/container-format.mdalone and never shipped, andtests/Integration/golden-container-test.phpopens the fixture with it, drives today's writer over the same recipe, and compares the two. What it compares is the framing — the version byte, the total length, the index length and the ordered sealed-key and ciphertext lengths, which is every byte of a container that is not a sealed key, a nonce or a ciphertext — and it also asserts the two are not the same bytes, because those three spans are random and must differ. §5's reassembly rule and five reader refusals are pinned besides. The fixture's bytes are never regenerated: every expected number is computed from the recipe by the specification's own arithmetic, no digest of the fixture is recorded anywhere, and no regeneration script is committed, so re-cutting the blob cannot turn a red test green — it can only move which assertion is red. A red golden test means the code stopped honouring the format, or the format moved deliberately and owes a second fixture. No production code changed, not a line and not a comment. No REST change. docs/release-procedure.md§9 makes a site's pinnedchunk_sizeoverride visible before a release is installed on it, and §8's install sequence runs it at step 2. The step exists because this project's only completed clone depended on a value the plugin did not ship: 256 KB, held on production as a filter onkntnt_extractor_config_chunk_sizeinside a Perfmatters code-snippet collection, materialised as an mu-plugin — a place no file search of the site's plugins reaches and nothing in this repository knew about. Now that ADR-0023 ships that same 256 KB, such a pin is a second copy of the number that will silently override any future change to the default, so the step has an override matching the shipped default removed and one deviating from it justified in writing or removed. It names both hiding places — awp-config.phpdefine and a filter, the latter possibly in a snippet manager's collection — and directs the reader to the part count rather than to any configuration report: the config seam is constant-then-filter, so a snippet registered after the filter has fired reads as configured while not being in force, and the 36 MB filedocs/measurements/2026-08-19-chunk-size-curve.mdwas measured on is 139 parts at 256 KB against 9 at 4 MiB. Reporting the resolved chunk size over REST was considered for this and rejected on that ground (#32). No REST change; no code change.- ADR-0024 records that this plugin carries no backwards compatibility with its own earlier releases below 1.0 — no migration, no tolerance branch for a record an older release wrote, no cleanup routine for what one left behind, and no deprecation cycle. The ground for it is a fact ADR-0015 assumed the opposite of: the plugin is installed on exactly one site, operated by the author, and distributed to nobody else, so the retirement condition ADR-0015 called uncheckable "because it ships to sites the author does not operate" is decidable after all. This does not touch the sealed container's byte format or the REST contract, whose lifecycles ADR-0017 and ADR-0018 govern and whose coordinated-release requirement with
kntnt-wp-skillsrests on a different fact — that the client is a separate program with its own cadence, so an installed copy can be older than the server it is pointed at. Compatibility with a concurrently-running client is a real constraint; compatibility with a record this plugin itself wrote three releases ago is not. The decision is revisited on reaching 1.0, and must be revisited before the plugin is ever installed anywhere the author does not operate, since a population of one is its whole ground.
Changed
-
create()'s runaway docblock line is wrapped and its lost-race branch comment breathes (#48).create()'s docblock was wrapped throughout except for one line of 113 columns — a sentence appended to a wrapped paragraph without re-wrapping it — and the lost-race branch put its fourteen-line paragraph comment straight onto the opening{, while thereturnbelow it kept the blank line its sibling paragraph is entitled to. Both breakagents.d/coding-standard/general.md, which calls the paragraphing rule "the most central in this standard", andcomposer gatesaid nothing about either, becausephpcschecks neither: comment width has no sniff and no sniff knows what a paragraph is. Comments and whitespace only — every token of the file is unchanged, verified by diffing the comment and non-comment token streams of both revisions, so no executable line is touched. The ticket's first acceptance criterion had to be amended before this could land: as written it demanded that no comment line in the file exceed 80 columns, a whole-file criterion its own Out-of-scope clause forbade acting on, and one no revision of the file has ever satisfied. The file-wide re-wrap it implied is now #60. -
The scaffolded coding standard is resynced from
kntnt-code-skills, three modules deep (Kntnt/kntnt-code-skills#59).agents.d/coding-standard/'sgeneral.md,php.mdandwordpress.mdhad all drifted from the plugin that owns them and were carrying superseded text — most consequentiallygeneral.md's Line wrapping rule, still the single vague sentence ("Comments wrap at column 80. Code may go wider…") that upstream had already replaced with three explicit rules. That stale sentence is what #48 quoted and built an unsatisfiable criterion on, so the drift was not cosmetic. The resync brings in the three-rule form plus upstream's new statement of what column 80 is measured on — the whole physical line, from column 1, indentation counted, a tab advancing to the next multiple of the module-declared display width (wordpress.md: Tabs (display as 4 cols)), nothing in column 81 or beyond — and its last-resort exemption for docblock tag lines whose type shape cannot be broken without changing what a static analyser reads.php.mddrops its old 120-column code cap and its duplicated comment rule in favour of a pointer to the general module;wordpress.mdgains the Plugin and theme headers section (the header block is exempt from the comment-width rule, becauseget_file_data()parses one field per line and silently truncates a wrapped value) and a phpcs / WPCS ruleset section. That ruleset section names six required exclusions where this project'sphpcs.xml.distcarries ten; the four extras are supersets or paragraph-rule consequences, each already argued in place in that file, so the ruleset is ahead of the doc rather than in conflict with it. Applying the now-unambiguous measure toclasses/for the first time finds 2,241 over-long prose comment lines, tracked in #60. -
Every citation pointing into the implementation-plans directory from a file that outlives it is repointed or dropped, so retiring that directory breaks nothing (#57). The pointers move; nothing they argue does.
docs/adr/0018's Consequences and this file's own 0.6.0 entry now namedocs/release-procedure.md§8, "The coordinated case", where #54 wrote the cross-repo ordering out; the three settled steps in the same entry'sdocs/release-procedure.mdbullet resolve to §7 and §8 alone, two of the three having landed in the same section;docs/adr/0020names the audit that carved out the half it closes rather than the file that recorded the carve-out, the validation order itself being ADR-0003's; anddocs/measurements/2026-08-19-successful-run.mdnames plan 018 the way the sentence beside it already did, that plan having landed as #31 and ADR-0026. What made the removal costly was two records existing nowhere else, and the larger is now ADR-0028: the 2026-08-16 decision that a refused create discloses nothing about the occupying job — the evidence that every 429 anyone has met is the client's own sequencing rather than contention between administrators, the client's deliberate hard stop that never reads the body, the privacy analysis that keeps owner identity out and leaves ADR-0012's uniform refusal intact, the explicit finding that enriching the refusal would move noapi_version, and the two triggers that would reopen it. It is that decision restated in an ADR's form, with none of its reasoning changed.tests/Integration/extractions-test.phpcites the ADR where it used to cite the directory, which is the whole job of that comment: to say why the test asserts a bare ceiling refusal and nothing about the occupant. §10 of the release procedure no longer records inlining-and-repointing as an alternative considered and rejected, because it is what has since been done, and names where each record that would have been lost now lives. No REST change; no code change. -
No instruction in this repository is written in terms of one particular client any more (#56), and the convention that says so is now written down in
docs/release-procedure.md§1, so the mentions that remain are not re-opened later as an oversight. #54 and #55 moved §8 and two test comments off that framing; a sweep of everything outside the implementation-plans directory found three residues, all of them instruction rather than record.AGENTS.md's pointer atdocs/container-format.mdnamed the reader that document should be read against; §8's worked example opened by naming the client that pins a ceiling today; andtests/Fixtures/README.mdnamed it as the other half of the coordinated release a format move obliges. All three now name the property that makes the rule bite — a reader, a client that pins a verified ceiling — and point at the documents where the name legitimately lives. What stays is deliberate, and §1 states which two kinds it is.docs/adr/andCHANGELOG.mdare the historical record: ADR-0003 names a consumer in order to reject its domain knowledge, calling this a plugin meant to be a standalone product regardless of that consumer, and ADR-0018 names one because a real client defect is what justified anAPI_VERSIONmove — rewriting either would make the record untrue, so not a word of an ADR or of an earlier changelog entry is touched.docs/container-format.md§1 and §10 name the only production reader because that is a fact about the world: this repository ships a writer and no reader, and the reader lives elsewhere whatever this one would prefer.CONTEXT.mdnames it twice, in its opening description and under Verified ceiling, each time as an instance of a term and not its definition. Those four are the whole of the expected set, and a mention outside them is a regression to fix rather than a precedent to follow. Documentation only — no code, no test assertion, no fixture and no REST change;api_versionstays7. -
Two comments in the integration suite named
kntnt-wp-skillsas the caller, although nothing intests/depends on that name — no assertion, no fixture, no helper (#55).tests/Integration/sealed-writer-test.php's docblock said segments are recovered "exactly as the caller (kntnt-wp-skills) will"; what that round-trip proves is a property of the container format, so the docblock now grounds it indocs/container-format.md(§4's reading algorithm, §6's cryptography), which specifies it normatively and is what any reader is written against.tests/Integration/poll-progress-error-test.php's AC6 comment attributed one of the bumps that movedapi_versionpast1to "the kntnt-wp-skills trio (GET /environment and its siblings)"; it now names the endpoints that bump actually added —GET /environmentandGET /extractions, alongside thetables_structure_onlyrequest field they shipped with as one coordinated cutover — the endpoints being the fact, and who asked for them not. Both comments still do the job they were there for: the first says what the crypto seam is for, the second why the assertion reads the currentapi_versionrather than a frozen1. The second's stale claim that the version "reads 2" is corrected in passing, the assertion beside it having read7since ADR-0018's bump. Comments only — not an assertion, fixture or helper moved, and no production code changed. No REST change;api_versionstays7. -
docs/release-procedure.md§8 states the coordinated release as a rule about any client that pins a verified ceiling rather than as a case about one client (#54). The rule comes first and generically: a client pinning a ceiling refuses to run at all against a server above it, so anAPI_VERSIONmove decided under §4 leaves the release incomplete until every such client has released its raised ceiling and both sides are installed — with what that ordering requires now written out, since it was carried only by an execution record in the implementation-plans directory, since removed (#58). Merging this plugin's change is never blocked, only installing it where an extraction run is pending; a client's ceiling is raised last, because raising it is that client's declaration that it has been verified against the new version; and an install counts wherever a client runs, which need not be the site. One client stays as the one instance — unnamed there, an instance of that rule and not its definition, with the name left todocs/container-format.md§1 and §10, where it legitimately lives — and the 6 → 7 move as the one worked example, traceable now to ADR-0018 and to this file's own 0.6.0 entry rather than to the other repository's file names, function names and commit hashes — none of which this repository can verify, and any of which a rename over there turns into a confident, wrong sentence over here that no check in this repository can catch.AGENTS.md's pointer follows the new framing, and §10's record of why the ordering was left there says what has since moved. The install sequence and the manual wp-admin install are unchanged, in substance and byte for byte;docs/container-format.mdis untouched. No REST change; no code change. -
The integration suite no longer shares one
Table_Dumperacross every test file (#40). The suite runs every*-test.phpin a single PHP process — deliberately, because that is what makes it fast — andPluginused to build the whole driving stack at load: oneDispatcher, oneArtifact_Builder, oneTable_Dumperfor the life of the process. Every per-request thing that dumper memoised was therefore suite-long, reachable by files with no relationship to each other, and that hazard is why plan 007 dropped its step (c) rather than fix the harness.Pluginnow builds none of that at load. The driver is a factory:Extractions_Controllerasks for one per REST request, and the watchdog cron event builds itsWatchdog— and the driver under it — when the event fires. ATable_Dumpertherefore lives exactly as long as the request that drives, which is what it already meant in production, where the process boundary made the point for free. The suite still runs in one process, and its runtime is unchanged: measured on this change's own branch, 910 assertions in 6 min 28 s on this machine, against 906 in 6 min 20 s before it — under two per cent, which is the two extraction chunks the new pair drives and run-to-run noise, not the wiring. Both figures are that branch's suite and not this release's, which is larger: #31 and #39 each added a test file of their own into the same release.tests/Integration/request-scope-fill-test.phpandrequest-scope-probe-test.phpstate the property as a number rather than as an assurance — the first drives one chunk through the plugin's own watchdog binding, which fills the catalog memo, and the second counts theSHOW TABLESan equivalent chunk asks for in the next file. One means that file's dumper read the catalog for itself; zero would mean it inherited the listing from the file before. No existing test was rewritten: not an assertion moved, and both edits to existing tests are comments — one intable-chunking-test.phpthat described the shared dumper as a standing fact of the harness, which it no longer is, andwatchdog-test.php's scheduled-hook comment, which named the plugin-wiredWatchdogthis change replaced with the closure that builds one, and the driver under it, when the event fires. Those two are this change's own and not the release's, which also carries #31's comment-only rescoping ofbounded-state-file-test.php— its docblock, its AC2 heading and the comments around it — to the skip that ticket added, again with no assertion moved. Production behaviour is unchanged — a request drove with one driver before and drives with one now — except that a page load which drives nothing no longer builds a driver at all, andWatchdog::register_schedule()is static, being a function of two constants with no watchdog left to hang off. One other suite-long singleton with per-request semantics sits in the same construction path and is deliberately left alone:Update_Checker::$checker, a private static the plugin fills at load and whose own docblock calls it build-once "within one request" — in the suite that is build-once for the whole process. Nothing observable rides on it (it memoises no site state and answers no test's question), so it is named here rather than changed, so the next person does not have to find it twice. No REST change. -
ADR-0015's pre-adaptation back-compatibility cluster is gone, in one piece (#52, ADR-0024, and a dated addendum on ADR-0015). That ADR built a resume path for a stall an earlier release stranded, a TTL-sweep carve-out sparing exactly that record, a reseeded sealed index, and a family of deserialisation branches that could read a pre-0.6.0 write — and said the whole cluster had to retire together or not at all, once a condition it could not check held. The condition is settled by decision instead (ADR-0024), and the cluster has retired together:
Sweeper::reclaimable()'s carve-out,Extraction_Job::is_pre_adaptation_stall(),Dispatcher::resume_failed()and the predicates that served it,Sealed_Writer::seed_index()and its call site,Build_Progress::$legacy_names, andExtraction_Job::$budget_keys_presentare removed rather than merely unused.failedis now terminal in both directions: it frees the concurrency slot, and no tick, watchdog, or other path re-enters it intorunning. A failed record is reclaimed on the ordinary TTL and absolute-lifetime windows, with no exemption of any kind — which is also what closes #41: the carve-out was wider than the resume it protected, so a legacy record stalled where nothing can shrink (a structure-only table, or the sealed index) was spared forever and re-driven by nobody. Deserialisation now understands the current schema and no earlier one, and a record it cannot parse degrades quietly — skipped, with the records around it still enumerating — so one stale file can break neitherGET /extractionsnor the sweep for the jobs beside it. Nothing hunts down what an older release left on disk: there is no migration and no cleanup routine, and the sweep's ordinary windows are the whole disposal story. The adaptation itself is untouched — a stall still asks the host for room, still halves only the bounds the dead chunk spends, and still fails only at the floor.API_VERSION,SCHEMA_VERSIONand the sealed container's byte format are unchanged: this removes what a release can read, never what it writes. -
Table_Dumperasked the database two questions on every slice of every table —SHOW TABLES, to prove the name is real, andSHOW KEYS FROM …, to find the columns to page by — although the slices of one tick are one request and neither answer can change under them in a way the dump would have survived anyway. Both are now read once per request and held on the dumper instance, which whatever drives builds for that request alone — the plugin builds none of it at load (#40) — so a table carried across a hundred slices of a tick costs one of each rather than a hundred. The suite counts them rather than taking the claim on trust: a 250-row fixture at a 100-row budget dumps in three slices and issued threeSHOW TABLESand threeSHOW KEYSbefore this change and one and one after, asserted through WordPress'squeryfilter with the slice count in the same breath so the saving cannot be handed back unnoticed by a later refactor. Removing them is safe because the memo is an optimisation and never an authority: a hit is answered from it, and a miss — the first call of a request included — re-reads the live catalog before anything is accepted or refused, so a name is still accepted or refused against aSHOW TABLESthis request took from the database, exactly as ADR-0003 requires. A refusal is in fact decided on a listing read after the name was seen, where a per-slice read could return one taken before it. The rejected alternative — refusing on a miss — would have made the memo the allow-list and denied a table that came into existence after the first read; the integration suite creates fixture tables in exactly that window, and a case that pins it is now part of the suite. What this buys is a count of round trips, and that count has not been converted into a time. Nobody has timed aSHOW TABLESon the production host, so no millisecond figure is claimed here or in the ADR. One guard is narrowed by one scope, and now has the first test this project has given it.dump_chunk()refuses a cursor whose arity no longer matches the table's primary key; with the key memoised, a key that changes between two slices of one tick is no longer caught by that check, while a change between two ticks still is — every tick being a separate request with a dumper of its own, which is the guarantee a resumed build actually rests on (ADR-0003's addendum). No REST change;api_versionstays 7 and nothing joinshonours, since a caller can observe none of this and has nothing to opt into. -
The PHP coding standard no longer requires
@sinceon a new symbol below 1.0, and the release procedure's §4 now says when theVersion:header moves. The two were one fault.@sincenames the release a symbol first shipped in, but that number is derived from the changelog at release time, so nothing during a cycle can know it — the header names the version last released for the whole of a cycle, and reading a forthcoming version off it yields one that has already shipped. Stamping it anyway cost thirteen re-stamps and six verification rounds on the 0.7.0 cycle, for a tag nothing in this repository reads,phpcsdoes not check, and no behaviour depends on. Version history is also bookkeeping for exactly the compatibility question ADR-0024 defers. Existing stamps are correct for the releases they shipped in and are left alone; the requirement to document the why and the contract on every symbol is untouched.