fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(service-storage): the storage/test probe cleans up in the store it wrote to - #13921

Merged
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup
Aug 31, 2026
Merged

fix(service-storage): the storage/test probe cleans up in the store it wrote to#13921
os-steve merged 2 commits into
mainfrom
claude/issue-13726-storage-probe-cleanup

Conversation

@claude

@claudeclaudeBot commented Aug 31, 2026

Copy link
Copy Markdown
Contributor

Closes#13726

The settings.registerAction('storage', 'test', …) handler writes a probe object, reads it back and deletes it. When the form posts values it builds a temporary adapter first — so an operator can validate credentials that are typed but not yet saved — and probes that adapter instead of the persisted one. Two paths left the probe object behind in whichever store it was actually written to. Both were surfaced by the #12981 batch-7 dev inside the lines that batch edits, and correctly left alone there; re-verified by symbol on this branch's base (46b53a25b), since PR #13725 moved the line numbers.

Defect 1 — the failure cleanup deleted from a store the probe never wrote to

let target: IStorageService = proxy was declared inside the try, so at the catch the only name in scope was proxy, the persisted adapter. On the case the temporary adapter exists for — a failed test with edited credentials — the object leaked in the store that held it while a delete was issued against a store that never did. Deleting an absent key is a no-op on both shipped adapters, so the wrong-store delete "succeeded" and nothing looked wrong.

Repaired by resolving the adapter before the try whose catch has to clean up after it. That makes "the cleanup names the store the upload named" true by construction rather than by two expressions that happen to agree today.

Defect 2 — the content-mismatch return path cleaned up nothing

Reaching the round-trip comparison means the upload already succeeded, so the object is definitely present — and the return walked straight past the delete on the very next line. A guaranteed leak, not a best-effort one, on the one path that by construction only fires when the adapter is misbehaving. It now runs the same best-effort cleanup as the failure path.

The two judgement calls

1. "target may be unassigned, or may be the very thing that threw." The card is right that a delete against a half-built adapter is its own question, and the answer here is that the question is dissolved rather than answered: buildAdapterFromValues already had its own inner catch that returns, so a construction failure never reached the outer catch — but the reader could not see that from the catch, because the declaration was out of scope there. Hoisting the resolution above the try states the invariant where it can be read: past that point target is either proxy or a fully constructed temporary adapter. A construction failure still returns before anything is written, and on that path no cleanup is attempted at all — nothing was written, and a delete would have to name an adapter that does not exist. That decision is pinned (as a declared control, below), so a later "tidy-up" that adds a finally reddens it.

2. Where defect 2 cleans up. A finally was rejected: the success path already deletes on its own line, and its failure IS a probe failure the operator must be told about, so a finally would either delete twice or need a flag to know not to. "Leave it for inspection" was rejected because nothing records the key — it is minted per call from a timestamp and a random suffix — so the object would be un-findable litter rather than evidence. The mismatch path therefore calls the same cleanup helper the catch does, immediately before its return.

Built on batch 7, not over it

#12981 batch 7 made a refused cleanup name the key it left behind. That repair is intact — it moved into the shared removeProbeObject helper, warn level and message text unchanged — and it now covers the mismatch path too, which it could not reach before because no cleanup was attempted there. One pin drives exactly that case.

What did NOT change

No sweep for the __objectstack_probe__/ prefix — out of scope by the card. What the probe reports to the operator is unchanged on every path: the same result shape and the same messages, pinned in every case below. No published surface changes and no accept/reject behaviour moves.

Verification

All numbers from head 11c802f5d.

Pins — packages/services/service-storage/src/storage-service-plugin.probe-cleanup-store.test.ts

Every store in the file is a realLocalStorageAdapter on its own temp directory with exactly one verb replaced (via Object.create, so every other member stays the adapter's own). PUT allowed / GET refused is the ordinary shape of a half-right credential and it is what makes the leak observable: the bytes really land on disk, then the probe really fails. The assertions read the filesystem — what is left under __objectstack_probe__/ when the handler returns — rather than a call counter that could agree with a store nobody wrote to.

casekind
failed probe with edited credentials leaves nothing in the TEMPORARY store (and the persisted store is neither written to nor asked to delete)pin
failed probe with no edited credentials leaves nothing in the PERSISTED storedeclared control
an adapter that fails to build is reported, and no cleanup is attempted anywheredeclared control
a mismatch on edited credentials leaves nothing in the TEMPORARY storepin
a mismatch with no edited credentials leaves nothing in the PERSISTED storepin
a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatchpin

The two controls are green in both directions by construction — with no overrides target === proxy, so the old code deleted from the right store by accident, and the old code also attempted no cleanup after a build failure. They are declared as controls in the file's header and are not ablation evidence; they are there so the pins cannot pass on a handler that deletes from everything, or on one that cleans up after a store it never wrote to.

Ablation

Predicted before the run: reverting only storage-service-plugin.ts reddens the 4 pins and leaves the 2 controls green.

Procedure: the repair was committed first (c80f33a90); the mutation reverted the single file to its pre-fix blob in the worktree only; an EXIT INT TERM trap restored it with an absolute path; the mutation was confirmed on disk before measuring, by blob hash and by marker counts, never by an editor's exit code. No rebuild is in the loop: the pins import the module under test by relative path within the same package, so vitest reads the source .ts, and this package's vitest.config.ts carries no alias that could route it to dist/.

== blobs: HEAD(fixed)=56697fe055842a08f8f46703478e1bfefb57f373 HEAD~1(pre-fix)=51f623729c21dd6824d69cccc3be1b843779f4c7
== markers BEFORE mutation (working tree = the repair)
removeProbeObject: 3
proxy-as-IStorageService-delete: 0
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373
== MUTATE (revert the handler to the pre-fix blob; worktree only, index untouched)
hash-on-disk: 51f623729c21dd6824d69cccc3be1b843779f4c7
removeProbeObject: 0 (expect 0 — the repair's helper is gone)
proxy-as-IStorageService-delete: 1 (expect 1 — the wrong-store cleanup is back)
== MUTATED RUN
mutated-run-exit=1
× … defect 1 … a failed probe with EDITED credentials leaves nothing behind in the TEMPORARY store
✓ … CONTROL: a failed probe with NO edited credentials leaves nothing behind in the PERSISTED store
✓ … CONTROL: an adapter that fails to BUILD is reported, and no cleanup is attempted anywhere
× … defect 2 … a mismatch on EDITED credentials leaves nothing behind in the TEMPORARY store
× … defect 2 … a mismatch with NO edited credentials leaves nothing behind in the PERSISTED store
× … defect 2 … a mismatch whose cleanup is REFUSED names the stray key, and still reports the mismatch
Test Files 1 failed (1)
Tests 4 failed | 2 passed (6)
== RESTORE
hash-on-disk: 56697fe055842a08f8f46703478e1bfefb57f373 (expect 56697fe055842a08f8f46703478e1bfefb57f373)
removeProbeObject: 3
git diff HEAD -- target: []
git status --porcelain -- target: []
== RESTORED RUN
restored-run-exit=0
Test Files 1 passed (1)
Tests 6 passed (6)

The mutated failures are the leak itself, named on disk:

AssertionError: expected [ '1788193993158-fbkl7y8s' ] to deeply equal []
AssertionError: expected [ '1788193993188-8cske18x' ] to deeply equal []
AssertionError: expected [ '1788193993190-d0e7dm5m' ] to deeply equal []
AssertionError: expected [] to have a length of 1 but got +0 (the refusal warning that is never logged, because no cleanup is attempted)

Direction observed = direction predicted. The restore leg is proven by state — hash back to the HEAD blob, empty git diff HEAD, empty git status --porcelain — and re-measured green afterwards, so no later reading was taken on a mutated tree.

Suites and gates

  • pnpm --filter @objectstack/service-storage exec vitest run --maxWorkers=235 files, 528 tests passed (the whole package, including The durability log-level gate cannot see the catch { return null; } seeder family — 15 files outside #12923's five, and neither widening path is cheap #12981 batch 7's own file).
  • pnpm --filter @objectstack/service-storage exec tsc --noEmit51 errors, zero of them in either changed file. That is exactly this package's frozen DEBT ledger count, so check:type-check-debt's re-measure cannot move up on this diff. Run with the dependency closure built (pnpm --filter '@objectstack/service-storage^...' build), which is the ledger's own measurement condition.
  • Gate family re-derived from the actual diff with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack (both output sections read whole; the family is identical at the final head). All 36 harvested commands run; 32 green, and 4 report PREREQUISITE NOT MET, i.e. NOT MEASURED, never a pass and never a red: check:test-completeness (exit 3 — grades a saved turbo test log, none exists locally), check:dual-build-cjs-loads (exit 3 — needs a full pnpm build), check:i18n and check:type-check-debt (both need a built workspace closure; both refuse rather than measure). CI builds the workspace and runs all four.
  • The slot-lookup erasure ratchet did not move; the gate's own verdict line: ✓ slot-lookup ratchet holds: 106 unswept site(s) in 25 file(s), none new, and every file in the population parsed.
  • ESLint: declared narrowing to the two changed files rather than the repo-wide pnpm lint, which CI owns and runs regardless. pnpm exec eslint --no-inline-config --format json on both paths — 2 files linted, 0 errors, 0 warnings; both were accepted into eslint's own population rather than ignored (they are returned as results). The narrowing excludes nothing: this repo runs one eslint.config.mjs which never enables type-aware linting for any file (no parserOptions.project, no typed rules — stated and measured in the config's own header), so no rule's verdict on an untouched file is a function of this diff. Independently, check:slot-lookup executes ESLint across all of packages/ with the baseline's ignores lifted, and holds.

Changeset

.changeset/storage-probe-cleanup-target-store.md, patch on @objectstack/service-storage: the behaviour an operator can observe does move — the probe no longer leaves objects in the store it wrote to — even though no API surface does.

Generated by Claude Code


Generated by Claude Code

… wrote to
The `settings.registerAction('storage', 'test', …)` handler builds a temporary
adapter when the form posts values, so an operator can validate unsaved
credentials, and probes that adapter instead of the persisted one. Two paths
left the probe object behind.
Defect 1: `let target` was declared inside the `try`, so at the `catch` the
cleanup could only name `proxy` — the persisted adapter — while the probe had
written to the temporary one. Deleting an absent key is a no-op on both shipped
adapters, so the wrong-store delete "succeeded" and nothing looked wrong. The
declaration is now resolved before the try, which makes "the cleanup names the
store the upload named" true by construction, and settles the half-built-adapter
question: a construction failure returns before anything is written, so no
cleanup is attempted on that path.
Defect 2: the content-mismatch `return` walked past the delete on the next line,
after an upload that had by definition already succeeded — a guaranteed leak on
the one path that only fires when the adapter is misbehaving. It now runs the
same best-effort cleanup, which carries #12981 batch 7's refusal warning to this
path for the first time.
What the probe reports to the operator is unchanged on every path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016ZC5rNQj3WEet5HAmmAkMs
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)
  • a page that states a rule by its inputs shares no identifier with the emitter that implements the rule, so an emitter-only diff cannot list it — not on this run and not on any run. Measured on fix(driver-sql): emit varchar(maxLength) for a text field a declared index keys on #11430: content/docs/protocol/objectql/types.mdx documents the text-family column mapping by the ObjectQL type names it maps FROM (text / textarea / html) while the diff changed createColumn; it went unlisted, and it was the page that diff falsified, in four places. No shared token exists to detect this on, so a rule your change carries has to be re-read by hand in the pages that restate it.

Coarse fallback — 6 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): node scripts/docs-audit/affected-docs.mjs --json 4642f4c64c002f94f1d737bbb2a5fc94ae43ddf3packageMentionDocs.

Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/lteststooling

Projects

None yet

2 participants

@os-steve@claude