refactor(core,server): manager-layer boolean + debug logs for tenancy-scoped delete observability#18855
Conversation
DatasetsManager.get and DatasetsManager.delete used to look up a dataset by its primary key alone, so any caller who knew the id could read or delete it regardless of tenant. This pushes the tenancy predicate into the storage layer via an optional filter (option (a) — no fetch-then-assert). - Storage contract: getDatasetById / deleteDataset and item-mutation inputs accept optional filters. All adapters (inmemory, libsql, pg, mysql, mongodb, spanner) honor the predicate when set. - Core: DatasetsManager.get / .delete accept flat organizationId / projectId args, stash them on the returned Dataset, and forward on every downstream storage call (getDetails, update, item ops, startExperimentAsync). - Server: GET / PATCH / DELETE /datasets/:id accept organizationId and projectId query params. - Client: getDataset, updateDataset, deleteDataset accept optional tenancy. - Tests: shared _test-utils tenancy suite (auto-runs against every adapter), DatasetsManager unit tests, and server handler tests. On tenancy mismatch, get throws NOT_FOUND (returns null at the storage layer) and delete is a silent no-op — matching how a missing id already behaves, so existence does not leak through error timing or messages. Legacy calls that omit tenancy are unchanged, so this is fully backwards-compatible. MASTRA-4438 Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
… updateDataset test, single-item deleteItem plumbing Follow-up on the initial MASTRA-4438 patch, addressing CodeRabbit + audit findings: - Dataset handle now gates unscoped read paths (getItem, listItems, listVersions, getItemHistory, listExperiments, getExperiment, listExperimentResults, updateExperimentResult, deleteExperiment) with a private #assertScope() that runs a scoped getDatasetById preflight and throws DATASET_NOT_FOUND on miss. Closes the last known handle-level cross-tenant read gap. - DeleteDatasetItemInput now carries optional filters; base deleteItem enforces a parent-existence gate identical to batchDeleteItems, so a leaked itemId cannot delete an item from a dataset in another tenant. Updated inmemory + libsql/pg/mysql/mongodb/spanner adapters to the new signature (parent gate in base means adapter bodies unchanged). - Shared _test-utils/datasets adds three scoped-updateDataset contract tests (cross-tenant NOT_FOUND, matching-tenant apply, mixed-tenancy sanity). Runs against every adapter via createTestSuite. - Sharpen changeset wording: reads/updates throw NOT_FOUND (404 over HTTP), deletes silently no-op on tenancy mismatch. - Server handler test for cross-tenant delete rewritten to reflect the silent-no-op contract (returns success, row untouched). - Verified: @mastra/core 266 tests + tsc --noEmit; @mastra/libsql 817 tests (includes shared tenancy + new update-scope tests); @mastra/server 11 handler tests + check:permissions + check:core-imports. Route metadata + route types regenerated (no diff — additive optional query params). Related: MASTRA-4438 Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Address CodeRabbit round-2 review on MASTRA-4438: - Dataset.getItem / getItemHistory / getExperiment / listExperimentResults now verify the returned child row's parent datasetId matches this.id before returning. Prevents a caller with a valid handle to dataset A from reading item/experiment rows belonging to dataset B via a caller-supplied child id. - Dataset.updateExperimentResult / deleteExperiment throw NOT_FOUND on ownership mismatch, matching the existing deleteItem contract. - Dataset.listItems now forwards this.#scope via ListDatasetItemsInput.filters instead of an extra preflight roundtrip. - Server handler tests: assert HTTP 404 status on scope-mismatch (not just HTTPException) and add projectId-mismatch cases for update + delete routes. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
…p tests Address CodeRabbit round-3 review on MASTRA-4438: - #assertExperimentOwnership now calls #assertScope() first, so stale scoped handles cannot bypass the per-operation tenancy check on listExperimentResults / updateExperimentResult / deleteExperiment. - Narrow Dataset.updateExperimentResult's input type to require experimentId (kept the runtime guard for JS callers). - Tighten ownership-failure tests to assert MastraError.id (EXPERIMENT_NOT_FOUND / EXPERIMENT_RESULT_MISSING_EXPERIMENT_ID) instead of only the error class. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
…sultById
ExperimentsStorage.getExperimentById and getExperimentResultById used to
look up experiments (or experiment results) by their primary key alone, so
any caller who knew the id could read the row regardless of tenant. This
pushes the tenancy predicate into the storage layer via an optional filter
(option (a) — no fetch-then-assert), mirroring how MASTRA-4438 fixed the
same class of leak for datasets.
- Storage contract: getExperimentById / getExperimentResultById now accept
an optional { organizationId?, projectId? } filter. All adapters
(inmemory, libsql, pg, mysql, mongodb, spanner) honor the predicate.
- Dataset handle: Dataset.getExperiment and the shared experiment-ownership
gate forward this.#scope, so experiment reads and downstream mutations
reached through a dataset handle (list results, update result, delete
experiment) are automatically scoped to the owning tenant.
- Tests: shared _test-utils tenancy suite grows experiments coverage and
auto-runs against every adapter; inmemory + Dataset.getExperiment unit
tests added.
On tenancy mismatch the storage layer returns null — matching how a
missing id already behaves, so existence does not leak through error
timing or messages.
Legacy calls that omit the filter are unchanged, so this is fully
backwards-compatible.
MASTRA-4445
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
…Results
Extend MASTRA-4445 tenancy scoping to the two ExperimentsStorage delete
paths. Prior to this commit, deleteExperiment({id}) and
deleteExperimentResults({experimentId}) keyed on the primary id alone,
so any caller that knew an id could delete the row regardless of tenant.
This is the same class of leak MASTRA-4438 already closed for datasets.
Both deletes now accept an optional filters: { organizationId?, projectId? }.
On mismatch the delete is a silent no-op, matching how a missing id
already behaves so existence does not leak through error timing or
messages. The deleteExperiment cascade to results and the standalone
deleteExperimentResults bulk delete are both gated on the parent
experiment matching the caller scope, so a leaked id cannot wipe
another tenant results.
Adapters updated the same way as the getter fix:
- inmemory: reuse the tenancy filter check helper
- libsql, pg: use a shared tenancyWhere helper (extracted to
stores/{libsql,pg}/src/storage/domains/utils.ts; datasets impls
refactored to import it too so we do not keep two copies drifting)
- mysql: gate via operations.load({keys: {id, organizationId, projectId}})
- mongodb: shared applyTenancyFilter extracted to
stores/mongodb/src/storage/domains/utils.ts; datasets impls refactored
to import it
- spanner: reuse this.getExperimentById as the tenancy gate before the
cascading transactional delete
Dataset.deleteExperiment now forwards this.#scope alongside the existing
#assertExperimentOwnership check, so the storage-side delete is
defense-in-depth if a leaked handle or race bypasses the assertion.
The shared _test-utils experiments tenancy suite now covers:
- deleteExperiment silent no-op on mismatch (row + results preserved)
- deleteExperiment succeeds and cascades on match
- deleteExperiment backward-compatible when filters is omitted
- deleteExperimentResults silent no-op on mismatch
- deleteExperimentResults succeeds on match (parent experiment kept)
Legacy calls that omit filters are unchanged, so this is fully
backwards-compatible.
Related: MASTRA-4445
Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
# Conflicts: # packages/core/src/datasets/dataset.ts # stores/libsql/src/storage/domains/datasets/index.ts # stores/libsql/src/storage/domains/experiments/index.ts # stores/mongodb/src/storage/domains/datasets/index.ts # stores/mysql/src/storage/domains/datasets/index.ts # stores/pg/src/storage/domains/experiments/index.ts
…city Removes TOCTOU pattern of `SELECT id WHERE tenancy` -> `DELETE WHERE id` across all 5 store adapters. Delete now folds the tenancy predicate into the destructive statement itself: - pg: single scoped DELETE, cascade via scoped subquery - libsql: same, via batch() implicit transaction; adds buildScopedWhere helper in shared utils - mysql: RW transaction with SELECT id ... FOR UPDATE, then scoped cascade DELETEs - mongodb: tenancy filter applied to deleteMany (not only pre-check) - spanner: gate + cascade folded into a single runTransactionAsync, tenancy predicate in both DELETE DMLs Also: - Shared TenancyScope aliased to core DatasetTenancyFilters | ExperimentTenancyFilters so shared helpers stay in sync with the authoritative shape - Clarified base ExperimentsStorage JSDoc so downstream implementers know success does not imply a row was deleted and the tenancy predicate must live in the destructive DML - Added code example to the changeset Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Applies the same TOCTOU fix from experiments deletes to the datasets adapter deletes that shipped with MASTRA-4438: - pg: gate + cascade folded into a single transaction with SELECT ... FOR UPDATE, parent DELETE scoped by tenancy - libsql: tenancy predicate folded into the parent DELETE inside the batch (single-writer serialized, so check + DML cannot race) - mysql: gate moved inside the RW transaction with SELECT ... FOR UPDATE, parent DELETE scoped by tenancy - mongodb: tenancy filter applied to the destructive parent deleteOne (not only the pre-check) - spanner: gate + cascade folded into a single runTransactionAsync, parent DELETE scoped by tenancy Also drops 7 e2e LLM recording JSONs under packages/core/__recordings__ that accidentally rode along on the merge from main (they were removed on main in commit 575bc70 and reappeared in this branch's merge commit). Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Replace the "run best-effort DML, swallow errors" pattern in every deleteDataset that has to detach experiments from a dataset. That pattern was unsafe inside a Postgres transaction: when the experiment tables aren't installed alongside datasets, the first DELETE raises 42P01 "relation does not exist", Postgres puts the connection into 25P02 "current transaction is aborted", the JS-level try/catch swallows the original error, and every subsequent DELETE in the same tx (including the dataset row itself) fails, so the whole transaction rolls back and the dataset is never removed. Spanner has similar semantics — once a statement inside a read-write transaction fails, the tx is no longer a safe place to keep issuing DMLs. libsql and mysql are safer (single-writer autocommit, and ER_NO_SUCH_TABLE doesn't abort InnoDB), but running DMLs speculatively and swallowing errors is still fragile. Same shape everywhere now: probe the experiment tables once against the schema catalog before the destructive block (`to_regclass` on pg, `INFORMATION_SCHEMA.TABLES` on spanner/mysql, `sqlite_master` on libsql), then only run the detach DMLs when both tables exist. Nothing inside the transaction throws for "table missing" anymore. Also strip trailing HTML tags from `.changeset/loud-planes-scope.md` that would otherwise land in the GitHub release notes. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
…SDoc Document that when 'filters' is omitted, implementations MAY skip the tenancy predicate entirely (backward compat: callers explicitly opt out of scoping). This matches the observed divergence across adapters — pg/mysql/spanner take an unscoped fast path while mongodb/libsql fold the (empty) filter unconditionally — both are correct under the contract. Related: MASTRA-4445 Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Adds a debug-level log at the storage layer when a tenancy-scoped operation silently no-ops on a mismatch. Getters return null and deletes affect zero rows by design (throwing would leak existence via error timing/text), so operators had no signal when a scoped call was behaving unexpectedly. Covered ops: - getDatasetById, deleteDataset - getExperimentById, deleteExperiment - getExperimentResultById, deleteExperimentResults The log carries op, table, and an opaque 8-char correlation token derived from sha256(id + ':' + organizationId + ':' + projectId). Raw id and tenancy are never logged. Emission is gated on filters being tenancy- scoped so unscoped calls that return null on a legit 404 don't emit noise. Two shared helpers on the storage domain (logTenancyReadMiss, logTenancyDeleteNoOp) plus isTenancyScoped gate; wired through inmemory, libsql, pg, mysql, mongodb, and spanner adapters. No behavior change to any storage contract. Related: MASTRA-4454.
🦋 Changeset detectedLatest commit: d108562 The changes in this PR will be included in the next version bump. This PR includes changesets to release 23 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
WalkthroughThis PR changes ChangesTenancy-miss observability
Estimated code review effort: 3 (Moderate) | ~25 minutes Possibly related PRs
Suggested labels: Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Comment |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
1 Skipped Deployment
|
PR triageLinked issue check skipped for core contributor @NikAiyer. PR complexity score
Applied label: Changed test gateChanged tests failed against the base branch as expected. Label: |
…oss-observability-for-silent-tenancy-mismatch-no-ops-in # Conflicts: # packages/core/src/storage/domains/experiments/inmemory.ts # stores/libsql/src/storage/domains/datasets/index.ts # stores/libsql/src/storage/domains/experiments/index.ts # stores/mongodb/src/storage/domains/experiments/index.ts # stores/mysql/src/storage/domains/datasets/index.ts # stores/mysql/src/storage/domains/experiments/index.ts # stores/pg/src/storage/domains/datasets/index.ts # stores/pg/src/storage/domains/experiments/index.ts # stores/spanner/src/storage/domains/datasets/index.ts # stores/spanner/src/storage/domains/experiments/index.ts
…e test - Standardize on non-optional `getLogger().debug(...)` across all 5 scoped-miss debug log sites in DatasetsManager + Dataset. `Mastra.getLogger()` always returns a logger in real usage; the optional-chain was inconsistent (one site in `delete` used non-optional, the other five used `?.`). - Add manager test locking in that `store.deleteDataset` is fired unconditionally, even when the pre-delete probe returns null. This is the right behavior — the scoped adapter's tenancy predicate remains the source of truth and concurrent recreate races resolve consistently — but it wasn't covered by an existing test. Per PR #18855 review feedback. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
… preflight Per PR #18855 second review nit: unscoped `DELETE /datasets/:id` now costs 2 round-trips (probe + delete) inside `DatasetsManager.delete`, but that's identical to the prior implementation where the server handler did a preflight `mastra.datasets.get()` before the delete. The probe just moved from the handler into the manager so the boolean contract lives in one place. No net cost change on either the scoped or unscoped path. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
Cut the 55-line explanation down to the essentials: what changed, what the wire behavior is, and the fact that the abstract contract is unchanged. Round-trip cost, TOCTOU trade-off, peer-floor rationale, and PII sanitization notes belong in the PR description, not the changelog. Co-Authored-By: Mastra Code (anthropic/claude-opus-4-7) <noreply@mastra.ai>
…ilent-tenancy-mismatch-no-ops-in
…ilent-tenancy-mismatch-no-ops-in
There was a problem hiding this comment.
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
stores/libsql/src/storage/domains/datasets/index.ts (1)
410-438: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick winCross-tenant cascade still reaches child rows on a scoped miss. The manager always calls
deleteDataset, so this batch runs even when the parent DELETE no-ops. Because the experiment/item/version statements are only filtered bydatasetId = ?, they can delete or detach rows for a dataset owned by a different tenant. Fold the tenancy predicate into every statement, or restore the scoped existence guard, so the cascade stays a true no-op on mismatch.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@stores/libsql/src/storage/domains/datasets/index.ts` around lines 410 - 438, The deleteDataset cascade is not fully tenant-scoped because the child DELETE/UPDATE statements only filter by datasetId, so a scoped miss can still affect rows from another tenant. Update the batch built in deleteDataset to apply the same tenancy predicate used in scopedWhere to every experiment/version/item statement, or restore a parent existence check before building the batch, so the cascade remains a true no-op on tenant mismatch.
🧹 Nitpick comments (3)
.changeset/tenancy-miss-observability.md (1)
6-21: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winMissing code example for the new public API behavior.
This changeset introduces two consumer-facing behavior changes with no example:
DatasetsManager.delete()now returns a meaningfulboolean, andDELETE /datasets/:idnow returns{ success: false }instead of always{ success: true }on scoped mismatch. As per path instructions, "If the change is a breaking change or is adding a new feature, ensure that a short code example is provided. This code example should show the public API usage."
[potential_issue]📝 Suggested addition
`DELETE /datasets/:id` now returns `{ success: false }` on a scoped miss (previously always `{ success: true }`). Unscoped missing-id still returns `404`. + +```ts +const deleted = await mastra.datasets.delete({ + id: datasetId, + organizationId, + projectId, +}); +// `deleted` is `false` for both a scoped tenancy mismatch and a truly +// missing id — the two are indistinguishable by design. +```🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In @.changeset/tenancy-miss-observability.md around lines 6 - 21, Add a short public API example for the new tenancy-miss behavior: show `DatasetsManager.delete(...)` returning a boolean and note that `false` means either a scoped mismatch or a missing id, and also show `DELETE /datasets/:id` returning `{ success: false }` on scoped misses. Place the example near the changeset content so readers can see how to consume the updated `datasets.delete` and route behavior without guessing.Source: Path instructions
packages/server/src/server/handlers/datasets.test.ts (1)
265-277: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winTighten the new 404 test to assert status, like its sibling tests.
Other tenancy tests in this file (e.g. Lines 115-125, 134-145) explicitly assert
err instanceof HTTPExceptionanderr.status === 404. This new test only checks.rejects.toThrow(), so it wouldn't catch a regression where the handler throws a different error type/status instead of the expected 404.✅ Suggested tightened assertion
it('throws 404 when unscoped delete targets a non-existent id (legacy behavior)', async () => { // Unscoped callers must still get a 404 on a missing id — this preserves // pre-boolean-return behavior for clients that do not send tenancy scope // query params. Only scoped calls return `{ success: false }` on miss. - await expect( - (async () => - DELETE_DATASET_ROUTE.handler({ - ...createTestServerContext({ mastra }), - datasetId: 'ds_does_not_exist', - } as any))(), - ).rejects.toThrow(); + const err = await DELETE_DATASET_ROUTE.handler({ + ...createTestServerContext({ mastra }), + datasetId: 'ds_does_not_exist', + } as any).then( + () => null, + (e: unknown) => e, + ); + expect(err).toBeInstanceOf(HTTPException); + expect((err as HTTPException).status).toBe(404); });🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/server/src/server/handlers/datasets.test.ts` around lines 265 - 277, The new legacy 404 test in DELETE_DATASET_ROUTE.handler is too loose because it only checks that an error is thrown. Update the test to match the other tenancy tests by capturing the rejection and asserting it is an HTTPException with status 404, using the same DELETE_DATASET_ROUTE and createTestServerContext setup so it verifies both error type and status.packages/core/src/datasets/dataset.ts (1)
478-502: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winDebug log doesn't distinguish "missing" vs "wrong dataset owner".
Both
#assertExperimentOwnershipandgetExperimentlog the same message for two different miss conditions: the experiment truly doesn't exist under scope, vs. it exists but belongs to a different dataset (experiment.datasetId !== this.id). Since this PR is specifically about tenancy-miss observability, distinguishing these cases (e.g., areason: 'not_found' | 'ownership_mismatch'field) would make the debug logs more actionable for diagnosing scope vs. ownership issues.Also applies to: 507-527
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@packages/core/src/datasets/dataset.ts` around lines 478 - 502, The debug logging in `#assertExperimentOwnership` (and the matching getExperiment path) currently uses one message for two different failure cases, so update the log payload to distinguish whether the experiment was missing under scope or found but had a dataset ownership mismatch. Add a clear reason field such as reason: 'not_found' or reason: 'ownership_mismatch' alongside the existing context (datasetId, experimentId, organizationId, projectId) and set it based on whether experiment is absent or experiment.datasetId !== this.id.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Outside diff comments:
In `@stores/libsql/src/storage/domains/datasets/index.ts`:
- Around line 410-438: The deleteDataset cascade is not fully tenant-scoped
because the child DELETE/UPDATE statements only filter by datasetId, so a scoped
miss can still affect rows from another tenant. Update the batch built in
deleteDataset to apply the same tenancy predicate used in scopedWhere to every
experiment/version/item statement, or restore a parent existence check before
building the batch, so the cascade remains a true no-op on tenant mismatch.
---
Nitpick comments:
In @.changeset/tenancy-miss-observability.md:
- Around line 6-21: Add a short public API example for the new tenancy-miss
behavior: show `DatasetsManager.delete(...)` returning a boolean and note that
`false` means either a scoped mismatch or a missing id, and also show `DELETE
/datasets/:id` returning `{ success: false }` on scoped misses. Place the
example near the changeset content so readers can see how to consume the updated
`datasets.delete` and route behavior without guessing.
In `@packages/core/src/datasets/dataset.ts`:
- Around line 478-502: The debug logging in `#assertExperimentOwnership` (and the
matching getExperiment path) currently uses one message for two different
failure cases, so update the log payload to distinguish whether the experiment
was missing under scope or found but had a dataset ownership mismatch. Add a
clear reason field such as reason: 'not_found' or reason: 'ownership_mismatch'
alongside the existing context (datasetId, experimentId, organizationId,
projectId) and set it based on whether experiment is absent or
experiment.datasetId !== this.id.
In `@packages/server/src/server/handlers/datasets.test.ts`:
- Around line 265-277: The new legacy 404 test in DELETE_DATASET_ROUTE.handler
is too loose because it only checks that an error is thrown. Update the test to
match the other tenancy tests by capturing the rejection and asserting it is an
HTTPException with status 404, using the same DELETE_DATASET_ROUTE and
createTestServerContext setup so it verifies both error type and status.
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: CHILL
Plan: Pro
Run ID: 5a476337-f7e8-4b46-86e5-9d7fb128d68f
📒 Files selected for processing (11)
.changeset/tenancy-miss-observability.mdpackages/core/src/datasets/__tests__/manager.test.tspackages/core/src/datasets/dataset.tspackages/core/src/datasets/manager.tspackages/core/src/storage/domains/datasets/base.tspackages/core/src/storage/domains/experiments/base.tspackages/core/src/storage/domains/experiments/inmemory.tspackages/server/src/server/handlers/datasets.test.tspackages/server/src/server/handlers/datasets.tsstores/libsql/src/storage/domains/datasets/index.tsstores/mongodb/src/storage/domains/experiments/index.ts
✅ Files skipped from review due to trivial changes (1)
- packages/core/src/storage/domains/datasets/base.ts
…ilent-tenancy-mismatch-no-ops-in
…ilent-tenancy-mismatch-no-ops-in
…ilent-tenancy-mismatch-no-ops-in
What
Adds an observability signal for scoped tenancy misses on datasets and experiments — without changing the storage abstract contract or adding new exports from
@mastra/core/storage.Why
Scoped storage
get*ByIdcalls returnnulland scopeddelete*calls silently no-op when tenancy filters don't match. That's the correct posture — throwing would leak cross-tenant existence via error timing or message — but it left operators with no signal at all when scoped ops missed.How
Abstract contract is unchanged.
deleteDataset,deleteExperiment, anddeleteExperimentResultsstill returnPromise<void>, matching every other delete in the storage domain. Third-party adapter implementations do not need updating.Manager-layer boolean.
DatasetsManager.deletenow returnsboolean. It derives that locally by doing a pre-delete scopedgetDatasetByIdprobe, then calls the void adapter delete unconditionally. The tiny TOCTOU between probe and delete on concurrent recreate only affects debug-log fidelity — not correctness or the no-existence-leak contract.Wire behavior change on
DELETE /datasets/:id:organizationId/projectIdquery params) that targets a missing id or a scope-mismatched row:200 { success: false }. Legit 404 and scope mismatch remain indistinguishable to preserve the no-leak contract.404(unchanged — the handler throwsDATASET_NOT_FOUNDwhen nothing was deleted and no scope was supplied).Manager-layer debug logs.
DatasetsManagerand theDatasethandle emit adebug-level log when a scopedget*returns null or a scoped delete/mutation targets a missing/mismatched record. Payload is plain{ op, id, organizationId, projectId }— same identifiers already logged by the HTTP layer for scoped calls. Deployments that sanitize HTTP access logs should apply the same treatment to the debug channel if these identifiers are considered sensitive.No peer-floor bump needed. Because there are no new named exports from
@mastra/core/storage, none of the store adapters need a@mastra/corepeer-floor bump.Test plan
pnpm build:core— cleanpnpm --filter ./packages/core check— 0 tsc errorspnpm --filter @mastra/{libsql,pg,mysql,mongodb,spanner} build:lib— all cleanpnpm build:server— cleanpnpm --filter ./packages/server check:core-imports— clean_test-utilstenancy suite: adapter-leveldelete*assertions kept asresolves.toBeUndefined()(abstract contract unchanged){ success: false }— same shape as a legit 404 to the clientRelated: MASTRA-4454
ELI5
This change makes dataset deletes safer and clearer when teams use tenant scoping. If you try to delete something outside your tenant, the system now quietly says “nothing happened” instead of leaking whether that item exists.
Summary
DatasetsManager.deletenow returns a boolean based on a scoped pre-check, while still calling the adapter delete as a no-op-safe operation.DELETE /datasets/:idnow returns{ success: false }for scoped mismatches/misses, but still returns404for unscoped missing datasets.get*misses and scoped delete/mutation no-ops inDatasetsManagerandDataset, includingop,id,organizationId, andprojectId.