Skip to content

fix(objectql)!: floor a plugin driver on an external datasource — the engine applies the query, up to 1000 rows - #27403

Merged
objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-mlrxgx
Oct 29, 2026
Merged

objectstack-fleet[bot] merged 6 commits into
mainfrom
claude/project-thread-mlrxgx

Conversation

@objectstack-fleet

Copy link
Copy Markdown
Contributor

Requested by 田騏 · project thread

Fixes #27277
Clause-②: yes (two codes join ERROR_CODE_LEDGER under @objectstack/objectql: EXTERNAL_WRITE_REFUSED, EXTERNAL_ROW_CAP_EXCEEDED, both 422)
Metadata-protocol: none

维护者速读

  • 改了什么: 插件 driver 背后的外部数据源(schemaMode 非 managed,且 driver 不是内置 id)现在由引擎兜底。driver 只收到 where、orderBy 和 limit: 1001,引擎自己再把整条查询(过滤、排序、分页、投影)作用在返回的行上。超过 1000 行 ⇒ 422 EXTERNAL_ROW_CAP_EXCEEDED;远端对写入回 4xx ⇒ 422 EXTERNAL_WRITE_REFUSED;其余远端失败 ⇒ 503 ERR_DATASOURCE_UNAVAILABLE。
  • 为什么改: DriverCapabilities 没有任何下推声明(17.0.0 已退役),引擎无从得知插件 driver 执行了查询的哪一部分。一个忽略过滤的 driver 会静默返回错误的行。按裁决 Q1=B / Q2=A′ / Q3=A′ 落地。
  • 风险与回滚: 内置 driver、managed 数据源、ADR-0015 联邦 SQL 数据源都不受影响(测试钉住 getDriverByName 仍返回原实例)。受影响的只有插件 driver 外部对象:匹配超过 1000 行的读取从「静默返回」变成 422,变更集按 BREAKING 申报。回滚即 revert 两个提交。
  • 先看: packages/objectql/src/external-driver-floor.ts(Proxy 包装、上限读取、写入错误映射),以及 engine.ts 的 applyExternalDriverFloor(两个注册入口都调用它,drivers 映射里只放一个实例)。
  • 两分钟验证: pnpm --filter @objectstack/objectql exec vitest run --project local src/external-driver-floor.test.ts src/driver-query-in-memory.conformance.test.ts,预期 123 passed。
  • 需要你做的: spec 提交(账本两行)等 epic PM 的完整 Fable 审查。Acceptance notes 第 1、2 条是开放项,需要定夺是否另开卡。

What changes

  • external-driver-floor.ts (new): needsExternalDriverFloor(def) is true only for a non-managed datasource whose driver id resolveDriverId does not know. createExternalDriverFloor is idempotent per driver and returns a Proxy over it.
    • find, findOne and count send where, orderBy and limit: 1001, never offset or fields.
    • More than 1000 rows back ⇒ ExternalRowCapExceededError.
    • Otherwise the engine applies the whole query in memory. findOne is the same read with limit: 1; count is its length after where.
    • aggregate is hidden (both get and has), so the engine aggregates in memory.
    • Writes pass through. A remote 4xx (read from status, then statusCode) ⇒ ExternalWriteRefusedError; any other failure ⇒ DatasourceUnavailableError with the new reason remote, and the cause is logged at warn.
  • driver-query-in-memory.ts (new): applyDriverQueryInMemory(rows, query, { fieldClasses?, jsonStored? }) applies where, then orderBy, then offset/limit, then fields.
    • Nulls sort last in either direction.
    • A paged read appends an id tie-break in the last key's direction, as driver-sql does.
    • limit is read by presence, so limit: 0 returns no rows.
    • fields keeps id, and every row returned is a copy.
    • Keys the helper does not apply (groupBy, aggregations, having, search, searchFields, top) are refused by name.
    • A ReadableIds leaf is refused (ADR-0139 D3).
  • having-filter.ts: the walker gains a where position (matchesDriverWhere, refusal wording that names where). It adds $like and $ilike through the spec's own likePatternToRegExp, refusing a dangling escape, a NUL or a non-string pattern. having and the per-aggregation filter still refuse both operators.
  • external-driver-errors.ts (new): the two 422 classes, with status declared on the thrown error (the driver-connect-errors.ts pattern). driver-connect-errors.ts gains the per-request reason remote.
  • engine.ts: DatasourceDef gains driver, and registerDatasourceDef stores it. The private applyExternalDriverFloor(name) runs from both registerDriver and registerDatasourceDef, because either can come first. It replaces the map entry once, and unwraps it if a later definition no longer needs the floor. The registerDriver collision check compares the unwrapped drivers.
  • index.ts / core.ts: the new exports (listed in the changeset).
  • Spec, separate commit: two ledger rows under @objectstack/objectql in error-code-ledger.zod.ts. The closed ApiErrorSchema.code vocabulary gains both members, so the generated references contract.mdx, automation-api.mdx and error-code-ledger.mdx are re-recorded with gen:schema + gen:docs. check:api-surface reports the surface unchanged. shared/external-errors.ts is untouched, per the ruling.
  • packages/runtime/src/plugin-driver-external-object.spike.test.ts: the P0 spike pinned the pre-floor calls, so it is re-pinned to what the remote now sees.
    • find and count both arrive as find with limit: 1001, and findOne is answered from that find.
    • getDriverByName returns the floored instance, which unwrapExternalDriverFloor maps back to the driver.
    • A start_date tie on the paged list now breaks by id.

Tests

  • driver-query-in-memory.conformance.test.ts: the spec tables are the oracle. It covers FILTER_LOGIC_CASES, FILTER_TEXT_CASES (every rejection row in the ADR-0112 envelope), PAGINATION_CASES (walked page by page), PAGINATION_UNORDERED_CASES and PAGINATION_ZERO_LIMIT_CASES. It also covers TEMPORAL_CASES, lowered through lowerFilterCondition, with tokens resolved at TEMPORAL_NOW and rows in both writer forms. 106 passed.
  • external-driver-floor.test.ts: runs through the real engine, with a driver that pushes nothing down. 17 passed. It covers:
    • which datasources get the floor; a built-in or managed datasource keeps its own instance; both registration orders; a managed redefinition unwraps;
    • the one-instance pin (getDriverByName, datasource() and getDriver(object) are the same object, and re-registration is idempotent);
    • the right subset under filter, sort, page and projection, and exactly what the driver was sent;
    • findOne, count, aggregate in memory, 1000 rows answered and 1001 refused on every read;
    • a read throw ⇒ 503 without the cause in the message; a 4xx write ⇒ 422 with the remote message and status; a 502 write ⇒ 503.
  • driver-where-like.test.ts: $like and $ilike semantics and refusals, and having still refusing them. 6 passed.
  • Suites run under the verify lock, all green:
    • @objectstack/objectql: 482 files, 9071 tests.
    • @objectstack/rest: 334 files.
    • @objectstack/service-datasource: 58 files.
    • @objectstack/runtime: 471 files, 7668 tests.
    • The spike: 12/12.
  • typecheck (tsc plus check:test-typecheck) is green for objectql and runtime.

Acceptance notes

  1. Predicate writes are not floored. updateMany and deleteMany pass where to the driver unchanged, as the ruling says (writes pass through). A plugin driver that ignores where on these would write more rows than asked. Open item for the PM: floor them as read-ids-then-by-id writes, or refuse them on floored datasources.
  2. A ReadableIds leaf is refused. A controlled-by-parent external object, whose read scope composes a ReadableIds leaf, cannot be read through the floor: it is refused per ADR-0139 D3 rather than answered short.
  3. Every read costs a capped read. count and findOne each read up to 1001 rows from the remote, and a list page plus its total is two such reads. This is the price of correctness without a pushdown credential, which is the epic's P3.
  4. Docs are not in this PR. docs(data-modeling): virtual objects — rows from your server, shown in any view (plugin-driver recipe) #27349 has not merged, so the three virtual-objects.mdx passages (fail-closed owner/sharing column, the driver three-step progression, the 1000-row cap) and the two "planned" sentences become a follow-up after it lands.
  5. The change is declared BREAKING. It narrows the accept set: a plugin-driver read that matches more than 1000 rows used to be answered and is now 422. The changeset carries adr-0087: not-required (no-migration-prescription).

Verification

Final head 9f12119993.

  • Derived gates: node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack derives 110 families for this diff. All 110 ran with their exit codes recorded before any pipe, and --ran reconciles 110 derived, 110 run, 0 NOT-MEASURED, 0 UNRUN.
  • Three reds on the first pass, all fixed or re-measured:
    • check:docs: the generated references were stale; re-recorded in the spec commit.
    • check:query-options-erasure: the new test's as any on engine query options grew the test surface from 236 to 244; the casts were removed, so it is back at 236.
    • Two exit-3 prerequisites, which are not findings: check-plugin-teardown-shape --self-test needed its pinned fixture commit fetched, and check:dual-build-cjs-loads needed a built dist. Both pass on re-run.
  • Full dogfood battery (the title carries !): run under the verify lock with VERDICT command-exit 0. Test Files 348 passed and 3 skipped of 351; Tests 2806 passed and 20 skipped. It ran one commit before the final head, which changed only the new test's casts and the generated reference docs.
  • Suites, under the lock:
    • objectql: 482 files, 9071 tests.
    • rest: 334 files.
    • service-datasource: 58 files.
    • runtime: 471 files, 7668 tests.
    • spec ledger and contract tests: 93/93.
    • typecheck for objectql and runtime, including check:test-typecheck.

Generated by Claude Code

@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 29, 2026
@github-actions

github-actions Bot commented Oct 29, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 2 package(s): @objectstack/objectql, @objectstack/spec, touching 59 documentable anchor(s). ⚠️ 2 changed file(s) yielded no anchor (packages/objectql/src/core.ts, packages/objectql/src/index.ts), so the pages documenting them are NOT COVERED by this run — this is not a clean bill of health for those files.

48 hand-written doc(s) name something this change touched — list omitted above 15 rows. Re-derive on the tree named below: node scripts/docs-audit/affected-docs.mjs --json bbb49c9fe3ecfa56c843aac7a18d4ff95783047a.

⛔ 12 release-owned page(s) also affected — read-only, see AGENTS.md Documentation Guardrails.

What this run could not see
  • 2 changed file(s) yielded no anchor (packages/objectql/src/core.ts, packages/objectql/src/index.ts) — pages documenting those are invisible to this run
  • 1 anchor(s) matched too much of the corpus to be a work list: ObjectQL (symbol, 78 pages)
  • 12 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 58 of 210 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 — 168 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 bbb49c9fe3ecfa56c843aac7a18d4ff95783047a → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json bbb49c9fe3ecfa56c843aac7a18d4ff95783047a

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

…ngine applies the query, up to 1000 rows

A datasource whose schemaMode is not managed and whose driver id is not
built in is served through a floored driver, stored once in the engine's
drivers map. The driver is sent where, orderBy and limit 1001; the engine
applies where, orderBy, offset, limit and fields itself
(applyDriverQueryInMemory, over the having walker with $like/$ilike lifted
into its where position). findOne and count read the same way, aggregate
is hidden, writes pass through. More than 1000 rows is
EXTERNAL_ROW_CAP_EXCEEDED (422); a remote 4xx on a write is
EXTERNAL_WRITE_REFUSED (422); any other remote failure is
ERR_DATASOURCE_UNAVAILABLE (503, reason remote).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
…EDED under @objectstack/objectql

The two 422 refusals of the engine's floor over a plugin driver on an
external datasource (ADR-0015 Tier A). Neither is an ADR-0015 gate
refusal: EXTERNAL_WRITE_FORBIDDEN (403) stays the platform's own write
switch. The closed ApiErrorSchema.code vocabulary gains both members;
the generated references are re-recorded (gen:schema + gen:docs). The
api surface is unchanged (check:api-surface).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
…hree-step driver path, the 1000-row cap, fail-closed owner columns

The two "planned" sentences become the shipped behaviour (the floor; 503
ERR_DATASOURCE_UNAVAILABLE / 422 EXTERNAL_WRITE_REFUSED for driver
failures). New passages: the engine re-applies every query (what the
driver is sent, what the engine does), the driver's three steps, what
the 1000-row cap means and what the floor does not cover, and that a
missing owner column fails closed. The "what your driver receives"
table is re-measured under the floor.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
@marchtian
marchtian force-pushed the claude/project-thread-mlrxgx branch from 9f12119 to 55356b9 Compare October 29, 2026 05:23
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
REWORK round 1 · PR #27403 @ 55356b9f · 2026-10-29T05:33Z

Seat review plus an independent full Fable review. git range-diff shows the rebased objectql/spec commits are identical to 4dab7de7/9f121199. Ran locally: the three new objectql test files (129/129), spec ledger + external-errors tests (37/37), check-changeset-no-major, check-adr-0087-registration, check-error-status-conformance, and the Clause-② line parse (yes). All green.

Most of the PR is accepted as is:

  • Floor coverage is complete: all 13 engine read sites go through the map entry, and defaultDriver is a name.
  • === identity holds.
  • The evaluator refuses unknown operators by name, and the four spec tables pass.
  • The cap is exact at 1000/1001 and never truncates.
  • The 5xx failure path withholds the remote's message.
  • The ledger rows match their neighbours.
  • The changeset is patch with the BREAKING banner, which follows AGENTS.md.
  • The spike re-pin weakens nothing.
  • The docs match the code, including the columnMap correction.

Must fix

  1. Blocker: the floor is removed by a driver-less re-registration. applyExternalDriverFloor unwraps whenever needsExternalDriverFloor(def) is false, and that is false when def.driver is absent. packages/services/service-datasource/src/datasource-connection-service.ts:669 and :851 (restoreOldPool) call registerDatasourceDef({ name, schemaMode, external }) without driver, because EngineDatasourceDef has no such key.
    • Sequence: the stack install registers the def with driver, registerDriver floors it, then line 669 overwrites the def without driver, which unwraps the floor.
    • The reviewer reproduced it: a where-ignoring driver then answers all rows.
    • Today only the default factory's skipped-unsupported exit, and the spike's already-registered exit, keep it hidden.
    • Fix in objectql: a def whose driver is undefined keeps the previously stored driver id, so it never unwraps on an absent key. Add a test for exactly this sequence.
    • Passing driver from the connection service is a spec-contract touch (EngineDatasourceDef), so it is not in this PR. The seat carries it as a follow-up.
  2. Auth and rate-limit refusals must not surface as EXTERNAL_WRITE_REFUSED.
    • write() maps every 4xx to 422 and passes the remote's text verbatim. An expired token or a rate limit is an operator condition, and the text may name the credential.
    • Carry only request-shaped refusals (400/404/409/412/422) as EXTERNAL_WRITE_REFUSED.
    • Send 401/403/407/429 to ERR_DATASOURCE_UNAVAILABLE (503, reason: 'remote', cause logged).
    • Pin both with tests and say it in the docs.
  3. Dev question (a), seat ruling: hide updateMany / deleteMany in the floor Proxy (get → undefined, has → false), exactly as aggregate is hidden.
    • The engine's one predicate-write site (updateByIdExpecting, engine.ts ~14389, the ADR-0142 D4 precondition) then takes its existing documented degraded path, driver.update by id.
    • The ruling's "writes pass through" still holds: every write reaches the driver, by id.
    • This removes the case where a driver ignores where and bypasses the precondition. Add a test and update the docs' "Not covered" bullet.
  4. compareRows: a Date and an ISO string in one column compare as number vs string and read as equal. Normalise both to epoch ms when either side is a date, and add a test.
  5. Docs nits:
    • "the page at offset 1000 is refused" holds only when more than 1000 rows match; say so.
    • Under step ①, add "honour limit by paging the remote; a remote whose page is smaller than 1001 and that you do not page through answers fewer rows than match, and the engine cannot detect it."

Accepted as is, with follow-ups the seat files

  • Dev question (b): a ReadableIds leaf refused (ADR-0139 D3) is strictly safer than main. Controlled-by-parent external objects stay unreadable in P1, as the docs already say. The engine semi-join is a follow-up card.
  • Read throws become 503: this covers even a driver's own 400-band filter refusal. It matches the ruling. Passing 400-band read refusals through is a follow-up.
  • external-errors.ts left untouched is intended: the two codes are not ADR-0015 gate codes. The claim listed that file as surface, not as an obligation.

Same branch, same claim. The Fable review re-reads the delta after the push.

…predicate writes; only request-shaped 4xx is a write refusal

Rework round 1 on the plugin-driver floor:
- registerDatasourceDef without `driver` keeps the stored driver id, so the
  datasource-connection service's driver-less re-registration (after a
  connect, restoreOldPool) no longer unwraps the floor.
- updateMany / deleteMany are hidden on the floored instance, as aggregate
  is: a precondition write goes through update by id, and a multi-row
  predicate write is refused.
- Only 400/404/409/412/422 from the remote is EXTERNAL_WRITE_REFUSED; a
  401/403/407/429 is ERR_DATASOURCE_UNAVAILABLE (503) with its text withheld.
- The in-memory sort compares a Date and an ISO string in one column as
  instants.
- Docs and changeset follow.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
…mber edit and delete a virtual object's rows

An explicit sharingModel is not a way out on its own ('private' is refused
the same way): it is public_read_write with a beforeUpdate hook, or a
declared owner field the driver returns under its name (columnMap only on
a built-in SQL driver). Member deletes stay refused on public_read_write
unless the driver returns created_by or a policy or share grants delete.
Matches the security-external-owner-unmapped warning (#27332).

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
…lugin-driver datasource

A floored plugin driver offers no updateMany/deleteMany, so a multi:true
predicate update or delete fell to the engine's generic "requires an ID or
options.multi=true" — false for a caller who passed multi, and with no
prescription. Both fallback arms now say the datasource is served by a
plugin driver, why the engine does not run the write there, and to read
the matching ids and write each by id. Every other driver keeps the
generic text.

Co-Authored-By: Claude <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017ihL2dgsoHnkCiSyXF8isz
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
ACCEPT · PR #27403 @ 490c3c331a2132a8b60506d5b82127807822265d · 2026-10-29T07:16Z

维护者速读

  • 改了什么: 插件 driver 承载的外部数据源有了引擎兜底。引擎只向 driver 发 where + orderBy + limit 1001,拿回行后在内存里重做完整查询(筛选、排序、分页、字段)。超过 1000 行时响亮拒收(422 EXTERNAL_ROW_CAP_EXCEEDED,带处方)。远端拒写 422 EXTERNAL_WRITE_REFUSED,带远端消息;鉴权、限流、5xx、不可达都是 503,消息不外泄。多行谓词写在这类数据源上拒收并给处方。指南补上三段。
  • 为什么改: 裁决 6487532394(Q1=B、Q2=A′、Q3=A′)。driver 不能选择性忽略筛选,否则虚拟对象会静默返回错行。
  • 风险与回滚: 只影响「非托管 + driver 不是内置 id」的数据源;内置 driver、联邦 SQL、托管库零变化。changeset 为 patch 加 BREAKING 横幅(超 1000 行改为拒收);还原即回滚。
  • 先看: packages/objectql/src/external-driver-floor.ts、driver-query-in-memory.ts、engine.ts 的 applyExternalDriverFloor;账本两行 packages/spec/src/api/error-code-ledger.zod.ts。
  • 两分钟验证: 单跑 external-driver-floor.test.ts 与 driver-query-in-memory.conformance.test.ts。
  • 需要你做的: 无,席位落地。

席位意见:ACCEPT。两轮返工后,独立 Fable 完整复核 ACCEPT;第二轮的拒收措辞由席位亲读。

席位核对

  1. Shape: the seat read the diff (5 commits, +1611/−47). An independent full Fable review covered round 0 (55356b9f, whole PR, spec commit included) and the round-1 delta (55356b9f..7561b148). It confirmed with range-diff that the rebase changed no code. The seat read the round-2 delta (7561b148..490c3c33: a named refusal for multi-row predicate writes on a floored driver, generic text kept for every other driver).
  2. Premise measured: all 13 engine driver read sites go through the floored map entry. === identity holds. A driver-less re-registration keeps the floor; that is the blocker fixed in round 1, with a test of the connection-service sequence. The cap is exact at 1000/1001 and never truncates. The evaluator refuses unknown operators by name, and the four spec conformance tables pass. A 401/403/407/429 write withholds the remote text; that is tested.
  3. Gate check-runs on 490c3c33: 50 runs: 41 success, 9 skipped, 0 failed or pending. mergeable_state: clean. The dev's local results: objectql 483 files / 9088 tests and typecheck green. All 110 derived gate families exited 0 on round 1, and the 45 objectql families exited 0 on round 2. Dogfood: 2806 passed, 0 failed.
  4. Accepted deviations: branch claude/project-thread-mlrxgx (platform exception, in the claim). The runtime P0 spike was re-pinned to the floor; the review confirmed no assertion was weakened. The generated reference docs ride with the spec commit. packages/spec/src/shared/external-errors.ts is untouched on purpose: the two codes are not ADR-0015 gate codes.
  5. Out of scope, filed or carried: follow-ups filed by the seat after landing: the connection service passes the driver id (EngineDatasourceDef, spec contract), the ReadableIds semi-join for controlled-by-parent external objects, and passing 400-band read refusals through. Clause-②: yes (two ledger codes) · Metadata-protocol: none · contract review: the independent full Fable review of the spec commit stands as it, per the project rule for spec changes.
  6. Landing: not governed (check-governed-merges --pr 27403: 1658 lines ≤ 3000). Landed by the seat through the relay on the maintainer's order (田騏 2026-10-29T04:19Z: 「你应该使用 pm-dispatch 技能处理 issue 和 pr 落地」).

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV
Contract-review-claim: session_017ihL2c5z3q4h8inrn5kwqV · head 490c3c3 · 2026-10-29T16:40Z

Post-landing review owed at the landed head (seat miss recorded on #27261, correction 6501585877). Face: published schema (packages/spec/src/api/error-code-ledger.zod.ts) plus the Clause-②: yes line, so CONTRACT_REVIEW_TIER. An independent reviewer runs it now; the record follows on this PR.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

epic:#27261#1 · marchtian · session_017ihL2c5z3q4h8inrn5kwqV

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 490c3c331a2132a8b60506d5b82127807822265d
Local-runs: none
Scope: packages/spec/src/api/error-code-ledger.zod.ts + packages/objectql/src/external-driver-errors.ts, packages/objectql/src/external-driver-floor.ts, packages/objectql/src/engine.ts (registerDriver / registerDatasourceDef / applyExternalDriverFloor / update and delete refusal hunks; unregisterDriver read on main), packages/objectql/src/driver-connect-errors.ts, packages/objectql/src/driver-query-in-memory.ts, packages/objectql/src/having-filter.ts, packages/objectql/src/index.ts, packages/objectql/src/core.ts, content/docs/references/api/error-code-ledger.mdx, .changeset/27277-plugin-driver-floor.md, packages/spec/src/shared/external-errors.ts, packages/spec/src/api/errors.zod.ts, packages/spec/src/contracts/data-engine.ts, packages/types/src/thrown-http-error.ts, packages/services/service-datasource/src/datasource-connection-service.ts

Post-landing review at the landed head (merged 2026-10-29T07:38Z as 86edcaf8, merge queue), owed because no same-shape record stood before queueing (seat miss recorded on #27261). Inputs: card #27277 body and all 11 comments, PR #27403 body, file list and net diff against merge-base ad2c2869, the 52 check-runs on this head. The rest of the 19-file diff is the lane seat's ACCEPT at TIER_DEFAULT.

① Derived judgments

  • ERROR_CODE_LEDGER['@objectstack/objectql'] gains EXTERNAL_WRITE_REFUSED and EXTERNAL_ROW_CAP_EXCEEDED ⇒ RegisteredErrorCode and the closed ErrorCode enum (ApiErrorSchema.code) widen by two members — RIGHT: ruling 6487532394 Q2=A′ names exactly these two, 422, under objectql; [Decision] Clause ② on an UNREGISTERED error code carried by a thrown value: #14552 landed no, #15963 lands yes, and they are the same class #16404 "door or no door" makes a code shipped in objectql's dist a registration owed here. Clause-②: yes is the correct declaration for this widening.
  • EXTERNAL_SOURCE_UNAVAILABLE not added; packages/spec/src/shared/external-errors.ts untouched; remote failure reuses ERR_DATASOURCE_UNAVAILABLE (503) with DatasourceUnavailableError.kind widened to DatasourceUnavailableReason = kind | 'remote' — RIGHT per the ruling; DatasourceUnavailableInfo.kind stays the two-member type.
  • Generated references re-recorded (error-code-ledger.mdx, contract.mdx, automation-api.mdx) — RIGHT; check:docs / check:api-surface green in TypeScript Type Check. The api-surface snapshot is unchanged (the enum is spelled through the ErrorCode alias).
  • objectql public surface: applyDriverQueryInMemory, the two error classes and *_CODE constants, EXTERNAL_ROW_CAP, EXTERNAL_WRITE_REFUSED_STATUSES, needsExternalDriverFloor, unwrapExternalDriverFloor, four types; DatasourceDef.driver? — widenings, all named in the changeset — RIGHT.
  • Accept-set narrowing on a plugin-driver external datasource: a read matching more than 1000 rows ⇒ 422; aggregate, updateMany, deleteMany hidden on the floored instance; a ReadableIds leaf refused (ADR-0139 D3); groupBy/aggregations/having/search/searchFields/top refused by name — declared BREAKING in the changeset — RIGHT. Built-in drivers, managed datasources and ADR-0015 federation untouched (needsExternalDriverFloor requires schemaMode present and not managed AND resolveDriverId(driver) === undefined) — RIGHT per Q1=B.
  • $like / $ilike evaluated by the shared walker in the driver-where position only; having and the per-aggregation filter still refuse them — RIGHT per Q3=A′ (no driver-memory dependency).

② Semver level

  • .changeset/27277-plugin-driver-floor.md: @objectstack/spec: patch, @objectstack/objectql: patch; Clause-②: yes and Metadata-protocol: none line-initial in both the changeset and the PR body; **BREAKING** banner; one ADR-0087 marker not-required (no-migration-prescription). Level matches the kind. Check Changeset and Lint & Repo Gates success. CONSISTENT.
  • Should-fix (declaration precision, no gate reds): the diff both widens (two codes) and narrows (accept set); the precise spelling is Clause-②: yes (narrowing). The breaking signal rides on the banner.

③ Boundary flags

  • PR author objectstack-fleet[bot]; head repo = base repo; head branch claude/project-thread-mlrxgx as claim 6485327899 names.
  • Check-runs on 490c3c33: every gate-carrying context success; opt-in / path-filtered jobs skipped; zero failures.
  • Dev flags (report 6488646436): predicate writes — answered at the landed head (updateMany / deleteMany hidden, externalPredicateWriteRefusal names the refusal). ReadableIds leaf refused — accepted, in the changeset's "Not covered". Each read costs a capped read — accepted cost, epic P3. BREAKING — carried. "Docs are not in this PR" is stale against the tree: virtual-objects.mdx is in the file list, edited after docs(data-modeling): virtual objects — rows from your server, shown in any view (plugin-driver recipe) #27349 merged, so the serial constraint held.
  • Claim amendment 6487532394 named packages/spec/src/shared/external-errors.ts in the surface; untouched — consistent with the ruling.
  • Escalated — finding outside the reviewed face, must-fix class, new card owed (not a FAIL on this face): the floor is lost on a datasource reconnect and never engages for a runtime-origin datasource. On main, ObjectQL.unregisterDriver (engine.ts ~9216) deletes the datasourceDefs entry with the driver; DatasourceConnectionService.reconnect (~829–870, reached from the admin plugin's update door ~552) calls engine.unregisterDriver(name) then connect(record), whose registerDatasourceDef({ name, schemaMode, external }) carries no driver (EngineDatasourceDef, contracts/data-engine.ts 241–258, has no such key). After that, needsExternalDriverFloor answers false and a plugin driver serves unfloored. The ledger rows are not wrong for it: their producer is live on the manifest path.
  • Main since 86edcaf8: the hits file gained ERR_CURRENCY_CODE_REQUIRED and CONNECTOR_CREDENTIAL_REJECTED; the two reviewed rows are intact with their comments.

④ Design judgment

packages/spec/src/api/error-code-ledger.zod.ts (hits):

  • Admission rules hold: SCREAMING_SNAKE; EXTERNAL_ prefix beside EXTERNAL_WRITE_FORBIDDEN; rows under the stamping package; each row's comment states its status and reading; standardSynonymOf maps neither code.
  • Against ruling 6487532394 Q2=A′: exact — two codes, 422, objectql owner, ERR_DATASOURCE_UNAVAILABLE reused, EXTERNAL_SOURCE_UNAVAILABLE dropped, status on the thrown error.
  • Against its first consumer: both error classes carry the registered literal and status = httpStatus = 422; resolveThrownHttpError keeps a code that ErrorCode.safeParse accepts, so both doors answer 422 with the specific code. Producers reachable: readCapped (over 1000 rows) and the write wrapper (remote 400/404/409/412/422).
  • Should-fix (escalate to the maintainer): the standard catalog already carries MAX_RECORDS_EXCEEDED (400, "Query would return too many records", errors.zod.ts:67, zero producers). Dev report 6485390687 read "no ledgered code fits", and the ruling chose EXTERNAL_ROW_CAP_EXCEEDED on that premise. The row stands (the ruling names it); the maintainer should decide knowingly: keep it with a note that it deliberately shadows MAX_RECORDS_EXCEEDED at 422, or consolidate.
  • Should-fix (comment-only): the row comment cites "ADR-0015 Tier A"; ADR-0015 has no tier vocabulary ("Tier A" is epic [Decision] Ontology over external systems: virtual objects on ADR-0015 federation — two driver tiers (plugin code · declarative OpenAPI), pushdown/identity/catalog/cache rules, Studio and AI drafts (2026 ontology research + deep design) #27261's term). The same wording sits in external-driver-floor.ts and external-driver-errors.ts.
  • No must-fix on the hits file.

Implemented-by: claude/project-thread-mlrxgx
Reviewed-by: session_017ihL2c5z3q4h8inrn5kwqV

VERDICT: PASS

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/xl tests tooling

Projects

None yet

2 participants