Uh oh!
There was an error while loading. Please reload this page.
drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396
Conversation
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
…elope Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
📓 Docs Drift CheckThis PR changes 2 package(s): 10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
⛔ 1 release-owned page(s) also name something this change touched. These are read-only:
What this run could not see
Coarse fallback — 131 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin c78fe6759e4d2f87971c47e7999151d8a5a5972b && git checkout c78fe6759e4d2f87971c47e7999151d8a5a5972b
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 8422dd4c1d97fcb6e86943af0398809e0b5b684d && git checkout -B drift-repro 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 && git merge --no-ff 8422dd4c1d97fcb6e86943af0398809e0b5b684d
node scripts/docs-audit/affected-docs.mjs --json 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4
|
os-musk
commented
Sep 2, 2026
Enqueue provenance ( Generated by Claude Code |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14287
RemoteTransport.assertSafeIdentifieris the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bareError— nocode, nostatus— somapDataErrorreached none of its classifying branches, fell through to its sanitised terminal and served a 500. A caller whose own identifier was refused was told the server had faulted, and an SDK reading a 5xx retries a request that can never succeed. Same class as #11455 / #8931, one position over from #14113's alias half.Every one of those refusals now carries the ADR-0112 envelope
code: 'INVALID_REQUEST',status: 400, built by one constructor so the positions cannot answer three ways.The accept set is untouched.
SAFE_IDENTIFIERand every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Onlycodeandstatusare new. ThegroupByalias gating question (#14235) moves the accept set and is deliberately left open — this PR pins its current refusal so that card cannot be mistaken for having landed.Envelope chosen by the triage ruling on the card (2026-09-02): an existing member of the declared vocabulary, no new ledger code.
Per position
aggregate→objectError→ 500INVALID_REQUEST/ 400remote-transport-unsafe-identifier-envelope.test.ts— "objectrefuses with the envelope, and sends nothing"; and the #14113 control "theobjectposition still refuses an unsafe identifier"aggregate→ aggregationfieldError→ 500INVALID_REQUEST/ 400fieldrefuses with the envelope"; and the #14113 control "thefieldposition still refuses an unsafe identifier, and sends nothing"aggregate→groupByfieldError→ 500INVALID_REQUEST/ 400groupByFIELD refuses with the envelope, and sends nothing"aggregate→groupByout key (alias)Error→ 500INVALID_REQUEST/ 400groupByOUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"syncSchema→ table nameengine.syncObjectSchema)Error→ 500INVALID_REQUEST/ 400syncSchemarefuses an unsafe TABLE name with the envelope"buildCreateTableSQL→ column nameError→ 500INVALID_REQUEST/ 400syncSchema→ added column nameError→ 500INVALID_REQUEST/ 400syncSchemasBatch→ object nameError→ 500INVALID_REQUEST/ 400syncSchemasBatchrefuses an unsafe object name with the envelope"syncUniqueIndexes→ index name / columnsError→ 500INVALID_REQUEST/ 400remote-canonical-backfillfreeassertSafeIdentifierError, flattened to a report stringINVALID_REQUEST/ 400, still flattenedassertSafeIdentifierthrows the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"The
mapDataErrorreading — does the benefit survive the boundary?Measured by reading
packages/rest/src/error-response.tsatd62f990a9, not by editing or importing it (@objectstack/restis not a dependency of this package, and a test reaching outside its own package is thecheck:cross-package-test-inputsshape):mapDataErrordelegates toclassifyDataError. Nineerror?.code === '…'branches run first;INVALID_REQUESTmatches none of them, and none of them reads the message — so nothing intercepts this refusal ahead of the declared-status passthrough.declaredHttpStatus(error)(line 349) readserror.status, falling back toerror.statusCode, and keeps it when400 <= s < 600. Our 400 qualifies.declaredServerFaultAnsweris the 5xx arm — not taken at 400.{ status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }.thrownCodeFieldsemits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling todeclaredCode.INVALID_REQUESTis in the union.So the two fields this PR sets are exactly the two that door reads, and the response goes from a sanitised 500 to a 400 carrying
INVALID_REQUESTand the refusal's own sentence. No change topackages/rest.Ablation — reverse verification
Restore the bare
throw new Error(...)in bothassertSafeIdentifierhelpers, run the two test files, restore.--stat, and never the editing tool's exit code): anchored counts of the injected and removed text —injected transport=1 backfill=1 | removed transport=0 backfill=0. The script carriestrap restore EXIT INT TERMwith absolute paths seeded fromgit rev-parse --show-toplevel../turso-driver.js,./remote-transport.js,./remote-canonical-backfill.js), so vitest resolves the subject fromsrc/*.tsand nodist/sits between the mutation and the run. The RED result is itself that proof — adist-resolved subject would have stayed green.toEqualcomparisons reading{ code: undefined, status: undefined }. Not one is a "expected the transport to refuse …" — every input the gate refuses today is still refused with the ablated helper.toEqualas the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:The
messageline carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.git status --porcelainempty,git diff HEADempty, andgit hash-objectequal to the HEAD blob for both files —2b8d1bc11719b57ace1b380ccc320ed47b60d717(transport) and5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6(backfill).Driver-conformance ledger — before and after, verbatim
Before the first edit, at
d62f990a9:After the last commit, at
8422dd4c1:Unmoved, as it must be — this PR adds no conformance cell and retires none.
The one file outside the dispatched surface, named rather than slipped in
packages/spec/src/api/error-code-ledger.zod.tsgains one provenance row —'@objectstack/driver-turso': ['INVALID_REQUEST']with its reachability comment. It was not in the dispatched file surface, so here is the evidence rather than a silent edit:pnpm --filter @objectstack/spec check:error-code-provenanceprints:FAIL — 1 stamp site(s) of a registered code with no provenance row: @objectstack/driver-turso stamps 'INVALID_REQUEST' (constdef) at packages/drivers/driver-turso/src/remote-transport.ts:118 — not listed under its own owner key. The gate's own prescribed fix is this row or a waiver, and it says "Both are decisions on the record; silence is not." With the row:OK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver.INVALID_REQUESTis already registered by seven packages. Measured: the deduped registered-code union is 248 codes before and 248 after, with an empty added set and an empty removed set — soErrorCode,REGISTERED_ERROR_CODESand every generated spec artifact are unchanged (pnpm --filter @objectstack/spec check:generatedreports all 15 up to date).@objectstack/driver-memory(UNIQUE_VIOLATION, error-code-ledger:@objectstack/driver-memorybecomes an emitter of UNIQUE_VIOLATION with no provenance row — invisible to every gate, which is the point of the row #13254) and@objectstack/driver-sql(SQL_DIALECT_EMISSION_UNSUPPORTED, sql-driver: one emission-identity source — loud refusal for redshift/cockroachdb DDL, pgnative joins the Postgres family (#11756 ruling) #11991) are the same row for the same reason, and the ledger header's own rule is "a code emitted by several packages is listed once per emitting package".MONGODB_MULTI_TENANT_UNSUPPORTEDmay be registered-but-unemittable in the error-code ledger — a boot refusal never reaches a wire envelope #8035 test) isRemoteTransport's DDL andaggregatepositions on a server already serving HTTP — explicitly not the backfill module, whose callers flatten the throw.origin), and the claim comment's file surface is amended on the card in the same round.If you would rather this row landed separately, say so and it comes out — the driver change cannot go green without it, so the two would have to land together in either order.
What is not pinned, said plainly
The unique-index-sync position (
index.nameandindex.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived byuniqueIndexesFromFieldsfrom an object whose own name and columns were already gated one call earlier, so no input reaches that assert without being refused first. It is covered by construction, not by a test, and inventing a synthetic route to it would pin the harness rather than the behaviour.On the backfill producer: its envelope is defence in depth, not a wire answer today, and the test says so rather than implying more. Both callers flatten the throw into
report.errorby design (ADR-0053 D-B3 forbids a migration taking a boot down), socode/statusreach no response envelope from that module. The helper is exported so the envelope has a real pin — nothing else can observe it, and an unobservable assertion is a phantom check rather than a test.Verification
Union run on
8422dd4c1(this PR's head, after mergingorigin/main):pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42)·Tests 1143 passed (1143)pnpm --filter @objectstack/driver-turso typechecktsc --noEmit --listFilesconfirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers thempnpm lint(whole repo,eslint . --no-inline-config)pnpm --filter @objectstack/spec check:error-code-provenanceOK — every registered-code stamp site is listed under its own owner key or carries a recorded waiver (8 waiver(s), all live)pnpm --filter @objectstack/spec check:generated✓ All 15 generated artifacts are up to date.pnpm --filter @objectstack/spec exec vitest run src/api/error-code-ledger.test.ts18 passedpnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.pnpm check:error-code-casing·check:dispatcher-error-vocabularypnpm check:engine-double-contract·check:where-matcher·check:query-options-erasure·check:type-check-coveragepnpm check:nul-bytesThe rest of the re-derived family (
node scripts/pm/dispatch-gates.mjs, 52 path-matched plus 4 by change kind — the dispatch's hint named 20, before the ledger row widened it) ran green too:check:cross-package-test-inputs,check:doc-authoring,check:logger-receiver-detach,check:page-declaration-shape,check:published-files,check:slot-lookup,check:test-source-alias,check:type-source-resolution,check:changeset-gate-self-tests,check:objectui-changeset,check:pm-half-states,check:merge-driver,check:spec-parsed-alias,check:objectql-double-limit, and the direct-node ones (check-ci-filter-parity,check-comment-mask-adoption,check-cross-package-test-inputs,check-keyed-text-bounds,check-plugin-teardown-shape,check-shard-attestation,check-undeclared-dep-imports,docs-audit/check-affected-docs,docs-audit/check-drift-comment,check-adr-0087-registration,check-changeset-no-major,check-empty-changeset,release-rehearsal-clone --self-test).Two gates returned exit 3 = NOT MEASURED, which is neither green nor red and is recorded as such:
scripts/check-test-completeness.mjs(needs a savedturbo run testlog that only CI produces) andscripts/pm/check-half-states.mjs(needs repo-scoped REST egress; this session's repo-scoped REST answers 403).Changeset:
patchfor@objectstack/driver-tursoand@objectstack/spec— a refusal-envelope correction plus a provenance row; nothing an author writes changes, so it is not breaking.🤖 Generated with Claude Code
https://claude.ai/code/session_0112hMx9hjJ9BgB28X97DS68
Generated by Claude Code