Skip to content

fix(driver-sql): a read-only boot refuses a SQLite datasource whose file can be neither opened nor created, instead of reporting it connected - #25007

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24961-readonly-probe-unopenable-path
Oct 21, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/issue-24961-readonly-probe-unopenable-path

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Fixes #24961

Clause-②: no (narrowing)

The narrowing face: a read-only boot (sqliteAbsentFile: 'empty-in-memory', which os migrate plan and the read-only dry runs use) that answered datasource 'NAME': connected for a SQLite path that can be neither opened nor created now reports that datasource failed, naming the file. No exported declaration changes. The review of record is owed at CONTRACT_REVIEW_TIER on that face.

Dev session session_01QK6pdbxKZiHdFApL8RYVh9 (a mode:subagent run), dispatched by the domain:engine seat 2 under claim 6303642231.

What was wrong

resolveSqliteAbsentFileTarget (packages/drivers/driver-sql/src/sql-driver.ts) is the one place the "does the SQLite target exist?" judgement is made. Under 'empty-in-memory' it answered :memory: whenever existsSync(filename) was false. existsSync is false for every path it cannot stat, not only for an absent one. So a path under a regular file read as absent, the read-only boot opened an empty in-memory database over it, and the datasource read connected. The boot that writes refuses the same path in SqlDriver.connect().

What changed

H1: what the filesystem answers, measured before the change

Measured on Node v22.22.0 with a probe script (deleted), as root and as uid 65534 (setpriv). Nothing was written.

path existsSync stat(file) nearest existing level access(W_OK and X_OK) there
under a regular file (plain/archive.db) false ENOTDIR n/a n/a
deeper (plain/sub/archive.db) false ENOTDIR n/a n/a
file in a 0700 dir that is not ours (uid 65534) false EACCES n/a n/a
absent file in a 0555 dir false ENOENT the dir root: ok; uid 65534: EACCES
/proc/os-24961/archive.db false ENOENT /proc root: ok, though its mkdir answers ENOENT (the #23696 reading); uid 65534: EACCES
/opt/claude-code/os-24961/archive.db (ro mount) false ENOENT /opt/claude-code EROFS for both
absent file, existing or creatable dir false ENOENT a writable dir ok
a dangling symlink as the directory (measured by its pin) false ENOENT stat passes over the link to its parent; lstat still finds the link ok on the parent

stat already separates "absent" (ENOENT) from "cannot exist" (anything else). access on the nearest existing level covers permission and read-only mounts.

Public door, before and after

bin/run-dev.js migrate plan --database-url file:main.db in a throwaway project. dist/objectstack.json declares archive (driver: 'sqlite') and binds one object, os24961_note, to it. The driver-sql dist/ was built from base 4b8f597ba for "before" and from 3370501f9 for "after". The parent directory listing was compared before and after each run.

archive filename before (base) after
…/plain/archive.db (under a regular file) datasource 'archive': connected; Planned 1 declared datasource(s) … archive (…/plain/archive.db) — connected under the declared name; + os24961_note [create_table, 8 column(s)]; exit 0 datasource 'archive': connect failed — SQLite database file '…/plain/archive.db' cannot be opened or created: ENOTDIR: not a directory, stat '…/plain/archive.db'. (1 object(s) bind to it explicitly (os24961_note) … fail-fast per ADR-0062 D5); nothing planned; exit 1
…/plain/sub/archive.db not run at the door on base the same refusal, ENOTDIR; exit 1
…/link/archive.db (a dangling symlink as the directory) connected, planned create_table, exit 0 connect failed — … cannot be created: its directory '…/link' cannot be made ('…/link' is a symbolic link to a path that does not exist): ENOENT …; exit 1
…/ok/archive.db (absent, directory exists) not run at the door on base (the existing behaviour, pinned) connected, + os24961_note [create_table, 8 column(s)], exit 0, file not created
…/ok/new/deeper/archive.db (absent, directories missing) not run at the door on base (the existing behaviour, pinned) connected, planned, exit 0, no directory created

Every run, before and after, left the listing unchanged. The writing boot over the same path (migrate apply --yes, measured on base; this PR does not touch that path) answers connect failed — … cannot be created: its directory '…/plain' could not be made: EEXIST …, exit 1. The two boots now agree. Their codes differ (ENOTDIR here, EEXIST there) because the read-only judgement asks stat where the writing boot asks mkdir. Each is the system's own answer.

Resolver answers on the built dist/ after the fix, as uid 65534: a 0555 directory gives EACCES ("cannot be opened or created"), one level deeper gives EACCES ("its directory … cannot be made"), /proc/os-24961/… gives EACCES, and the read-only mount gives EROFS. As root, the 0555 directory and /proc/os-24961/… read absent. Nothing was written in any case.

Pins

packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts (relative src import), a new describe block:

  • A path under a regular file, at either depth: SqliteDatabaseFileError, code ENOTDIR, filename the file. The directory listing is unchanged.
  • A dangling symlink as the directory: refused (ENOENT). The link is still dangling, and its target was not created.
  • A 0555 directory as a non-superuser: EACCES, at both depths, and nothing created (skipIf superuser). As the superuser the same directory reads absent, because the writing boot can create there (runIf superuser). CI's runner is not root, so CI runs the first. This container runs as root, so here it ran the second, and the first was measured on dist/ as uid 65534 (above).
  • CONTROL: an absent file in an existing directory, and under missing directories, still answers :memory: and writes nothing.
  • 'create' and the default hand the path on unchanged.
  • new SqlDriver({ … sqliteAbsentFile: 'empty-in-memory' }) over the path throws the same error. Without the option, the same driver's connect() refuses the same file. Nothing is written.
  • A pg driver with a connection string keeps its string and answers sqliteOpenedEmptyInMemory: false. This is an assertion added to the existing "inert for non-sqlite dialects" case.

packages/services/service-datasource/src/__tests__/sqlite-driver-fallback-readonly-unopenable.test.ts (new; it resolves @objectstack/driver-sql through dist/, a KNOWN_UNALIASED_TEST_IMPORTS pair). Real better-sqlite3, real wasm SQLite, real files. An addon that cannot load is faked where better-sqlite3 loads it, the same way as the #24955 file:

  • resolveSqliteDriver({ sqliteAbsentFile: 'empty-in-memory' }) under a regular file, on the development native rung, the development wasm rung, and production: each rejects with SqliteDatabaseFileError / ENOTDIR. It never resolves an engine, never warns, and writes nothing.
  • An absent file that can be created: the native rung opens empty in memory (sqliteOpenedEmptyInMemory: true), and the wasm rung opens :memory: (engine: 'sqlite-wasm'). Nothing is on disk for either.
  • The read-only boot os migrate plan composes (createDefaultDatasourceDriverFactory({ sqliteAbsentFile: 'empty-in-memory' })): create() rejects with the refusal (code asserted). Through DatasourceConnectionService.connectDeclared, in development and in production, the datasource reads failed, its reason names the file, no driver is registered under archive, and nothing is written.

This pin file is the only service-datasource line in the diff. It is a test, outside the package's files[], and the cross-lane declaration to domain:services is the seat's.

Ablation

Both legs went through scripts/ablation-replace.mjs (WRAP), each with a trap restore on absolute paths. Each restore was proven by blob equality with HEAD, an empty git diff HEAD, and an empty git status --porcelain.

  • Leg 1, the judgement. The probe's first answer was replaced by plain existsSync semantics: stat succeeds means exists, anything else means absent. The marker identifier was ABLATION_24961. Anchor 1 to 0. Blob fbde48ea8f70 became fb3ea19400da and then returned to fbde48ea8f70. Then pnpm --filter @objectstack/driver-sql build, and ablation-dist-preflight found the marker in 2 built files. Predicted: driver-sql 3 failed, 12 passed, 1 skipped; service-datasource 6 failed, 22 passed. Observed the same. Red in driver-sql: under a regular file, the dangling link, and the constructor. Red in service-datasource: the native, wasm and production rungs, the factory, and the service in development and in production. The absent-file controls, the fix(service-datasource): in development, a SQLite file that cannot be opened or created fails its connect instead of stepping down to an in-memory engine #24955 file (9) and sqlite-driver-fallback.test.ts (11) stayed green. After the restore: rebuild, and --absent found the marker in none of the 6 built files, with the tree clean. The first attempt at this leg was refused by the tool before anything ran: its anchor sat inside its own replacement, so the anchor count could not drop. Nothing was built or tested on that attempt. The anchor was changed and the leg re-run.
  • Leg 2, the SQLite-client gate in knexConfigFor. || !this.isSqlite was removed. Blob dea47a1a378e became 38af381b3aac and then returned. The subject is a relative src import, so this leg has no dist step. Predicted and observed: 1 failed ("is inert for :memory: and for non-sqlite dialects"), 14 passed, 1 skipped.

Local verification

Builds:

  • Base closure for the before-door: turbo run build --filter='@objectstack/cli^...' --concurrency=2, 60 of 60.
  • After merging origin/main (4fe3af9d7, merge head 28d5dd67e): pnpm install --frozen-lockfile, then turbo run build --filter='@objectstack/cli^...' --filter=@objectstack/cli --concurrency=2, 61 of 61.

Tests:

  • Full driver-sql suite (pnpm --filter @objectstack/driver-sql exec vitest run --maxWorkers=2) on 02302719e: 225 files passed, 12 skipped; 3698 tests passed, 210 skipped. The skips include the live PG and MySQL cells (no live server here) and this file's superuser split.
  • Full service-datasource suite on 02302719e: 51 files, 916 passed.
  • On the merge head 28d5dd67e: the driver-sql pin file gave 15 passed and 1 skipped; the three service-datasource fallback files gave 28 passed.

Merge state: origin/main was merged at 4fe3af9d7. Since then main has gained fbb42d900 (plugin-auth) and 86d236071 (rest), which touch none of this diff's packages; the queue rebuilds on current main. Against #24960's branch head 6d3dd6247 (fetched into a ref of my own), git merge-tree reports no conflict: that branch edits SqliteDatabaseFileError's doc block, which this diff leaves alone.

Typecheck:

  • pnpm --filter @objectstack/driver-sql typecheck passed, and its tsc --listFiles includes the edited test file.
  • pnpm --filter @objectstack/service-datasource typecheck: the first run (02302719e) failed on the new pin file (a .then on create()'s union return type). It was fixed in 7c66e98f6, and it passes there and on 28d5dd67e.

Driver ledger (pnpm check:driver-conformance), before (4b8f597ba) and after (28d5dd67e): 55 covered cell(s), 0 in the DEBT ledger, 0 exempt both times. The dialect axis is unchanged: 9 suites, 11 of 11 dialect-scored cells matrix-routed. The ledger did not move.

Gates:

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack (no paths) derived 72 commands at the merge head 28d5dd67e. The derivation's first line names objectstack-ai/objectstack at 28d5dd67e. The PM's list at e4d604efd had 55, and the 17 extra come from this diff's changeset and its new service-datasource test. All 72 ran one at a time, each exit code captured before any pipe.

  • First pass: 70 exited 0, and 2 exited 3 (PREREQUISITE NOT MET).
    • node scripts/check-plugin-teardown-shape.mjs --self-test: its positive-control commit 621a48760788 was outside this shallow clone. I fetched that one commit into a ref of my own (--depth=1) and re-ran: exit 0, 48 cases.
    • pnpm check:dual-build-cjs-loads: ten packages outside the CLI closure had no dist/. I ran turbo run build --filter='!@objectstack/docs' --concurrency=2 (76 of 76) and re-ran: exit 0.
  • --ran over the first pass: 72 derived, 70 run, 2 NOT-MEASURED, 0 UNRUN. Over the final record: 72 derived, 72 run, 0 NOT-MEASURED, 0 UNRUN, and the tool exited 0.
  • After the merge (packages/spec moved on main's side), pnpm --filter @objectstack/spec check:generated: all 14 generated artifacts up to date, exit 0.
  • test:repo (the vitest repo project) was not run locally: the diff touches none of its tests, and the derivation does not name it. It is declared to CI (Test Core).
  • Every family CI runs is read from this PR's check-runs, not re-run here.

Acceptance notes

  • Boundary, measured: one shape still reads absent without a write. /proc/MISSING/… as root: access answers ok, and only mkdir (ENOENT) would say no. The governing text, "'empty-in-memory' leaves the filesystem untouched", rules out finding out by trying. So the read-only boot still opens it in memory, and the writing boot still refuses it. As a non-root user it is refused (EACCES on /proc). The probe's doc names this.
  • Telemetry sibling on a read-only boot. provisionTelemetryDatasource wraps resolveSqliteDriver in a best-effort try. Over an unopenable sibling path, the read-only boot now gets no sibling, as the writing boot already did, where before it got an in-memory one. The sibling sits next to the primary, and an unopenable primary already fails the boot first. This is by code reading only, not measured at a door.
  • NODE_ENV=production: pinned at the service layer (dev: false, both the resolver and the connection service). The production entry bin/run.js was not driven: NOT MEASURED at that door.
  • os migrate audit-mail-bodies without --apply: its archive arm exists only on PR feat(cli,plugin-audit): os migrate audit-mail-bodies masks the archive datasource too #24934, which is unmerged. NOT MEASURED. Its read-only boot builds its datasources through the same factory, so the factory pins are its mechanism.
  • Not addressed here, in the reporting seat's hands: the writing boot over a dangling symlink as a declared datasource's directory. Measured on base and unchanged by this PR: migrate apply --yes prints datasource 'archive': connected, then a false "native better-sqlite3 unavailable (ABI mismatch or not built)" step-down warning, and exits 1 later. The driver's makeDatabaseDirectory accepts EEXIST on the link. better-sqlite3's code-less "directory does not exist" TypeError is not one of the codes connect() refuses on. The report carries it as a finding.

Generated by Claude Code

… path that can be neither opened nor created

resolveSqliteAbsentFileTarget answered ':memory:' whenever existsSync was
false, and existsSync is false for every path it cannot stat: a path under a
regular file (ENOTDIR), a directory this process may not search (EACCES). So a
read-only boot (os migrate plan, sqliteAbsentFile: 'empty-in-memory') opened
an empty in-memory database over a file that cannot exist and reported the
datasource connected, while the boot that writes refuses the same path.

The judgement now asks the operating system without writing anything
(probeAbsentSqliteDatabaseFile, the read-only twin of makeDatabaseDirectory)
and throws SqliteDatabaseFileError naming the file and the system's answer.
knexConfigFor asks it for a SQLite client only.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…enable SQLite path on every rung; changeset

The read-only boot os migrate plan composes (createDefaultDatasourceDriverFactory
with sqliteAbsentFile: 'empty-in-memory') reports a declared SQLite datasource
under a regular file as failed, naming the file, and registers no driver; the
native rung, the wasm rung and production all reject with the driver's
SqliteDatabaseFileError (ENOTDIR), and an absent file that can be created
still opens empty in memory on both rungs with nothing written.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…wn from on the probe, leaving SqliteDatabaseFileError's own doc as it stands

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
… refusal pin, so tsc reads it

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
…adonly-probe-unopenable-path

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/m documentation Improvements or additions to documentation tests tooling labels Oct 21, 2026
@github-actions

github-actions Bot commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/driver-sql, touching 6 documentable anchor(s).

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

  • content/docs/data-modeling/drivers.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/data-modeling/queries.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/permissions/tenant-audit-census.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/plugins/packages.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/index.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/kernel/lifecycle.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/query-syntax.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/protocol/objectql/types.mdx (via SqlDriver (symbol, a top-level class))

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

  • content/docs/releases/v17/17-0.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-5.mdx (via SqlDriver (symbol, a top-level class))
  • content/docs/releases/v17/17-6.mdx (via SqlDriver (symbol, a top-level class))

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 54 of 206 client-bound route-ledger rows — the other 152 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 152: 0 are remediable by widening that discovery convention (an in-repo file declares the path; the convention did not scan it); 55 are structural — on a ledger where NOT ONE row is declared in-repo, so no discovery change reaches them at any price; 97 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.
  • a key NAME is not a key, so the hand re-read the line above prescribes can land on the wrong schema. The same spelling is authorable on one governed type and a [REMOVED] tombstone on another for each of active, aria, joins, objects, template, tools and version (censused on [finding] tools is a key on BOTH AgentSchema (tombstoned, dead) and SkillSchema (live, cloud-attested), so a name-based search attributes skill examples to the agent key — it produced a false stop-the-line alarm on PR #19059 #19093 over the liveness ledger's governed types, top-level keys); nothing in a search result distinguishes the two, so a grep hit on a LIVE example reads as evidence about the DEAD key. Measured on fix(spec): the agent.tools liveness row says dead — it claimed live on a key the schema tombstoned #19059: content/docs/ai/agents.mdx was reported as contradicting the agent.tools tombstone over its tools: example at :161, which is inside the defineSkill({ block opened at :155 — the page was already correct. Settle ownership by PARSING the value against both schemas, never by the name: that literal PASSES SkillSchema, and as an AgentSchema it FAILS at tools with the tombstone prescription. ⛔ These names are not the whole class — a key retired through a .strict() guidance map leaves no tombstone in the walked shape and none of them here (tool.category, live as AIToolDefinition.category).

Coarse fallback — 11 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 62bc1293080968f0ec8c3d31dc1f9ed8da4077df → packageMentionDocs.

Which tree this was computed on

This run read content/docs from 1c9342d044774d69aa0f2dd662f0f15188b07753 — the merge of head 86d0cbfb7eae953f633478be25b462a8bc5ae8ec into base 62bc1293080968f0ec8c3d31dc1f9ed8da4077df, 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 1c9342d044774d69aa0f2dd662f0f15188b07753 && git checkout 1c9342d044774d69aa0f2dd662f0f15188b07753
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 62bc1293080968f0ec8c3d31dc1f9ed8da4077df 86d0cbfb7eae953f633478be25b462a8bc5ae8ec && git checkout -B drift-repro 62bc1293080968f0ec8c3d31dc1f9ed8da4077df && git merge --no-ff 86d0cbfb7eae953f633478be25b462a8bc5ae8ec

node scripts/docs-audit/affected-docs.mjs --json 62bc1293080968f0ec8c3d31dc1f9ed8da4077df

⚠️ 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 62bc1293080968f0ec8c3d31dc1f9ed8da4077df → pass the list as
args.docs, on the commit named under Which tree this was computed on.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Seat review · domain:engine seat 2 · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T00:53Z · head 28d5dd67e · verdict: REWORK, patch round 1 (one item).


Generated by Claude Code

…the launch window ships a BREAKING accept-set narrowing

The changeset declared Clause-② no (narrowing) and graded driver-sql patch, which the Check Changeset level axis refuses: a narrowing is BREAKING, and during the launch window a BREAKING change ships minor. Raise driver-sql to minor and say so in the BREAKING banner, in the wording the other launch-window narrowings use. No other file changes.

Claude-Session: https://claude.ai/code/session_01QK6pdbxKZiHdFApL8RYVh9
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 86d0cbfb7eae953f633478be25b462a8bc5ae8ec
Local-runs: none
Scope: packages/drivers/driver-sql/src/sql-driver.ts, packages/drivers/driver-sql/src/sqlite-database-file.ts, packages/drivers/driver-sql/src/sql-driver-sqlite-absent-file.test.ts, packages/services/service-datasource/src/tests/sqlite-driver-fallback-readonly-unopenable.test.ts + .changeset/24961-readonly-probe-unopenable-path.md, packages/drivers/driver-sql/src/index.ts, packages/drivers/driver-sql/package.json, packages/services/service-datasource/src/sqlite-driver-fallback.ts, packages/services/service-datasource/src/default-datasource-driver-factory.ts, packages/services/service-datasource/src/datasource-connection-service.ts, packages/services/service-datasource/package.json, packages/runtime/src/default-datasource-plugin.ts, packages/runtime/src/standalone-stack.ts, packages/cli/src/utils/schema-migrate.ts, packages/cli/src/utils/telemetry-datasource.ts, scripts/check-test-source-alias.mjs, scripts/check-changeset-no-major.mjs, scripts/check-adr-0087-registration.mjs, and on origin/main the changesets 24707-pg-multischema-introspect.md, 24813-adopt-orphans-readonly-package.md and 24949-migrate-apply-confirmation-exit.md

① Derived judgments

Inputs: card #24961's body and its three comments (claim 6303642231, dev reports 6304625148 and 6305394134); PR #25007's body and file list (5 files, +483/-6); the net diff origin/main...86d0cbfb7 read locally (merge base 4fe3af9d7, the same 5 files, +483/-6); the check-runs on the head. The seat's own review comments on the PR were not read. record-recognisers --paths over the five paths decides no face on its own and defers to the changeset's Clause-②: line, which carries the (narrowing) arm: the narrowing face, so the floor is every path outside the prose faces.

  1. resolveSqliteAbsentFileTarget, a value export of the @objectstack/driver-sql entry (src/index.ts). Under 'empty-in-memory', a path whose stat answers anything but ENOENT, a directory level a dangling symbolic link holds, or a nearest existing level that refuses access(W_OK | X_OK) now throws SqliteDatabaseFileError (name, code, filename) where it answered { filename: ':memory:', openedEmptyInMemory: true }. New throws on inputs the published function accepted: a narrowing of its accept set, and declared as one. The PR body and the changeset both carry Clause-②: no (narrowing); the changeset carries the BREAKING banner, FROM, TO (naming the error and each code), the one-line Fix and an ADR-0087 disposition. Signature and return type unchanged. Right. SqliteDatabaseFileError itself is not re-exported from the entry, which is pre-existing (the writing boot's connect() throws the same unpublished class, runtime: a declared sqlite datasource whose file cannot be created never settles its connect, so the boot is refused only by a plugin start timeout (30 s, 120 s after PR #23694) that names neither the datasource, the path nor the open error #23696); consumers recognise it by code (isSqliteFileOpenFailure) and name, and the changeset's TO names exactly those. No new surface.
  2. new SqlDriver({ ... sqliteAbsentFile: 'empty-in-memory' }) on a SQLite client: the constructor now throws the same error through knexConfigFor, before any Knex instance is built. The same narrowing under the same declaration (TO: "and so the SqlDriver constructor, throws"). Right.
  3. knexConfigFor gains || !this.isSqlite. isSqlite reads this.config, which the constructor assigns before the call, so the getter is safe at that point. Under the option, a non-SQLite client's connection string or filename is no longer rewritten to :memory: and sqliteOpenedEmptyInMemory stays false. This enforces SqlDriverConfig.sqliteAbsentFile's standing doc ("Ignored for :memory: and for non-SQLite dialects"); the prior behaviour contradicted it and produced an unusable config. The changeset names it (the last sentence of its "Unchanged" bullet: the heading is loose, the sentence states the change), it is pinned (the pg URL assertion) and ablated (leg 2). No exported declaration moved. Right.
  4. probeAbsentSqliteDatabaseFile carries export in sqlite-database-file.ts but src/index.ts does not re-export it, and package.json exports has the . entry alone over dist/index.*: package-private, not published. The PR's "module-private" is loose wording; its claim "No exported declaration changes" holds. SqliteAbsentFileMode and SqlDriverConfig change in doc only. Nothing else published moved.
  5. Reach, read in the callers:
    • Service layer: resolveSqliteDriver evaluates wasmFilename = resolveSqliteAbsentFileTarget(...) before if (!dev) and before any rung, and the factory's sqlite arm has no catch, so create() rejects on the native rung, the wasm rung and in production; DatasourceConnectionService.handleFailure records failed under the declared name. Pinned by the new service test in development and production, with the listing unchanged. No line of sqlite-driver-fallback.ts changed, as claimed. Right, in the declared narrowing.
    • Runtime host primary: createStandaloneStack({ sqliteAbsentFile: 'empty-in-memory' }) (schema-migrate.ts line 1313) reaches standalone-stack.ts line 989, whose existsSync only skips the mkdir prep and records no verdict; the driver is then built through the same factory by DefaultDatasourcePlugin, whose connect is bootCritical, so a read-only boot whose PRIMARY file is unopenable now fails fast naming the file, where it planned over an empty in-memory database. Within the changeset's TO ("The datasource's connect fails with it"), and the writing boot's primary already refused that shape. NOT MEASURED at that door by the dev (the door rows were a declared archive); the resolver pins are its mechanism. Right as declared; named so the seat knows the reach.
    • CLI telemetry sibling: provisionTelemetryDatasource wraps the resolver in a best-effort try and answers no sibling, the answer the writing boot already gives there. By code reading, as the PR says. Right; no contract moved.
    • The service pin's unaliased @objectstack/driver-sql import is a registered pair in KNOWN_UNALIASED_TEST_IMPORTS (check-test-source-alias.mjs, the @objectstack/service-datasource row). Both new test files read their own tmpdir scratch alone, so check:cross-package-test-inputs is not implicated. Both sit outside files[] (dist, README.md, CHANGELOG.md) in both packages.
  6. Error-code semantics are declared. The dangling-link refusal carries code: 'ENOENT' (the changeset's TO lists it), which isSqliteFileOpenFailure's ^E[A-Z0-9]+$ classifies as a file-open failure, so the dev rung does not step down to wasm over it: pinned. The superuser asymmetry (a 0555 directory reads absent as root, since the writing boot can create there) and the one shape the probe cannot see without a write (/proc/MISSING as root) are stated in the test and in the probe's doc block. Walk soundness: a regular file at any ancestor answers ENOTDIR from the first stat, so the walk never lands on a non-directory level, as its doc says.
  7. Governance: none of the five paths is a governed surface (Governed Surface Queue Guard is success on the head); the head repo is objectstack-ai/objectstack, not a fork; 489 changed lines; no skip-changeset label (labels: documentation, size/m, tests, tooling).

② Semver level

Clause-②: no (narrowing)

  • @objectstack/driver-sql is graded minor in .changeset/24961-readonly-probe-unopenable-path.md. The PR body's Clause-②: no (narrowing) line and the changeset's agree; a (narrowing) arm is BREAKING, and check-changeset-no-major.mjs's launch-window convention ships a breaking change as minor, with the BREAKING banner and the ADR-0087 disposition as the mandatory carriers. Both are present (not-required (no-migration-prescription), with the FROM, TO and one-line Fix the Post-Task Checklist asks of a breaking changeset). That gate's LEVEL AXIS refuses a moved package graded patch under that declaration, which is what reddened the previous head 28d5dd67e; on this head Check Changeset is success. The head differs from 28d5dd67e in that changeset alone (+2/-2, read locally), so every code judgment above is of the same tree the dev's suites ran on.
  • @objectstack/service-datasource gains one test outside files[] and publishes nothing: no changeset owed, none written. Correct.
  • The changeset matches what the diff publishes. One prose staleness: the PR body's "What changed" bullet still says @objectstack/driver-sql patch; the gates read the Clause-②: line and the changeset, not that bullet. The seat that writes the body may change the word to minor.

Check-runs on the head, read once at this record's write and not awaited, exactly as they stand: success 19 (Check Changeset, Governed Surface Queue Guard, Check PR Size, Lint · PM dispatch-gates, Lint · docs and code guards, Lint · repo and tooling gates, the three Lint · scoped jobs, Type Check · source gates, the four claim and closing-keyword guards, Check Documentation Links, Flag docs affected by code changes, Dogfood Regression Gate, Auto Label, filter); skipped 7 (the two opt-in harnesses, Build Docs, Console Pin Gate, the Dogfood shards, Dogfood Verify CLI, Temporal Conformance); in_progress 12 with no conclusion yet: Build Core, Test Core 1/6 through 6/6, Lint · CI release and engine gates, Lint · ESLint, Type Check · consumer gates, Type Check · debt ledger, Type Check · workspace. Of the five required contexts, Governed Surface Queue Guard is green and Build Core, Test Core, Lint & Repo Gates and TypeScript Type Check have no verdict on this head as of this read. This record is the contract review; the queue is armed on green alone.

③ Boundary flags

Both dev reports carry open_questions: []. Every deviation and finding that bears on the contract, answered or escalated:

  1. The walk placed in sqlite-database-file.ts beside makeDatabaseDirectory rather than inline in sql-driver.ts: accepted. Same package, package-private, the judgement is still made in the claimed resolver, and the read-only twin belongs beside the writing one.
  2. knexConfigFor's || !this.isSqlite: accepted. On the claimed surface, it enforces the documented contract, and it is pinned and ablated (① item 3).
  3. Dangling symbolic link as a declared datasource's directory: the read-only boot refuses it (under the card's "directory cannot be made"); the writing boot still answers connected-on-wasm (dev report 1, out-of-scope finding 1, class a, with evidence and dedupe words). The asymmetry on that one shape is declared in the PR and is outside this card, whose claim excludes the connect-classification sibling. Escalated to the owning seat: the finding has no carrier and asks to be folded into the SQLite connect-refusal family's closing card (driver-sql: a declared SQLite file that is not a database (SQLITE_NOTADB) is accepted by connect(); on a development boot the wasm rung then renames it to FILE.corrupt-TIMESTAMP, writes an empty database in its place, and reads connected #24960, 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). Prime Directive 10 wants it folded or filed; it does not bear on this PR's verdict.
  4. /proc/MISSING as root still reads absent read-only (finding 2): accepted as a documented boundary. 'empty-in-memory''s own contract forbids the mkdir that would answer, the probe's doc block and the PR's acceptance notes name it, and the writing boot refuses it. No carrier owed beyond the code doc.
  5. The changeset summary lacks the conventional ! the three launch-window siblings on main carry (24707, 24813, 24949): not a contract matter. check-adr-0087-registration.mjs reads the bang as one of several interchangeable breaking signals (banner, Clause-② arm, bang) and read this changeset as declared-breaking from the first two; Check Changeset is green. An optional uniformity edit for the seat; a push would move the head and void this record, so it is not asked for.
  6. The stale patch word in the PR body bullet (dev report 2's finding): answered in ②; prose only.
  7. The level-axis verdict in dev report 2 came from a payload the dev built: superseded by CI's own Check Changeset success on this head.
  8. The ablation attempt refused by the tool before anything ran, the two gate families re-run after their prerequisites were met, the temporary refs fetched into the shared .git and deleted, the door rows not run on base (marked in the PR), the full suites at 02302719e with targeted re-runs at 28d5dd67e, attribution per AGENTS.md's model-free trailer pair, and cleanup: process notes with no contract effect; none contradicts the diff.
  9. Own flag: the runtime host primary's read-only reach (① item 5) is within the declaration but NOT MEASURED at os migrate plan's primary door; the resolver and service pins are its mechanism. For the seat to measure in dogfood if it wants a door row; not blocking.
  10. Own flag, by code reading and unmeasured: a dangling link as the FILE itself (not its directory) answers ENOENT to the first stat, walks from its directory, and reads absent; the writing boot creates through the link when the link's target directory exists (consistent) and would answer SQLITE_CANTOPEN when it does not. Outside the card's named shapes; for the same family card as item 3.
  11. The seat's cross-lane declaration to domain:services for the test-only service-datasource file stands in the claim; nothing in that package's published source moved.

Implemented-by: claude/issue-24961-readonly-probe-unopenable-path
Reviewed-by: session_01QK6pdbxKZiHdFApL8RYVh9

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

席位核对

Seat review · domain:engine seat 2 · session_01QK6pdbxKZiHdFApL8RYVh9 · 2026-10-21T01:36Z · head 86d0cbfb7 · verdict: ACCEPT

  • Shape: a draft PR to main from the claimed branch claude/issue-24961-readonly-probe-unopenable-path; the body's only closing keyword is Fixes #24961, the card the claim (6303642231) names. mergeable_state: clean.
  • Premise, measured: the dev's door run shows os migrate plan answering "connected" and planning create_table on base, and "connect failed … ENOTDIR … fail-fast per ADR-0062 D5" with exit 1 on the branch, listing unchanged (PR body). The seat's own reading of origin/main matched before dispatch (resolveSqliteAbsentFileTarget used existsSync).
  • Gates on this head: 41 check-runs, 34 success and 7 skipped. All 7 skips are in main's roster (check-expected-skips.mjs --pr 25007: "OK — 7 skipped check-run(s), every one in main's roster"). The five required contexts Lint & Repo Gates, TypeScript Type Check, Build Core, Test Core and Governed Surface Queue Guard are success. check-governed-merges.mjs --pr 25007: NOT governed, 489 changed lines.
  • Contract review: CONTRACT_REVIEW_TIER, on the narrowing face, PASS on this head (6305566864).
  • Accepted deviations: probeAbsentSqliteDatabaseFile placed in sqlite-database-file.ts, package-private, not re-exported; the knexConfigFor SQLite-only gate (pinned, ablated, named in the changeset). Changeset at minor after patch round 1 (6305259004). One prose staleness is left, not pushed: the body's "What changed" bullet still says patch, while the changeset and the gate say minor. The changeset is the record.
  • Out of scope, where it went: the writing boot over a dangling-symlink directory, and the dangling link as the file itself (the review's own flag 10), went to the family's closing card driver-sql: two more SQLite file failures read connected on a declared datasource — SQLITE_CORRUPT (a truncated header) is swallowed at connect, and a dangling-symlink directory passes makeDatabaseDirectory; on a development boot both step down to wasm #25019 (6305577138). The /proc/MISSING-as-root boundary is documented in the probe's doc block and the Acceptance notes; no card.
  • Landing: this seat flips the PR to ready and arms auto-merge through the relay now, and follows it to MERGED. Sibling PR fix(driver-sql): a SQLite file that is not a database fails connect() at once, naming it, and a dev boot no longer renames and replaces it #25006 shares sql-driver.ts and sqlite-database-file.ts in other regions; the queue merges each on the other's result.

Generated by Claude Code

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

Labels

documentation Improvements or additions to documentation size/m tests tooling

Projects

None yet

2 participants