Skip to content

fix(cli,service-datasource): under bin/run-dev.js (NODE_ENV=development) an unopenable SQLite datasource steps down to an in-memory engine, so a dev-mode os migrate audit-mail-bodies reports an unreachable archive as examined and clean #24950

Closed

Description

@objectstack-fleet

Filing gate ③: a finding from the #24899 work (PR #24934). The dev measured it, and the seat's delta review recorded it as R2 (6296994906).

  • Reader who acts: the domain:services lane (sqlite-driver-fallback.ts), with domain:cli for the entry point.
  • Dedupe: none found ("sqlite fallback in-memory unopenable", "run-dev NODE_ENV development datasource").

What was measured

  • packages/cli/bin/run-dev.js pins NODE_ENV=development. Under development, the SQLite factory (sqlite-driver-fallback.ts) steps a datasource file it cannot open down to a wasm/in-memory engine instead of failing.
  • So an archive datasource declared on an unopenable SQLite path (here, a path under a regular file) reads connected on a dev-mode boot. os migrate audit-mail-bodies then reports it examined, scanned 0, exit 0.
  • The archive was never read, yet the report says it is clean.
  • The production entry bin/run.js pins production. There the native driver is returned unprobed, the read fails with DATABASE_ERROR, and the command counts a failure and exits 1. Released operators are therefore not affected. Source-tree runs and any development-mode deployment are.

Why it matters

A maintenance command that reports "examined, 0 to mask" over a store it never opened is a false all-clear. The same step-down presumably applies to any datasource and any command on a development boot. That is acceptable for a scratch dev database, and wrong for a declared datasource holding real data.

Measure, then decide

  1. Which datasources and commands take the step-down under development: only the primary, or every declared datasource as well?
  2. Should a declared datasource ever fall back to in-memory? A plausible rule: the fallback applies to the primary's absent dev file only, and a declared datasource that cannot open fails its connect, so listConnectionStates() reports it.
  3. Fix, with a pin. feat(cli,plugin-audit): os migrate audit-mail-bodies masks the archive datasource too #24934's integration test uses an unreachable Postgres archive for exactly this reason, and could then add the SQLite case.

Not a security regression on released 18.0.0. It belongs to the same honesty class as #24934's B1.

Activity

  1. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Claim: PM loop round 15 · 2026-10-20T17:36Z
    Session: session_011TdZ8GjixNZFLz1xJD9zzK
    Account: os-justin (the seat's linked user as GET /user answers it; always the card's assignee)
    Branch: claude/issue-24950-sqlite-open-failure-no-stepdown
    Worktree: objectstack-issue-24950
    Domain: domain:services
    Seat: domain:services#1
    File surface: packages/services/service-datasource/src/sqlite-driver-fallback.ts (resolveSqliteDriver's probe classification and its module note), its pins in a NEW test file under service-datasource/src/__tests__/, and one new .changeset/24950-*.md. ⛔ Not packages/cli/** (bin/run-dev.js keeps NODE_ENV=development), ⛔ not connect-failure-remedy.ts or datasource-connection-service.test.ts (PR #24933 is in the merge queue on them), ⛔ not packages/drivers/**, ⛔ no change to the native → wasm → memory step-down for a native addon that cannot load (stop on breach; explain in the report)
    Container & model: S, mode:subagent, model: default (opus), quoting this dispatch's --tier output "no path-derived mandate … floor sonnet · default opus · ceiling fable"
    Clause-②: no
    Responsibility: service-datasource's resolveSqliteDriver steps down to wasm and then in-memory on ANY probe failure in development, including a file it cannot open, so a declared datasource on an unopenable path reads connected and a maintenance command reports it examined and clean | none: production re-throws, but source-tree runs and development deployments do not | every development-mode boot that declares a SQLite datasource on a path it cannot open; measured with os migrate audit-mail-bodies (#24934's R2, 6296994906)
    Thread-read: none
    Serial constraints cleared: Of the 21 open PRs (all files read at this claim), only PR #24933 (#24909, this seat, in the merge queue) touches service-datasource, on connect-failure-remedy.ts and its tests; this claim stays off those files. PR #24934 (#24899, the epic seat) is the source of the finding and touches no service-datasource file.

    Seat ruling (third tier, PM discretion; the maintainer's veto window applies). Governing text:

    • the module's own note (sqlite-driver-fallback.ts:6–33): the step-down exists because better-sqlite3 loads its native addon lazily, so an ABI or load failure is invisible until the first query;
    • AGENTS.md "Degradation log levels";
    • the decision frame's third axis.

    These settle it as follows.

    1. Route.
      • A probe failure that is the native addon failing to LOAD keeps today's step-down, in development only.
      • A probe failure that is the database FILE failing to OPEN (a path under a regular file, a missing parent the driver cannot create, a permission refusal) is re-thrown in development as in production. The datasource's connect then fails, and listConnectionStates() reports it. That holds for every datasource, the primary included.
    2. Premises, each measured before any edit:
      • P1. The two failures are distinguishable on the probe's thrown value, by a structured code (SQLITE_CANTOPEN, ENOTDIR, EACCES, …) as opposed to the addon-load shape. Read from a real better-sqlite3, not from text alone where a code exists.
      • P2. No measured development flow relies on the in-memory rung for an unopenable FILE, as opposed to an absent native addon. The default primary's absent dev file is created by the driver and is not an open failure.
      • P3. The wasm rung would fail on the same path, so the in-memory rung is what currently hides it.
    3. If a premise fails, report a fork. ⛔ No new env var and no new gate.

    Readings taken on origin/main 71696121a6.


    Generated by Claude Code

  2. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    os-dev-report

    {
      "issue": 24950,
      "status": "done",
      "branch": "claude/issue-24950-sqlite-open-failure-no-stepdown",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/24955",
      "session": "session_011TdZ8GjixNZFLz1xJD9zzK — this run's harness-stamped id (subagent = parent's); the same id the Claim 6297153129 names",
      "premise_still_valid": true,
      "summary": "resolveSqliteDriver (service-datasource/src/sqlite-driver-fallback.ts) now re-throws, in development as in production, a native probe failure that is the database FILE failing to open or be created. It classifies on the thrown value's code only: an operating-system code /^E[A-Z0-9]+$/ (EEXIST, ENOTDIR, ENOENT, EACCES, …) or SQLITE_CANTOPEN, which SqlDriver.connect() raises as SqliteDatabaseFileError. Every other failure keeps today's step-down. A native addon that cannot load (ERR_DLOPEN_FAILED, bindings' code-less not-built error, a missing package) still steps down to wasm with the same warning, and production is unchanged. The module note now says which failures step down and why. The pins are in a NEW file, src/__tests__/sqlite-driver-fallback-file-open-failure.test.ts (9 cases, real better-sqlite3, real wasm, real files), and there is one patch changeset. P1, P2 and P3 all hold. P3 has one refinement: for a directory as the file, and for a new file in a read-only directory, base wasm connect() RESOLVED (engine sqlite-wasm over a file it could not read) rather than failing to memory. The native re-throw catches both. The re-throw sits at the native rung only: I added it to the wasm rung too, the ablation showed no measured path reaches it (connect() makes the directory before the addon loads), and I removed it (4da3d4bf0c), so the step-down for an addon that cannot load behaves exactly as before. Public door before/after: bin/run-dev.js migrate apply --yes with a bound archive under a regular file went from connected on InMemoryDriver, Applied 0 change(s), exit 0 to a refusal naming the file (EEXIST), exit 1.",
      "tests": "Head of record d353b3979b (the merge of origin/main ea08ce584c, which landed #24933, into 05e9a8e09a). sqlite-driver-fallback.ts has blob ec4203705cea at 4da3d4bf0c, 05e9a8e09a and d353b3979b alike. || pnpm --filter @objectstack/service-datasource exec vitest run --maxWorkers=2 (whole package), under os-verify-lock: at d353b3979b \"Test Files 50 passed (50) · Tests 892 passed (892)\", VERDICT command-exit 0. At 05e9a8e09a, before the merge: 49 files, 871 passed. || Targeted, at cb069be13f: vitest run --maxWorkers=2 on the new file and src/sqlite-driver-fallback.test.ts: \"Test Files 2 passed (2) · Tests 20 passed (20)\" (9 new + 11 existing). || pnpm --filter @objectstack/service-datasource typecheck (\"> tsc --noEmit\"): exit 0 at d353b3979b (joined with && after the test run; VERDICT command-exit 0). tsc --listFiles includes src/__tests__/sqlite-driver-fallback-file-open-failure.test.ts. || Build: turbo run build --filter=@objectstack/service-datasource... --concurrency=2 at d353b3979b: 19/19 successful. || Ablation, one leg (the native re-throw), at 4da3d4bf0c, via scripts/ablation-replace.mjs WRAP: anchor 1 -> 0, marker ABLATION-24950-NATIVE 0 -> 1 (grep -c 1), blob ec4203705cea -> fc817e7389d4. The subject is a relative src import, so no dist leg. Predicted and observed: 4 failed | 5 passed. Red: regular file, directory, addon+regular file, DatasourceConnectionService failed state. Green: two absent-file pins, ERR_DLOPEN_FAILED -> wasm, not-built -> wasm, production. Restore proved: blob ec4203705cea == HEAD, git diff HEAD empty. An earlier three-leg run, with the since-removed wasm-rung re-throw present, showed that leg ALL GREEN (9/9). That is the evidence the wasm rung was unreachable. || Premise probes (real better-sqlite3 13.0.3, Node v22.22.0; base file = git show feb09db14a:…/sqlite-driver-fallback.ts run against the real drivers; temporary probe files deleted). BASE, dev: under-regular-file -> memory; deeper -> memory; /proc/missing -> memory; EACCES mkdir (as uid 65534) -> memory; is-a-directory -> sqlite-wasm (first query DATABASE_ERROR cause EISDIR); new file in a 0555 dir -> sqlite-wasm; absent file -> better-sqlite3 (created); missing parents -> better-sqlite3 (made); not-a-database -> sqlite-wasm and the file renamed to notadb.db.corrupt-1792518563978. HEAD: rejections SqliteDatabaseFileError EEXIST / ENOTDIR / ENOENT / EACCES / SQLITE_CANTOPEN / SQLITE_CANTOPEN, no warning; absent and missing-parent unchanged (native); not-a-database unchanged; production (dev:false) unchanged (native unprobed, first query DATABASE_ERROR cause SQLITE_CANTOPEN). || Public door, bin/run-dev.js migrate apply --yes --database-url file:main.db, with a dist/objectstack.json archive (driver sqlite) under a regular file. Bound object: BEFORE (native re-throw mutated out and planted in dist/, ablation-dist-preflight marker in 2 built files) \"neither native nor wasm SQLite available — dev falling back to InMemoryDriver\", \"datasource 'archive': connected\", \"Applied 0 change(s)\", exit 0. AFTER (05e9a8e09a) \"datasource 'archive': connect failed — SQLite database file …/plain/archive.db cannot be created … EEXIST … fail-fast per ADR-0062 D5\", exit 1. autoConnect only: BEFORE connected, exit 0; AFTER WARN connect failed naming the file (degraded), exit 0. Restore leg: rebuilt, preflight --absent: marker absent from all 24 built files, tree clean. || NOT MEASURED: the card's own path (os migrate audit-mail-bodies over an archive), reason: the archive examination exists only on PR #24934, unmerged at this head.",
      "gates": "Local derived union at d353b3979b: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths; stderr names objectstack-ai/objectstack at d353b3979b, 3 paths vs merge base ea08ce584, 354 changed lines) -> 66 commands. A runner ran them one at a time under os-verify-lock, in the background, PID recorded and waited with tail --pid, each exit captured before any pipe: 66 x exit 0 (VERDICT command-exit 0, held 844 s). || --ran: \"Run reconciliation — 66 derived, 66 run, 0 NOT-MEASURED, 0 UNRUN\" / \"✓ dispatch-gates --ran: 66 derived famil(ies) accounted for — 66 run, 0 NOT-MEASURED (a DERIVED zero …)\", RAN_EXIT 0. || Before the merge, at 05e9a8e09a, the same 66 commands: 65 x exit 0, and pnpm check:dual-build-cjs-loads exit 3 (PREREQUISITE NOT MET: unbuilt packages elsewhere in the tree; its --self-test half passed, 93 cases). --ran then printed 66 derived / 65 run / 1 NOT-MEASURED / 0 UNRUN. || Not owed: check-platform-checklist (no exported symbol renamed or deleted); check:engine-double-contract is in the union anyway (the new connection-service engine fake has no write verbs). || PR #24955 check-runs on d353b3979b, read once at report time. success (13): Auto Label, Dogfood Regression Gate, Governed Surface Queue Guard, Flag docs affected by code changes, Lint · scoped · tooling self-tests, No other open PR may claim the same single-writer path, Lint · PM dispatch-gates, No other open PR may claim the same issue, Part-of PR must not also close its card, filter, Check Documentation Links, The card this PR closes must claim this branch, Check PR Size. skipped (7): Build Docs, Console Pin Gate, Temporal Conformance (live PG + MySQL), Dogfood Verify CLI, Dogfood Regression Gate (matrix), Packed-tarball smoke (opt-in), 18.x upgrade harness (opt-in). in_progress (18): Test Core (1-6/6), Build Core, Lint · ESLint, Type Check · debt ledger, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · scoped · slot lookup & corpus, Type Check · source gates, Type Check · consumer gates, Type Check · workspace, Lint · scoped · query-options erasure, Lint · docs & code guards, Check Changeset. Not awaited.",
      "files_changed": [
        "packages/services/service-datasource/src/sqlite-driver-fallback.ts (+89/-21: module note section, module-private isSqliteFileOpenFailure + closeQuietly, the native-rung re-throw)",
        "packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-file-open-failure.test.ts (new, 9 cases)",
        ".changeset/24950-sqlite-open-failure-no-stepdown.md (new; @objectstack/service-datasource patch; carries \"Clause-②: no\")"
      ],
      "deviations": [
        "Order: the raw better-sqlite3 P1 codes were read before any edit, but the first src edit came before the driver-level P2/P3 probes. Those probes were then run on the base file byte for byte (git show BASE), before the first commit.",
        "Lock conflict, noted rather than resolved silently: os-dev.md says check:* gates do not go through os-verify-lock; the dispatch says every gate run goes under it. I followed the dispatch (one runner at a time, under the lock), holding it 710 s and 844 s. The CLI door boots ran outside the lock, as boots and not builds or tests.",
        "test:repo (the vitest repo project) was not run locally: the diff touches none of its tests, and the derived list does not name it. Declared to CI (Test Core).",
        "origin/main moved mid-run (#24933 landed, ea08ce584c, on the same package). Per AGENTS §10 I merged it before opening the PR (d353b3979b, trailers added to the merge commit before its first push) and re-ran the build, the package tests, the typecheck and the whole derived gate union on the merge head.",
        "The wasm-rung re-throw was in an intermediate commit (cb069be13f) and was removed in 4da3d4bf0c. Both are on the branch; the PR body says why.",
        "The before leg at the public door was produced by planting the pre-fix branch into service-datasource dist/ (ablation-replace WRAP, then rebuild, then ablation-dist-preflight; restore, rebuild, preflight --absent), not by a base-tree build.",
        "Attribution: commits carry AGENTS.md's model-free trailer pair, and the PR body ends in the session-URL footer alone. The harness reminder's model-named Co-Authored-By trailer and its two-line PR block were not used (AGENTS.md precedence). Reported, not imitated.",
        "Card path NOT MEASURED (see tests)."
      ],
      "mcp_calls": "0 — no MCP GitHub tool was called",
      "api_writes": "2 — both fleet-write relay strokes, each one POST /repos/objectstack-ai/objectstack/dispatches (scripts/pm/fleet-write/dispatch.mjs). Stroke 1 = pr_create: POST /repos/objectstack-ai/objectstack/pulls (draft) + POST /repos/objectstack-ai/objectstack/issues/24955/assignees (os-justin), run 39475762022 success, body read back identical 11808 bytes. Stroke 2 = this report: POST /repos/objectstack-ai/objectstack/issues/24950/comments. git push is not counted. Reads: unauthenticated-header REST GETs (issue 24950 and its comments, comment 6296994906, comments on #24899 and #24934, PR 24934 and 24955, check-runs).",
      "open_questions": [
        {
          "question": "A declared SQLite file that is NOT a SQLite database (SQLITE_NOTADB) still steps down in development. Wasm then renames the operator's file to FILE.corrupt-TIMESTAMP, creates an empty database in its place, and the datasource reads connected under a false \"native better-sqlite3 unavailable (ABI mismatch or not built)\" warning. Measured at bin/run-dev.js migrate apply --yes, exit 0. Should it be re-thrown like an open failure? The ruling's named route lists open failures only, so I left it.",
          "options": [
            "A — Producer side, both modes: driver-sql SqlDriver.connect() refuses SQLITE_NOTADB as a SqliteDatabaseFileError, as #23696 did for SQLITE_CANTOPEN. Today it logs \"Failed to set PRAGMA\" and resolves. resolveSqliteDriver then adds the code to isSqliteFileOpenFailure. Cost: a small driver-sql change (domain:drivers lane, packages/drivers/**), a pin each side, and production connect fails loud on a non-database file where it now resolves and fails at first read. About half a day.",
            "B — Consumer side only, this PR's file: re-throw when the probe's DATABASE_ERROR carries cause.code SQLITE_NOTADB. Cost: one patch round here; the classifier then reads a wrapped cause (the driver wraps SELECT 1 failures), and production keeps resolving connect on such a file.",
            "C — Keep it: treat the wasm quarantine as intended dev recovery. Cost: a declared file is moved aside and replaced on a development boot, under a warning that names the wrong cause."
          ],
          "recommendation": "A. On the four axes: (1) Business need: measured at a public door, a real dev boot of a declared datasource pointed at a wrong or encrypted file moves that file aside. That is not speculative. (2) Long-term soundness: the module note's single cause (the addon load) is the contract, and the file's own driver is the producer that classifies file failures (contract-first, the #23696 precedent), so a consumer-side cause-sniff (B) is the weaker shape. (3) AI-error resistance: a loud connect refusal naming the file beats a silent rename plus a misleading warning, especially for AI-written configs that point at the wrong file. (4) Startup no-sprawl: no new surface, flag or gate, only one more code in an existing classification. B is the fallback if the drivers lane cannot take A soon."
        }
      ],
      "out_of_scope_findings": [
        "class: a · reach: public door bin/run-dev.js migrate apply --yes (head 05e9a8e09a; base unchanged by this PR) with a declared archive (driver sqlite) whose file is a text file: native probe DATABASE_ERROR cause SQLITE_NOTADB -> dev step-down -> wasm quarantines it (archive.db renamed to archive.db.corrupt-1792519404778, a fresh 16384-byte archive.db written), \"datasource 'archive': connected\", exit 0, under the warning \"native better-sqlite3 unavailable (ABI mismatch or not built)\". Seam: runtime: service-datasource resolveSqliteDriver native catch -> driver-sqlite-wasm WasmSqliteConnection.quarantineCorruptFile; producer driver-sql SqlDriver.applyAutoVacuumIncremental swallows SQLITE_NOTADB. Same family as #24950 (a step-down on a failure that is not the addon), so it belongs on this card's family or as a sub-issue, not a lone card (see open_questions[0]). Dedupe words: \"SQLITE_NOTADB step-down\", \"wasm quarantine corrupt rename declared datasource\", \"not a database dev fallback\", \"corrupt-TIMESTAMP connected\".",
        "class: a · reach: public door bin/run-dev.js migrate plan (head 05e9a8e09a) with an object bound to an archive (driver sqlite) on a path under a regular file: \"datasource 'archive': connected\", \"Planned 1 declared datasource(s) … archive (…/plain/archive.db) — connected under the declared name\", \"+ os24950_note [create_table, 8 column(s)]\", exit 0. The plan proposes DDL into a file that cannot exist, and the same boot with --apply now refuses it (this PR). Mechanism: the read-only boot (readOnlyProbe -> sqliteAbsentFile empty-in-memory, used by migrate plan and the dry runs such as audit-mail-bodies without --apply) asks driver-sql resolveSqliteAbsentFileTarget, whose existsSync(path) is false for an unopenable path, so it opens an empty in-memory SQLite and no probe failure ever occurs. It is NODE_ENV-independent by code reading (production entry not measured). It is likely also the dry-run half of the card's own audit-mail-bodies path, NOT MEASURED. Seam: runtime: driver-sql resolveSqliteAbsentFileTarget -> cli schema-migrate DeclaredDatasourceConnectionPlugin readOnlyProbe. Fix lives in packages/drivers/** (an absent file whose directory cannot be made or opened is not absent). Dedupe words: \"read-only probe unopenable sqlite connected\", \"sqliteAbsentFile empty-in-memory path under regular file\", \"migrate plan unopenable datasource create_table\", \"resolveSqliteAbsentFileTarget existsSync\".",
        "carrier: none — with a native addon that cannot load, the wasm rung's probe is connect() alone, and connect() swallows the open error (applyAutoVacuumIncremental), so wasm resolves over a file it cannot read (a directory as the file -> first query DATABASE_ERROR cause EISDIR; a new file in a read-only directory). Measured only through resolveSqliteDriver on base, with no public-door reach (it needs a broken addon). It is the addon-cannot-load path this card is barred from changing. In the PR Acceptance notes.",
        "carrier: none — os db clean on an unopenable existing target now prints the driver's SqliteDatabaseFileError text (names the file) instead of its own \"native and wasm both failed to load\". Noted in Acceptance notes, not filed."
      ]
    }

    Generated by Claude Code

  3. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    ACCEPT → PR #24955 comment 6298790582 · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T18:59Z


    Generated by Claude Code

  4. objectstack-fleet commented on Oct 20, 2026

    @objectstack-fleet
    ContributorAuthor

    Landed · domain:services seat 1 · session_011TdZ8GjixNZFLz1xJD9zzK · 2026-10-20T19:24Z

    reading result
    PR #24955 timeline added_to_merge_queue 2026-10-20T19:01Z → merged 2026-10-20T19:24Z
    origin/main by content squash 5d27d326c2 (one parent, 2941a1929d), 3 files. sqlite-driver-fallback.ts carries isSqliteFileOpenFailure and the native-rung re-throw
    closing references the PR body closes #24950 only

    State change in this stroke: pm:dispatched off and the assignee cleared. The card closed completed on the merge.

    What changes for users.

    • A SQLite datasource whose file cannot be opened or created now fails its connect in development as in production, naming the file and the operating system's answer. Before, it stepped down to an empty in-memory engine.
    • A native addon that cannot load still steps down, with the same warning (ACCEPT 6298790582).

    Follow-ups, filed for domain:engine and unlabelled for triage:


    Generated by Claude Code

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions