drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@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

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500 - #14396

Merged
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope
Sep 2, 2026
Merged

drivers(turso): an unsafe identifier on the remote transport answers 400 INVALID_REQUEST, not an opaque 500#14396
os-musk merged 4 commits into
mainfrom
claude/issue-14287-turso-unsafe-identifier-envelope

Conversation

@os-musk

Copy link
Copy Markdown
Collaborator

Fixes#14287

RemoteTransport.assertSafeIdentifier is the one gate for every position where an identifier is inlined into SQL on the Turso remote transport. It threw a bare Error — no code, no status — so mapDataError reached 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_IDENTIFIER and every refusal message are byte-identical; exactly the inputs refused before are refused after, with byte-identical prose. Only code and status are new. The groupBy alias 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

PositionReached byBeforeAfterPinned by
aggregateobjectan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400remote-transport-unsafe-identifier-envelope.test.ts — "object refuses with the envelope, and sends nothing"; and the #14113 control "the object position still refuses an unsafe identifier"
aggregate → aggregation fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the aggregation field refuses with the envelope"; and the #14113 control "the field position still refuses an unsafe identifier, and sends nothing"
aggregategroupBy fieldan aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy FIELD refuses with the envelope, and sends nothing"
aggregategroupBy out key (alias)an aggregate query over HTTPbare Error → 500INVALID_REQUEST / 400same file — "the groupBy OUT KEY refuses with the envelope — gating unchanged, envelope added"; and the #14113 control "the groupBy alias position is UNCHANGED"
syncSchema → table namepublishing an object (engine.syncObjectSchema)bare Error → 500INVALID_REQUEST / 400same file — "syncSchema refuses an unsafe TABLE name with the envelope"
buildCreateTableSQL → column namepublishing a new objectbare Error → 500INVALID_REQUEST / 400same file — "… the CREATE leg"
syncSchema → added column namere-publishing an existing object (ALTER limb)bare Error → 500INVALID_REQUEST / 400same file — "… the ALTER leg"
syncSchemasBatch → object namebatched schema syncbare Error → 500INVALID_REQUEST / 400same file — "syncSchemasBatch refuses an unsafe object name with the envelope"
syncUniqueIndexes → index name / columnsunique-index syncbare Error → 500INVALID_REQUEST / 400inherited from the single producer; not independently pinned — see "What is not pinned" below
remote-canonical-backfill free assertSafeIdentifierboot-time backfill after remote schema sync, or the public operator-driven methodbare Error, flattened to a report stringINVALID_REQUEST / 400, still flattenedsame file — "the free assertSafeIdentifier throws the identical envelope" (direct), plus "the backfill still REPORTS rather than throws, and sends no statement"

The mapDataError reading — does the benefit survive the boundary?

Measured by readingpackages/rest/src/error-response.ts at d62f990a9, not by editing or importing it (@objectstack/rest is not a dependency of this package, and a test reaching outside its own package is the check:cross-package-test-inputs shape):

  • mapDataError delegates to classifyDataError. Nine error?.code === '…' branches run first; INVALID_REQUEST matches 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) reads error.status, falling back to error.statusCode, and keeps it when 400 <= s < 600. Our 400 qualifies.
  • declaredServerFaultAnswer is the 5xx arm — not taken at 400.
  • The 4xx arm returns { status: declaredStatus, body: { error: msg, ...thrownCodeFields(error, declaredStatus) } }. thrownCodeFields emits the closed ADR-0112 member verbatim when the code is in the union, and demotes an unregistered spelling to declaredCode. 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_REQUEST and the refusal's own sentence. No change to packages/rest.

Ablation — reverse verification

Restore the bare throw new Error(...) in bothassertSafeIdentifier helpers, run the two test files, restore.

  • Mutation proved on disk before the run (never a bare --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 carries trap restore EXIT INT TERM with absolute paths seeded from git rev-parse --show-toplevel.
  • No rebuild leg, and why that is sound here: every import in the test file is relative (./turso-driver.js, ./remote-transport.js, ./remote-canonical-backfill.js), so vitest resolves the subject from src/*.ts and no dist/ sits between the mutation and the run. The RED result is itself that proof — a dist-resolved subject would have stayed green.
  • Result: 19 failed / 13 passed. All 19 failures are toEqual comparisons 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.
  • The predicted direction held for the envelope and was too coarse for the accept-set block, recorded rather than quietly rewritten (also corrected in the test file's own docblock). The accept-set block's REFUSED half goes red because it asserts the envelope in the same toEqual as the message; its ACCEPTED half — the direction that catches a tightened gate — stayed green. The red half's diff is the sharper evidence:
 {
- "code": "INVALID_REQUEST",
+ "code": undefined,
"message": "RemoteTransport: unsafe identifier rejected: \"\"",
- "status": 400,
+ "status": undefined,
}

The message line carries no marker: prose and predicate are provably untouched. That is what makes this an envelope change and not a gating one.

  • Restore proved byte-exact, not by an exit code: git status --porcelain empty, git diff HEAD empty, and git hash-object equal to the HEAD blob for both files — 2b8d1bc11719b57ace1b380ccc320ed47b60d717 (transport) and 5d5ce5ea9b1c873de49c654f25c35038fdd0b8b6 (backfill).

Driver-conformance ledger — before and after, verbatim

Before the first edit, at d62f990a9:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

After the last commit, at 8422dd4c1:

check-driver-conformance: OK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.

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.ts gains 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:

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.name and index.columns) inherits the envelope from the single producer but has no independent case: the index name and columns are derived by uniqueIndexesFromFields from 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.error by design (ADR-0053 D-B3 forbids a migration taking a boot down), so code/status reach 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 merging origin/main):

CommandVerdict line
pnpm --filter @objectstack/driver-turso testTest Files 42 passed (42) · Tests 1143 passed (1143)
pnpm --filter @objectstack/driver-turso typecheckexit 0 — and tsc --noEmit --listFiles confirms both edited/added test files are in the program (2 hits), so "typecheck clean" really covers them
pnpm lint (whole repo, eslint . --no-inline-config)exit 0
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 passed
pnpm check:driver-conformanceOK — 50 covered cell(s), 0 in the DEBT ledger, 0 exempt.
pnpm check:error-code-casing · check:dispatcher-error-vocabularyexit 0 · exit 0
pnpm check:engine-double-contract · check:where-matcher · check:query-options-erasure · check:type-check-coverageexit 0 (the four convention-triggered by a new test file)
pnpm check:nul-bytesexit 0

The 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 saved turbo run test log that only CI produces) and scripts/pm/check-half-states.mjs (needs repo-scoped REST egress; this session's repo-scoped REST answers 403).

Changeset: patch for @objectstack/driver-turso and @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

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/driver-turso, @objectstack/spec, touching 7 documentable anchor(s).

10 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:

  • content/docs/api/client-sdk.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/error-catalog.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/api/error-handling-server.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/api/metadata-api.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/automation/webhooks.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/data-modeling/import-mappings.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/kernel/contracts/data-engine.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object))
  • content/docs/permissions/authentication.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/permissions/sso.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))
  • content/docs/ui/forms.mdx(via INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

1 release-owned page(s) also name something this change touched. These are read-only:

  • content/docs/releases/v17.mdx(via ERROR_CODE_LEDGER (symbol, a top-level const object), INVALID_REQUEST (literal, a string literal in ERROR_CODE_LEDGER; a string literal in UNSAFE_IDENTIFIER_CODE))

content/docs/releases/ is RELEASE-OWNED (AGENTS.md "Documentation Guardrails"): release
notes are written centrally at release time, and a code PR that edits them is the exact PR
that guardrail exists to stop. They are still audited — read-only. If one of them is actually
wrong, file an issue or open a dedicated docs-only PR; do not edit it here.

What this run could not see
  • the SDK route bridge reached 47 of 219 client-bound route-ledger rows — the other 172 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 172: 14 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 56 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 102 are undecided (no in-repo declaration, on a ledger that has other in-repo registrars — absence and an unreadable spelling are not distinguishable here). The rows themselves: node scripts/docs-audit/affected-docs.mjs --bridge-coverage
  • 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 — 131 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 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4packageMentionDocs.

Which tree this was computed on

This run read content/docs from c78fe6759e4d2f87971c47e7999151d8a5a5972b — the merge of head 8422dd4c1d97fcb6e86943af0398809e0b5b684d into base 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4, which is what actions/checkout gives a pull_request run. Not the PR head.

A worktree cut from an older main holds a different content/docs, so re-deriving there can legitimately return a different list — that is a different tree, not a wrong row. To answer on the same tree:

# 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

⚠️ That checkout carried uncommitted changes, so the commit above does not fully identify what was read.

Advisory only, and a precision-first one (#9192): a page is listed because it names a
symbol, wire route or SDK method this diff touched — not because it mentions a changed
package. Each row says which anchor put it there, so a wrong row is reportable rather than
merely annoying. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs 038f3332e62d61469b67ffa2cf8d3ef7ac8ccea4 → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@os-muskClaude

Copy link
Copy Markdown
CollaboratorAuthor

Enqueue provenance (domain:engine execution seat, session_0112hMx9hjJ9BgB28X97DS68): ACCEPT on #14287 (comment 5504280781) → at 04:29Z every check run on head 8422dd4c1 was completed with success or skipped (34 runs; Lint & Repo Gates finished 04:24Z), governed-surface test on the six changed paths: NOT governed, mergeable_state not dirty → marked ready and auto-merge (squash) enabled. Landing is by the merge queue; the engine seat follows it to MERGED.


Generated by Claude Code

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-musk@claude