Skip to content

fix(objectql): a data driver receives the declared driver options and nothing else - #30151

Merged
objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/sec-hardening-t
Nov 4, 2026
Merged

objectstack-fleet[bot] merged 7 commits into
mainfrom
claude/sec-hardening-t

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

Clause-②: no
Metadata-protocol: none

维护者速读

  • 改了什么: 引擎交给数据驱动的选项,现在只含 DriverOptionsSchema 声明的键(从 schema 读出,去掉退役墓碑 timeout)。buildDriverOptions 返回的就是这组键;find、findOne、count、aggregate、insert、update、delete(按 id 与多行)以及原始 execute 都把它交给驱动。hook 读到的 input.options 不变,仍是调用方的选项袋加上驱动选项(加法视图)。
  • 为什么改: 维护者裁决(选项 A):引擎交给驱动的只有声明的驱动选项,别无其他。沿用 feat(objectql): engine option bags are a closed contract — unknown keys throw instead of silently doing nothing #4400 把引擎选项袋做成封闭契约的做法,往下推一跳到驱动。
  • 风险与回滚: 声明键(transaction、tenantId、tenantIds、timezone、preserveAudit、bypassTenantAudit)原样到达,有对照测试;仓内驱动都只读声明键。若有第三方驱动读未声明的键,它将读不到(仓内没有)。@objectstack/objectql patch,spec 只改两处注释。回滚 = revert 本 PR。
  • 先看: ① packages/objectql/src/engine-driver-options.ts 的 pickDeclaredDriverOptions、mergeDriverOptionsIntoHookView 和 buildDriverOptions 早返回那几行;② engine.ts 里 find / findOne / insert / update / delete 把 driverOptions 交给驱动、把合并视图留给 hook 的位置。
  • 两分钟验证: pnpm --filter @objectstack/objectql exec vitest run --project local src/engine-driver-options.test.ts,预期 8 passed;把 buildDriverOptions 里的 pickDeclaredDriverOptions(base) 换回 { ...base } 再跑,第二个用例转红。
  • 需要你做的: 无,席位落地。

What changes

The engine → driver hop is now a closed contract, one hop below the engine option bags #4400 closed: a data driver receives the keys DriverOptionsSchema declares and nothing else.

  • packages/objectql/src/engine-driver-options.ts
    • declaredDriverOptionKeys() reads the key set off DriverOptionsSchema.shape on first use, minus any member that admits only absence (a retiredKey() tombstone, today timeout). It is never restated, so a key the spec declares reaches drivers with no edit here.
    • pickDeclaredDriverOptions(bag) cuts a bag to that set (own enumerable keys, values untouched).
    • buildDriverOptions starts from the declared keys of base instead of a copy of the whole bag, so every bag it returns is declared-only. A caller's explicit tenantId / transaction / bypassTenantAudit still wins, exactly as before.
    • mergeDriverOptionsIntoHookView(callerBag, driverOptions) builds the after* view of input.options: the caller's bag with the driver options merged on, additive as the hook PHASE contract states; the caller's own object when nothing is added; and a copy of the driver options when there is no caller bag, so the hooks' view and the driver's bag are never one object.
  • packages/objectql/src/engine.ts: find, findOne, insert, update and delete keep the built options in a local driverOptions, hand THAT to every driver call (including the reads and writes the verb makes along the way), and assign the merged view to input.options for hooks.
  • packages/objectql/src/engine-execute-transactions.ts: the raw execute door hands its driver the declared keys of its bag.
  • packages/objectql/src/engine-top-level.ts: comment-only. The pass-through keys docblock now says the bag's declared keys are the base of the driver options.
  • packages/spec/src/data/hook.zod.ts, packages/spec/src/data/driver.zod.ts: comment-only. The hook PHASE paragraph said the after* handler and the driver read one view; it now says the merged view is the hooks' and a driver is handed the declared keys, and it names both buildDriverOptions (which produces the driver's set) and mergeDriverOptionsIntoHookView (which merges that set into the hook view). DriverOptionsSchema's doc states the hop rule.
  • .changeset/driver-options-declared-keys-only.md: @objectstack/objectql patch.

Tests

packages/objectql/src/engine-driver-options.test.ts (new, 8 cases), on a recording driver double that captures the options argument as the driver receives it:

  • a data driver receives the declared driver options and nothing else
    • the hop key set is DriverOptionsSchema's declared keys, and every shape key it leaves out refuses every probe value (a tombstone);
    • find, findOne, insert, update and delete (by id and multi-row; count, updateMany, deleteMany asserted reached too) hand the driver only declared option keys;
    • the raw execute door hands the driver only declared option keys.
  • control: the declared keys the engine threads still reach the driver: transaction, tenantId and timezone arrive on every verb; preserveAudit and a caller's explicit bypassTenantAudit arrive unchanged.
  • the after* hook view of input.options stays additive: an afterFind hook still reads the caller's own keys beside the driver options; an afterUpdate hook still reads a key the caller passed; the merge helper answers the caller's own bag when it adds nothing, and a copy, never the driver's bag itself, when there is no caller bag.

packages/objectql/src/engine.test.ts: four expansion sub-read cases hold the driver's received option keys to a subset of declaredDriverOptionKeys(); the file-reference case reads the expansion marker at the middleware seam.

Acceptance notes

  • Why Clause-②: no, no arm, Metadata-protocol: none, patch. No accept set moves: every engine door accepts the option bags it accepted, and DriverOptionsSchema is unchanged apart from its doc comment. The keys that stop reaching a driver were never declared on the driver contract (the DriverOptions type carries none of them, so a typed driver could not read one without a cast), and no driver in this repository reads one. What a metadata author can write does not change.
  • Why the cut is not inside the hook view. buildDriverOptions's result used to be both the driver's options and the after* hook view of input.options. Hooks in this repository read caller keys from that view after the merge (an afterInsert / afterUpdate handler reads one there), and the hook PHASE contract states the merge is additive. So the declared set is what the driver is handed, and hooks keep the additive view; the third test block pins that.
  • Undeclared keys are dropped without a sound, as ruled: the caller's bag was already judged at the engine's own door (feat(objectql): engine option bags are a closed contract — unknown keys throw instead of silently doing nothing #4400), so nothing dropped at the hop is a caller's mistake. A dev-mode warning was considered and not added; the contract review decided against one.
  • Bulk after* per-row dispatches read the same merged view as before.
  • Which doors admit the schema's other declared keys. insert and the raw execute door hand them on when a caller sets them; find, findOne, update and delete admit only the six threaded keys at their own door (ENGINE_DRIVER_PASSTHROUGH_KEYS), unchanged by this PR.
  • Contract review. PASS with no must-fix; the should-fix items are done at 6aaa035 (see Verification).

Verification

Head: 6aaa035 (the contract-review should-fix round). Every reading below is against that tree.

Tests, typecheck, full suite, and the one-shot ablation of the no-bag copy
head 6aaa0350bb, one verify-lock hold (VERDICT command-exit 0)
$ pnpm exec turbo run build --filter='@objectstack/objectql...' --concurrency=2      BUILD_EXIT=0
$ pnpm --filter @objectstack/objectql exec vitest run --project local --maxWorkers=2 \
    src/engine-driver-options.test.ts src/engine.test.ts src/hook-input-shape-contract.test.ts
 Test Files  3 passed (3)
      Tests  160 passed (160)
$ pnpm --filter @objectstack/objectql typecheck          TYPECHECK_EXIT=0
  check:test-typecheck: OK — 39 file(s) / 233 error(s) / 64 pinned signature(s) held
$ pnpm --filter @objectstack/objectql test               TEST_EXIT=0
 Test Files  548 passed (548)
      Tests  10844 passed (10844)

One-shot ablation of the new behaviour (scripts/ablation-replace.mjs, wrap mode):
  anchor   "if (callerBag === null || typeof callerBag !== 'object') return { ...driverOptions };"  x1 -> x0
  replace  "if (callerBag === null || typeof callerBag !== 'object') return driverOptions;"  x0 -> x1
  blob     08ffacb3c171 -> 6a9047dba9d2   (packages/objectql/src/engine-driver-options.ts)
       × merging adds the driver options, answers the caller's own bag when nothing is added, and never answers the driver's bag itself
   Test Files  1 failed (1)
        Tests  1 failed | 7 passed (8)
  restored: blob == HEAD (08ffacb3c171) and `git diff HEAD` is empty
Direction: red, as predicted, on the merge case only.

The declared-keys rule itself was ablated once at 7319d7c7f1 (replacing the pick
with a spread of the whole bag reddens the rule case only, 1 failed | 7 passed),
and that line is unchanged since.
Derived gate families
head 6aaa0350bb
$ node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack
  84 command(s) derived (9 paths vs merge base 1f13eb318; check:stack-collection-maps is new, for engine-top-level.ts)
  every one run, exit code captured before any pipe:
    82 x exit 0
     2 x exit 3 (PREREQUISITE NOT MET => NOT MEASURED locally; CI runs both)
       - pnpm check:dual-build-cjs-loads   (needs every workspace package's dist/)
       - pnpm check:type-check-debt        (its own closure build never got the verify lock)
$ node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --ran RAN_FILE
  Run reconciliation — 84 derived, 82 run, 2 NOT-MEASURED, 0 UNRUN.
Repo-wide scans (pnpm lint and the full farm) are CI's.

… nothing else

The engine now hands every driver call the keys DriverOptionsSchema declares
(read off the schema, minus retired tombstones) and nothing else.
buildDriverOptions returns that declared set; find, findOne, insert, update,
delete and the raw execute door hand it to the driver, while input.options
stays the hooks' additive view of the caller's bag.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…n keys

A recording driver double reads the options argument as the driver receives it
on find, findOne, insert, update, delete (by id and multi), count and the raw
execute door: every key is one DriverOptionsSchema declares. Controls pin that
transaction, tenantId, timezone, preserveAudit and an explicit
bypassTenantAudit still arrive, and that the after* hook view of input.options
stays additive.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
check:objectql-double-limit grades every in-memory find double: apply the
caller's bound by presence after the filter, or refuse it.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…dleware reads it

Four engine.test.ts cases asserted the expansion marker on the driver's
options argument. The driver is now handed declared driver options only, so
those cases assert that, and the file-reference case observes the marker at
the middleware seam, as the existing lookup-expansion routing case does.

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
…'s other declared keys

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added the size/m label Nov 4, 2026
@github-actions

github-actions Bot commented Nov 4, 2026 •

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

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

22 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 471add32df3f89315156cf982586b40e193bb84f.

⛔ 7 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/engine-top-level.ts, packages/spec/src/data/driver.zod.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, 80 pages)
  • 4 name(s) were too generic to anchor anything (single lowercase words)
  • the SDK route bridge reached 61 of 219 client-bound route-ledger rows — the other 158 have no registrar path: tail to select them, so pages documenting THEIR client methods cannot appear above, on this or any run. Of those 158: 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; 103 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 — 172 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 471add32df3f89315156cf982586b40e193bb84f → packageMentionDocs.

Which tree this was computed on

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

node scripts/docs-audit/affected-docs.mjs --json 471add32df3f89315156cf982586b40e193bb84f

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

@github-actions github-actions Bot added documentation Improvements or additions to documentation protocol:data tests tooling labels Nov 4, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract-review-claim: session_01QSeKYn5CmmdShCJifm6WYj · head 10142d7 · 2026-11-04T16:39Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 10142d759859892a248487e0ba3524fcf5aef59f
Local-runs: none
Scope: packages/spec/src/data/driver.zod.ts, packages/spec/src/data/hook.zod.ts + packages/objectql/src/engine-driver-options.ts, packages/objectql/src/engine.ts, packages/objectql/src/engine-execute-transactions.ts, packages/objectql/src/engine-driver-options.test.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/engine-hook-dispatch.ts, packages/objectql/src/engine-insert.ts, packages/objectql/src/engine-secret-internal-fields.ts, packages/objectql/src/engine-defaults-and-autonumbers.ts, packages/objectql/src/engine-count-aggregate.ts, packages/objectql/src/engine-delete-and-aggregate.ts, packages/objectql/src/external-driver-floor.ts, packages/plugins/plugin-audit/src/audit-writers.ts

Ruling applied: the engine hands a data driver the declared DriverOptionsSchema keys and nothing else (maintainer, option A); precedent #4400, the closed engine option bags, one hop down. Inputs: the PR body, the file list, the net diff against main at this head (origin/main 6d1dc7f835 is 11 commits past the merge-base 1f13eb3189 with no change under packages/objectql/src or the two spec files, so the net diff is the PR's diff), and the head's check-runs. Nothing built, run or re-run.

① Derived judgments

  1. Every driver-hop call site now hands the declared keys and nothing else — correct. Read at this head, by verb:
    • find (engine.ts 6651→6659) and findOne (6881→6886): buildDriverOptions → local driverOptions → the driver.
    • insert (8037–8052): one driverOptions built from the first row context's bag (plus the derived organization), handed to encryptSecretFields (8079) and through it to the sys_secret create (engine-secret-internal-fields.ts 333, one.driverOptions), to issueAutonumbers (8323, 8399), to bulkCreate / create (8382, 8385), and to createWithAutonumberResync (8410 → engine-defaults-and-autonumbers.ts 754, 766, 792, 810).
    • update: the pre-read preOpts (9485→9493) is built from the caller's bag and so is declared-only; driverOptions (9662) reaches encryptSecretFields (9783, 10210), updateByIdExpecting (10127 → engine-insert.ts 176–180: update, updateMany, the findOne readback), update (10128), the two pre-reads (10561, 10575) and updateMany (10578).
    • delete: the pre-reads (11552, 11717); driverOptions (11739) reaches the two pre-reads (11844, 11852) and deleteMany (11854); the by-id path (11784–11787) and the batched predicate path (11868, 11886) rebuild through buildDriverOptions from the merged hook view, which the pick cuts to the declared set again.
    • count (engine-count-aggregate.ts 146–155) and aggregate (engine-delete-and-aggregate.ts 523, 567) build from an undefined base — already closed before this PR, unchanged.
    • raw execute (engine-execute-transactions.ts 132): pickDeclaredDriverOptions(options).
    • The secret helper's own reads and the handle release build fresh { transaction } bags (engine-secret-internal-fields.ts 385–392, privilegedReadDriverOptions at 543, 586) — declared-only by construction.
    • A grep of driver.<verb>( over packages/objectql/src at this head finds no call that is handed hookContext.input.options, optionsBase or a caller options bag; the three sites that did (engine.ts 6647, 6872, 10108 and the four pre-reads on main) are the ones the diff cuts. The new test drives every verb with a bag carrying engine-level keys and asserts Reflect.ownKeys of what the driver receives is a subset of the declared set, with a non-vacuity check that each driver method was reached.
  2. Declared keys are read off the schema shape, tombstones excluded — correct. declaredDriverOptionKeys() reads DriverOptionsSchema.shape (a resolving access on the lazySchema proxy) and drops a member that admits only absence by walking _zod.def through optional / nullable / default / readonly to never. retiredKey() is z.never({ error }).optional() (retired-key.ts 194), so timeout is out and the nine live keys are in. Test case 1 pins both directions: whatever is excluded refuses every probe value, and the six engine-threaded keys are present. The memo is process-lifetime on a module constant — fine.
  3. transaction / tenantId / tenantIds / timezone / preserveAudit / bypassTenantAudit still arrive — correct. buildDriverOptions (engine-driver-options.ts 259–309) fills each in only when the declared pick left it undefined, exactly the pre-PR fill-in rule, so a caller's explicit value still wins; the control block pins arrival on every verb and the explicit bypassTenantAudit / preserveAudit values unchanged.
  4. The after-hook view stays additive — correct. mergeDriverOptionsIntoHookView answers { ...bag, ...driverOptions }, or the caller's own bag when nothing is added; find, findOne, update and delete assign it to input.options after the build, insert assigns it to every row context (8049–8052). Bulk per-row afterUpdate / afterDelete contexts copy batchCtx.input.options after that assignment (engine-hook-dispatch.ts 98–104). plugin-audit's writeCommentMentions (afterInsert / afterUpdate, audit-writers.ts 2646) reads the caller's onMentionsWithheld off ctx.input.options and still finds it; the third test block pins that and the afterFind case. Trigger order: beforeFind (6648, 6879), beforeInsert (7605), beforeUpdate (9409) and beforeDelete (11644) all fire before their verb's build, and no hook fires between the build and the verb's driver calls, so a before* write to a declared key still reaches the driver and a write to an undeclared key does not — the ruling's intent.
  5. The symbol key is gone from the driver bag and its one reader still gets it — correct. WRITE_PRECONDITION_OPTION is read off the caller's bag at engine.ts 8781 into opCtx.writePrecondition before the build and handed to updateByIdExpecting as the explicit precondition argument (10125–10127); pickDeclaredDriverOptions iterates declared string keys only, so no symbol reaches a driver; the only producer is engine-insert.ts 196 and the only reader is 8781. The hook view keeps it (object spread copies own enumerable symbols).
  6. Check-runs on the head (gate verdicts, read from the API, not the seat). Every completed run is success or skipped: Lint & Repo Gates, Governed Surface Queue Guard, Check Changeset, Type Check · source / consumer / debt / workspace, Build Core, Build & E2E UI, Live E2E UI, Lint UI (skipped), Type Check UI, Test UI 1–16/16, Test Core 1–8/8, Spec property liveness, PR-body guards, Check PR Size, Dogfood Regression Gate. Polled 2026-11-04T17:00:25Z: every check-run completed, none with a conclusion other than success or skipped.

② Semver level

@objectstack/objectql patch, Clause-②: no with no arm, Metadata-protocol: none, and no spec changeset — consistent with the diff.

AGENTS.md Post-Task Checklist step 3 is the text: "Clause-②: yes|no plus at most one arm from the closed pair (widening)/(narrowing) ((narrowing) is BREAKING, no (widening) malformed), and Metadata-protocol: added|narrowed|semantic|none, the change to what a metadata author can write … The level follows the kind alone: a kind takes at least minor on a package the diff moves, none takes patch; a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition." The gate's own reading of the value (check-changeset-no-major.mjs): Clause-②: no means "this PR widens no published accept set or public surface; add (narrowing) if it narrows one".

The dev's question 2 — does dropping keys a third-party driver could read only with a cast count as narrowing — no. The published surface at the hop is the IDataDriver contract: every method takes options?: DriverOptions (contracts/data-driver.ts 108–269), and that type declares none of the dropped keys; a read of one was never on the published surface, so no published accept set or public surface narrows. The engine's own doors accept exactly the bags they accepted (ENGINE_FIND/UPDATE/DELETE_OPTION_KEYS unchanged); nothing an author writes changes. What does change is runtime behaviour for a reader outside its contract, which is the changeset's "Who moves" paragraph to carry — and it does — not a (narrowing) arm. No ADR-0087 disposition is owed. The spec edits are comment-only: no .describe(), no export, and the edited hook.zod.ts paragraph feeds no generated page (content/docs/references carries none of its sentences), so no regenerated artifact and no spec changeset is owed; the type-check lanes that carry the spec gates are green.

③ Boundary flags

  • (1) Silent drop versus a dev-mode warn — the silent drop holds; no warn. Every caller bag carries engine-level keys on every call (the doors' legal sets include them by design), so a warn keyed on "undeclared key dropped at the hop" would fire on every verb call and train readers to skim it (AGENTS.md, degradation log levels); nothing is lost — the engine reads those keys itself, and the bag was already judged at the engine's own door (feat(objectql): engine option bags are a closed contract — unknown keys throw instead of silently doing nothing #4400). A warn would be meaningful only if keyed on what a driver actually reads, which the engine cannot see. The contract is pinned by the test, which is the right instrument. Decided; nothing to raise.
  • (3) The objectstack-ai/cloud driver census — landing does not wait for it; it is a pre-release item. The repository is not readable from this session either (GET refused; not attached). Reasons landing is independent of it: the ruling is the maintainer's; the in-repo contract never declared the keys; cloud consumes @objectstack/objectql at its own pinned upgrade, where its driver suites run against the new hop; and the migration is one sentence — declare the key on DriverOptionsSchema first, a spec change with its own review. Before the release that carries this patch: a cloud-side census card, written at rule level (undeclared keys), per security-findings §4 — one line on any public surface. Prime Directive Add missing Field.phone() helper and factory methods for Action/Dashboard/Report #15 keeps the release itself with the maintainer.
  • Out-of-scope note (the insert option bag has no unknown-key door) — accurate, correctly out of scope. rejectUnknownEngineOptions runs for find (engine.ts 6538), findOne (6797), update (8717) and delete (11348) and never for insert; so an undeclared insert option is still ignored at the engine door, the exact feat(objectql): engine option bags are a closed contract — unknown keys throw instead of silently doing nothing #4400 shape, and after this PR also never reaches a driver. A card under the feat(objectql): engine option bags are a closed contract — unknown keys throw instead of silently doing nothing #4400 precedent (PD chore: version packages #10), not this PR. One consequence for the prose face, for the seat's ACCEPT: the changeset line "The schema's other declared keys pass when a caller sets them" is true at insert and execute only — on the four doored verbs the door refuses timeoutMs / skipCache / traceContext before the hop (ENGINE_DRIVER_PASSTHROUGH_KEYS, engine-top-level.ts 284–286, admits the six threaded keys only). should-fix (prose): qualify that sentence.
  • Security-findings §6 (disclosure). Title, PR body, changeset, the six commit messages and every test name stay at rule level — "the declared driver options and nothing else", "undeclared keys" — and no mechanism is narrated. Two wordings to tighten, should-fix: the body's and changeset's "What stays with the engine" bullet and commit de02e50287's first line enumerate categories and one key of the undeclared set; say "undeclared keys". The four re-pointed engine.test.ts assertions name one undeclared key in the negative; assert against declaredDriverOptionKeys() (the subset form the new test uses) instead. No security-note.md or similar is in the file list.
  • Author is objectstack-fleet[bot]; no login note owed.

④ Design judgment

  • driver.zod.ts (hits). The added paragraph states what the code now does: the engine hands a driver the keys declared here, retired tombstones aside, whatever else rode in the caller's bag, and a key a driver needs is declared here first — pickDeclaredDriverOptions and admitsOnlyAbsence are exactly that, and the test's first case holds the schema and the code to each other. Holds against its first consumer. (The docblock sits above the trailing import rather than on the export — pre-existing placement, not this PR's.)
  • hook.zod.ts (hits). The PHASE sentence is true for both phases: a before* handler reads the caller's bag (every before* trigger precedes its verb's build, ① item 4); an after* handler reads the merged view, which is additive and strips nothing ({ ...bag, ...driverOptions }); and the new sentence "the merged view is the hooks', not the driver's" is what every verb now does. should-fix: the parenthetical still says the merge is done by buildDriverOptions ("merges the driver-facing keys onto it (buildDriverOptions: …)"); at this head buildDriverOptions produces the driver's set and mergeDriverOptionsIntoHookView does the merge — name both so the sentence points at the code that does it. hook-input-shape-contract.test.ts pins the envelope rows and is unaffected.
  • Break attempts.
    • A driver call site still passing the raw bag: none at this head (① item 1 lists every site, the helpers included).
    • A hook mutating input.options after the split and expecting the driver to see it: no hook fires between any verb's build and its driver calls, and the two delete paths that rebuild from the merged view pass through the pick again. should-fix (robustness, not a hole today): mergeDriverOptionsIntoHookView(undefined, d) answers d itself, so when a caller passes no bag (update / delete with options undefined) the hooks' view and the driver's bag are one object, while the docblock promises the driver "is handed driverOptions itself" as a thing apart; answer { ...driverOptions } so a later reordering of a hook and a driver call cannot reopen the door.
    • A remote / wire driver path: a plugin-published external driver sits behind createExternalDriverFloor, which forwards options to the inner driver unchanged and adds no key (external-driver-floor.ts 224–261); the cut is upstream in the engine, so a floored driver receives the declared set. The in-repo wire driver (driver-odata) and the five other drivers read only transaction, tenantId, tenantIds, timezone and bypassTenantAudit off their DriverOptions argument (census over packages/drivers/*/src). Direct getDriver() calls by plugins are outside the engine hop and the ruling; unchanged.
    • A second key list: ENGINE_DRIVER_PASSTHROUGH_KEYS (engine-top-level.ts 284–286) is the doors' admit list, held to the Engine*OptionsSchema shapes by engine-unknown-option.test.ts; it is not a restatement of the hop's set and the two stay consistent. should-fix: its docblock ("the option bag IS the base of the driver options") now reads "the bag's declared keys are the base".

No must-fix. Four should-fix items (the hook.zod.ts parenthetical, the merge helper's no-bag copy, the pass-through docblock, the disclosure wording), one prose qualification for the seat's ACCEPT.

Implemented-by: claude/sec-hardening-t
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

…er options rule

- the no-bag hook view is a copy, never the driver's bag itself
- the hook PHASE paragraph names buildDriverOptions (the driver's set) and
  mergeDriverOptionsIntoHookView (the merge into the hook view)
- the pass-through keys docblock says the bag's declared keys are the base
- the four re-pointed engine.test.ts assertions hold the received keys to a
  subset of declaredDriverOptionKeys()
- the changeset says undeclared keys, and qualifies which doors admit the
  schema's other declared keys

Claude-Session: https://claude.ai/code/session_01QSeKYn5CmmdShCJifm6WYj
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/l and removed size/m labels Nov 4, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 6aaa0350bb42d2e4de85c8132785f971a24b32e6
Local-runs: none
Scope: packages/spec/src/data/hook.zod.ts + packages/objectql/src/engine-driver-options.ts, packages/objectql/src/engine-driver-options.test.ts, packages/objectql/src/engine-top-level.ts, packages/objectql/src/engine.test.ts, packages/objectql/src/engine-execute-transactions.ts, .changeset/driver-options-declared-keys-only.md

Delta review by the same reviewer of the one commit 6aaa0350bb on top of the PASS record at 10142d759859892a248487e0ba3524fcf5aef59f (comment 6636977728). Read: git diff 10142d75..6aaa0350 (6 files, +39/−15), the rewritten PR body, and the new head's check-runs. The substance of the earlier record stands; only the should-fix items and the prose are re-judged here.

① Derived judgments

Each should-fix item from the earlier record, against the diff:

  1. §6 disclosure — done. The changeset's "What stays with the engine" bullet now reads "Undeclared keys in a caller's engine option bag stay with the engine and are no longer copied onto a driver's options" — the rule, nothing enumerated. The PR body's equivalents follow: the hook-view note reads "an afterInsert / afterUpdate handler reads one there", the Tests section reads "a key the caller passed", and the engine.test.ts line reads "hold the driver's received option keys to a subset of declaredDriverOptionKeys()". The four re-pointed engine.test.ts assertions now use one asymmetric matcher, declaredDriverOptionsOnly ($$typeof: Symbol.for('jest.asymmetricMatcher'), asymmetricMatch over Reflect.ownKeys requiring every key to be a string in declaredDriverOptionKeys()), in place of a negative assertion that named one undeclared key; undefined options are accepted as the empty subset, which is inside the rule. Test Core is green on the head, so the matcher is honoured by vitest's equality. Residue, accepted: commit de02e50287's first line (an earlier commit, not rewritten) and the unchanged engine.test.ts comment at 1637–1639 still name the expansion marker — a non-sensitive marker that main's own test named in the positive before this PR; no mechanism is narrated.
  2. hook.zod.ts parenthetical — done. It now reads "(buildDriverOptions produces the driver's set — transaction, tenantId, timezone, … — and mergeDriverOptionsIntoHookView merges that set into this view)", which is what engine.ts does at every verb's build (6651–6652, 6881–6882, 8037–8052, 9662–9663, 11739–11740). The PHASE sentence stays true for both phases as judged before. Comment-only; no .describe(), no export; no generated artifact owed.
  3. The no-bag copy — done. mergeDriverOptionsIntoHookView now answers { ...driverOptions } when there is no caller bag, and its docblock says so ("never driverOptions itself, so the hooks' view and the driver's bag are never one object"). The test case is renamed to say it and pins expect(view).toEqual(driverBag) beside expect(view).not.toBe(driverBag); the PR body reports a one-shot ablation of that line reddening that case alone. The other three branches (caller's own bag when nothing is added; spread when something is; the caller's bag when there are no driver options) are unchanged and still pinned.
  4. ENGINE_DRIVER_PASSTHROUGH_KEYS docblock — done. It now reads "the bag's DECLARED driver-option keys are the base of the driver options (buildDriverOptions(object, ctx, bag) keeps those and nothing else of the bag — pickDeclaredDriverOptions)". The list itself is unchanged (the six threaded keys), so the drift pin in engine-unknown-option.test.ts is unaffected; check:stack-collection-maps now derives for the file and is green.
  5. The changeset qualification — done and accurate. "The schema's other declared keys pass when a caller sets them at a door that admits them, which is insert and the raw execute door; find, findOne, update and delete admit only the six keys above at their own door." Verified: rejectUnknownEngineOptions runs on find / findOne / update / delete only (engine.ts 6538, 6797, 8717, 11348) with ENGINE_DRIVER_PASSTHROUGH_KEYS as the only driver keys those sets admit; insert has no door and pickDeclaredDriverOptions(optionsBase) keeps every declared key; the raw execute door (engine-execute-transactions.ts) judges no option key — it reads object / datasource to choose the driver and hands pickDeclaredDriverOptions(options) on. The PR body carries the same sentence under "Which doors admit the schema's other declared keys".

No new driver call site, no change to any hop, no change to the declared-key reader or the pick. The file list grows by engine-top-level.ts (comment-only); no governed path.

  1. Check-runs on the new head (gate verdicts, read from the API). At 2026-11-04T18:15:44Z every run was success or skipped — Lint & Repo Gates and its scoped lanes, Governed Surface Queue Guard, TypeScript Type Check (source / consumer / debt / workspace), Build Core, Test Core 1–8/8, Lint UI (skipped), Type Check UI, Test UI 1–16/16, Build & E2E UI, Live E2E UI, Spec property liveness, PR-body guards, Check PR Size, Dogfood Regression Gate, Check Changeset (one row success) — with one second Check Changeset row still in_progress. Polled again 2026-11-04T18:16:22Z: every check-run completed, none with a conclusion other than success or skipped.

② Semver level

Unchanged and still consistent: @objectstack/objectql patch, Clause-②: no with no arm, Metadata-protocol: none, no spec changeset. The delta is comment-only in packages/spec and engine-top-level.ts, a test-only change in engine.test.ts, a copy in a hook-view helper that no published surface exposes, and changeset prose. Nothing widens or narrows a published accept set or public surface; nothing an author writes changes. Check Changeset is green on the head for the row that has concluded.

③ Boundary flags

  • Rewritten PR body against security-findings §6 — at rule level. Title unchanged. The body states the hardening rule ("the declared driver options and nothing else"), "undeclared keys", and names no mechanism; the two sentences that enumerated categories are gone (① item 1). The 维护者速读 is unchanged and at rule level. The Verification section reports readings only. Nothing to raise.
  • The body's "Contract review" acceptance note cites this round by sha and says the should-fix items are done; that matches the diff. The earlier record's PASS is not restated as a relaxation of anything.
  • One observation outside §6, for the seat's ACCEPT (prose face): the rewritten body ends in an HTML comment signature and no longer carries the session-URL attribution footer AGENTS.md names for PR bodies ("session-URL: use in PR BODIES"). Not a contract matter; noted so the seat's body write restores it.
  • No open question remains from the dev: the dev-mode warn is recorded as decided against, the cloud census stays a pre-release item (unchanged), the insert-door card stays out of scope (unchanged).
  • Author is objectstack-fleet[bot]; no login note owed.

④ Design judgment

  • hook.zod.ts (hits). The amended parenthetical names the two functions that do the two things, in the order the engine does them; the sentence is true against the code it cites at this head. Holds.
  • The three objectql edits each do exactly what the should-fix asked and nothing beyond it: the copy closes the aliasing without changing any value a hook or a driver sees; the two docblocks now state what the code does; the subset matcher pins the rule positively where a negative assertion had named a key. No new break path: no hook fires between a build and its verb's driver calls (unchanged), the floor forwards unchanged (unchanged), and the matcher's undefined arm cannot admit an undeclared key.

No must-fix. No new should-fix.

Implemented-by: claude/sec-hardening-t
Reviewed-by: session_01QSeKYn5CmmdShCJifm6WYj

VERDICT: PASS

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

security#1 · os-justin · session_01QSeKYn5CmmdShCJifm6WYj

席位核对

ACCEPT · head 6aaa0350bb42d2e4de85c8132785f971a24b32e6 · 2026-11-04T18:19Z

  • Shape: 9 files, +471/−40. Not governed. Under 3000. Patch for @objectstack/objectql, with Clause-②: no and Metadata-protocol: none. The spec edits are comment-only.
  • Rule held (security-channel ruling of 2026-11-04, option A): the engine hands a data driver the declared DriverOptionsSchema keys and nothing else. Every verb, pre-read, readback and helper is covered. The after-hook view stays additive.
  • Contract review: the full record is 6636977728 (PASS). The delta record on this head is comment 6638493388: Served-tier: CONTRACT_REVIEW_TIER, Head-sha equal to this head, VERDICT PASS, no must-fix. The round-1 should-fix items are all done, including the disclosure wording.
  • Check-runs at this head (latest per name): 11 skipped, 53 success. mergeable_state: clean.
  • Gates (dev):
    • dispatch-gates --ran: 84 derived, 82 run, 2 NOT MEASURED (check:dual-build-cjs-loads, check:type-check-debt). Both are green in CI's lanes.
    • Full objectql suite: 10844 passed.
    • Ablation: both legs red, then restored.
  • Pre-release item (not for this landing): a cloud-side census of drivers that read undeclared option keys, before the release that carries this patch.
  • Landing: ready, then auto-merge.

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review November 4, 2026 18:20
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge November 4, 2026 18:21
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Nov 4, 2026
Merged via the queue into main with commit 7193160 Nov 4, 2026
69 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/sec-hardening-t branch November 4, 2026 18:47
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 protocol:data size/l tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant