test(service-cluster-redis): pin the resolved ioredis/ioredis-mock pair the inertness declaration names - #17522
Conversation
…ertness declaration names `redis.contract.test.ts` closes the `ioredis-mock@8` (peer `ioredis@^5`) vs `ioredis@^6` gap by measuring it and declaring it inert. The whole deliverable is prose, and that prose states its own expiry condition — either declared range moving, or either RESOLVED version moving under an unchanged caret. Nothing checked it, so a routine bump would leave a stale header still reading as authoritative. This pin keys on the resolved pair read off the installed tree (6.0.0 / 8.13.1), not on the declared range strings, because a lockfile-only bump moves the resolution under an unchanged caret and a range read cannot see it. The declared ranges are pinned as well, since the expiry clause names them too. The last assertion reads the declaration's own text and requires it to name the same pair, so editing the pinned constants alone cannot restore green. That is deliberate: a pin people learn to edit instead of re-measuring would be worse than no pin, and the failure message says so in the words of someone who has never read the original measurement. Claude-Session: https://claude.ai/code/session_01ToDPcx9AESFubJkDiFMtKW Co-authored-by: Claude <noreply@anthropic.com>
…redis-mock-pair-pin
📓 Docs Drift CheckNothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 0 changed package(s)), so this run has no opinion about the docs. What this run could not see
Coarse fallback — 0 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): |
|
ACCEPT — 2026-09-10T19:15Z, head Landing pre-checks, each measured:
What this seat checked beyond green:
Flipped ready and armed. ⛔ Not approved and ⛔ not merged by this seat; the merge queue lands it. Generated by Claude Code |
Fixes #15986
Clause-②: no
The route
The card presented three routes and called itself 「a policy call」; it then sat in
pm:awaiting-maintainerfor exactly that reason and the maintainer answered. Quoted, not translated:⇒ Route A, pinned. The "re-derive the overload intersection in CI" middle option is the road not taken and is not implemented here. One dissent is recorded at the bottom, for the record only — it did not change what was built.
What this adds
One new file,
packages/services/service-cluster-redis/src/ioredis-pair.pin.test.ts. Nothing else. No manifest change, no lockfile change, no dependency change, no CI change.It defends the inertness declaration at the top of
src/redis.contract.test.ts— the section## The double is one major version behind the client it doubles, whose entire deliverable is a paragraph of prose, and which states its own expiry condition:Four assertions, one per limb of that clause plus a coupling limb:
ioredisresolves to6.0.0ioredis-mockresolves to8.13.1^6.0.0(dependency) and^8.13.1(devDependency)package.jsonsrc/redis.contract.test.tsBoth reads stay inside the package (
src/to the package root, and a sibling source file), so neither is acheck:cross-package-test-inputsescape; that gate is green below.Which source the resolved pair was read from, and why that one
The installed tree —
createRequire(import.meta.url).resolve('ioredis/package.json')and the same forioredis-mock, then that manifest's ownversionfield.The refinement carried on the card is that the claim can expire with no range moving at all: a lockfile-only bump moves the resolved version under an unchanged caret, and a pin reading only the declared range strings stays green through exactly that. So the pin had to key on resolution. Two sources could answer that, and they are not equally honest:
pnpm-lock.yamlrecords what pnpm would install. (Its importer entry for this package readsioredis: specifier ^6.0.0 / version 6.0.0andioredis-mock: specifier ^8.13.1 / version 8.13.1(...), so it agrees today.) It is also a file another open PR currently holds, and reading it would make the pin a cross-package input needing its own declaration.import RedisMock from 'ioredis-mock'two files over resolves to, in the same process, at the same moment the suites run. The declaration's own words for the ioredis leg are "the version resolved in this workspace", and this is that number rather than a proxy for it.Ablation 1 below shows the difference is not theoretical: with the resolution moved and the caret range untouched, assertion 3 stays green and assertion 2 goes red.
Why assertion 4 exists
The card names the exact failure this design is aimed at:
Assertion 4 is the answer to it. The pinned pair must also appear in the declaration's own text, so editing the constants in the pin is not sufficient to get back to green — the header has to move too, and moving the header honestly means re-taking the measurement. Ablation 2 drives exactly that scenario and it stays red.
The failure message is the other half. Whoever sees this go red is mid-bump and has never read the original measurement, so the message tells them what expired, where it is written, and what the remedy is. Rendered output from ablation 1, verbatim:
RED before GREEN
GREEN, at the branch head, whole package:
os-verify-lock: VERDICT command-exit 0(the pin alone:4 passed (4),VERDICT command-exit 0).Ablation 1 — a simulated lockfile-only bump
The package-local
node_modules/ioredissymlink was replaced with a stub manifest declaring6.1.0, leaving the tracked caret range untouched. That is the move a range-only pin cannot see.On-disk proof, not an editor's exit code:
Result —
os-verify-lock: VERDICT command-exit 1:⭐ The three that passed include assertion 3, the declared-range limb. That is the measurement behind this PR's central design choice: a pin asserting only that the ranges still read
^6.0.0and^8.13.1would have been green on this run.Restored, and proven by state rather than by exit code:
Ablation 2 — the bump is "fixed" by editing the pin
The scenario the card is afraid of, driven end to end: the double bumps to
8.14.0(stub manifest behind the package-local symlink), the manifest range is edited to^8.14.0, the pinned constants are edited to match — and the declaration is left alone.On-disk proof, with the HEAD blob hashes recorded first:
Result —
os-verify-lock: VERDICT command-exit 1:Assertions 1, 2 and 3 all went green under the edit — and the pin still refused. Editing it is not a way out.
Restored by state, all four readings:
Both ablation scripts carry
traponEXIT INT TERMwith absolute paths, refuse to start on an empty HEAD blob hash, and restore withgit checkout HEAD -- ...rather than a baregit checkout --.Premises verified before writing anything
git grep -l ioredis-mockexcluding the lockfile anddistreturnspackage.json,src/kv.transaction.test.ts,src/redis.contract.test.tsioredisgrep returns 13 files, so the narrow grep is not silently emptyclosed/completed, labelstooling priority:p2 domain:skillsclosed/not_plannedClaim:names this branchClause-② — both limbs
files[]is["dist","README.md","CHANGELOG.md"], the package builds fromentry: ['src/index.ts']only, and after a build,ioredis-pair/PINNED/version pair pinhit zero times acrossdist/,README.mdandCHANGELOG.md— positive controlRedisKVhits 4 published files.⇒
Clause-②: no. This is test infrastructure and touches no published runtime surface, which is what the claim comment predicted.Changeset
skip-changeset, on the same measurement as Limb B: nothing that publishes moves.pnpm check:published-filesgreen.Gates
Derived from the actual diff with
node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstackon the merged tree (a220174a5): change set1 path(s) vs merge base 634f23de2, 53 commands. All 53 run, each exit code captured before any pipe.50 of 53 exit 0. Named among them:
check:cross-package-test-inputs,check:type-check-coverage,check:nul-bytes,check:published-files,check:test-source-alias,check:engine-double-contract,check:dts-closure,check:tier-file-adoption,check:type-source-resolution.3 exit 3 — PREREQUISITE NOT MET, i.e. NOT MEASURED, not a pass and not a failure:
check:dual-build-cjs-loads— "this gate reads built output, and some package has no dist/" (74 packages listed).check:lean-entry-closure— "this gate loads BUILT entry points, and some target is absent".check:type-check-debt— "--re-measurecannot run: 27 workspace dependenc(ies) ... have no built type entry point on disk".All three want a whole-repo build, which is CI's run on a shared box. Each is insensitive to this diff in principle — a
src/*.test.tsthat never reachesdist/cannot move a gate that readsdist/— but that reasoning is stated here as reasoning, and ⛔ is not rounded up into a green.Package-level, all under the shared verify lock, each quoting the lock's own verdict line:
pnpm --filter '@objectstack/service-cluster-redis^...' buildVERDICT command-exit 0pnpm --filter @objectstack/service-cluster-redis testVERDICT command-exit 0—37 passed (37)pnpm --filter @objectstack/service-cluster-redis typecheckVERDICT command-exit 0typecheckreally covers the new file, rather than being claimed to:tsc --noEmit --listFilesputssrc/ioredis-pair.pin.test.tsin a 410-file program alongside all three othersrc/*.test.ts.Lint — a declared narrowing, with its three pieces of evidence.
pnpm lintiseslint . --no-inline-config, a whole-repo scan CI owns. Run here instead: eslint on the one added file,0 errors, 0 warnings, exit 0.files: ['**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}']minusNEVER_LINTED, and the added file matches it (eslint linted it rather than skipping it).--format json:1.eslint.config.mjsand it never enables type-aware linting for any file —eslint --print-configon the added file returnsparserOptions = {"ecmaVersion":"latest","sourceType":"module"}, noprojectand noprojectService. With no type information crossing file boundaries, a new file cannot move the verdict on any untouched file.eslint.config.mjsrecords the same fact with its own positive control.All three present ⇒ this is a measurement, not a skipped run.
Acceptance notes
Out of scope, observed, deliberately not fixed here and not filed:
^5peer" is a registry fact, not a workspace one — no ioredis 5.x is installed here (pnpm resolves the mock's peer onto the 6.x copy). A newer 5.x release ages that leg with no local signal, and no offline pin can see it. Carrier: the limitation is written into the new file's header under "What it deliberately does NOT check", so the next person to bump either dependency reads it there. No card, because there is no defect and nothing to do.@types/ioredis-mock@8.2.7is in the tree, pulled in asioredis-mock@8.13.1's own peer (visible in the lockfile's resolution key). This confirms the reading already recorded on the card — present in the store, on no@typesdirectorytscwalks — and is ⛔ not a reason to revisit adopting it. Recorded, not filed.Dissent, for the record only
Having built it, I still think the middle option — re-deriving the overload intersection in CI — is the better long-run shape, for the reason the card's own follow-up gives: it measures the thing the claim is about and is immune to how the version moved. The ruled route was implemented as ruled; a seat does not silently re-rule a maintainer's answer, and this paragraph is a note, not a request.
Generated by Claude Code