Repository navigation
[finding] ObjectQL.find returns hookContext.result unguarded, so an afterFind hook can make a find() declared to resolve to any[] resolve to an envelope instead — measured #15823
Description
Activity
- addedpriority:p2Medium: important, M3Medium: important, M3and removed
on Sep 5, 2026 Triage routing:
domain:engine+needs-user-decision+priority:p2;finding补;⚠️ pm:queue撤除(决策状态与队列状态互斥)。分诊席(
session_01SwJQDFKe8tVit3BXQ9EfR5,R+164)。⛔ 本席不认领、不派工、不写代码、⛔ 不裁决(本会话claude-opus-5,硬闸要求 fable)。origin/main=cc5b3dd。复现 + ⭐ 一条让卡更强的读数
return hookContext.result在engine.ts里有三处,我逐处查了它们的宿主方法与声明返回类型:行 方法 声明的返回类型 有契约可违反吗 :9547async find(...)(:9407)Promise<any[]>✅ 有 —— 这就是本卡的缝 :9766async findOne(...)(:9653)Promise<any>⛔ 无( any没什么可违反):11990async update(...)(:10727)Promise<any>⛔ 无 ⇒ 卡指认的缝是唯一的那一条。 这不是"到处都这么写、随便挑了一处",而是三处里恰好只有一处声明了具体形状,而它没被守住。⭐ 这个对照使卡的论证更干净,建议接手/裁决时一并引用。
⚠️ 顺带一条不在本卡范围、但裁决方向 1 会牵出的问题:若裁定「find()必须保证数组」,那么findOne/update声明Promise<any>这件事本身就值得问一句——它们没有可执行的声明。⛔ 本席不代立卡,仅登记,供裁决时判断是否一并处理。为什么进决策箱
卡自己说得最清楚,且我认为这个判断是对的:
Two consequences, and they point opposite ways, which is why this wants a decision rather than a patch。
方向 1(
find()保证数组) ⇒ 在 hook 路径上拒绝/响亮报告非数组结果 ⇒ #15094 普查里剩下的 ~70 条死肢就无歧义地死了,可以仅凭类型删除 —— 这是跨该类的一次大幅简化。
方向 2(hook 可以重塑读取) ⇒find()的声明返回类型是错的,那 ~70 条死肢就不是死的 —— 它们是"行为不端的 hook"与TypeError之间唯一的东西,而这一类应当停止清扫。⇒ 两条通向相反的清扫政策,影响 104 个已普查的 normalizer 块。⛔ 这不是一个 dev 能顺手选的。
priority:p2⛔ 不给 p1:卡诚实标注「Nothing in this tree does it today」——
packages/**里唯一注册的afterFind是plugin-audit的读取记录器,它调recordView(ctx)而从不碰ctx.result。⇒ 无在野失效。⛔ 不降 p3:代价是评审成本,而且是持续发生的。卡把这一点讲得很准:
A reviewer asked to delete such a limb has to establish reachability, and the honest answer today is 「unreachable in practice, reachable in principle」, which is a much more expensive review than 「the engine guarantees an array」。
⇒ 这条缝每一次有人想删一条 normalizer 死肢时都要重新付一次钱,而普查数是 104 块 / ~70 条死肢形状。⇒ p2。
⭐ 提级条件:若出现任何真实注册的
afterFind改写ctx.result(本仓、examples 或客户部署),立即 p1 —— 那时它从"原则上可达"变成"已经发生"。⭐ 两处很好的做法,记名
① 它没有骑车。 #15597 被围栏限定在
plugin-auth且明令禁止改packages/spec或门,卡因此填而不修,并说明这是「anobjectqlengine question with its own reviewer」。⛔ 正确。② 它主动说明了自己与 #15597 的关系,避免两者看起来矛盾。 #15597 删掉那十四条肢的理由是:即使这条缝存在,十四处虚假免疫比一次可见的失败更糟(同一个包里另外 ~140 个
find()调用点照样会炸)。⇒ 那是关于plugin-auth的论证;缝本身该不该关是本卡的问题。⭐ 这种"我这张卡不推翻那张卡"的显式对账,省掉了下一个人重新推导一遍。查重(方法可信)
卡用一次 MCP
search_issues,并在同一次调用里满足了控制 —— 查询返回 #14460 与 #13706(两个已知必中项,#15597 自己的卡也引了它们)⇒ 「没有覆盖本缝的 open 卡」这个空结果是读数而不是假零。⭐ 这正是本席纪律第②/⑧条要求的做法(零命中须配活控制),⛔ 而且卡是自己主动做的。⚠️ 一处限度提醒:本环境search_issues的in:body自由文本失效,所以这条查重的可信度来自它同调用里的正控制,⛔ 不来自搜索本身的完备性。结论我未发现反例。
Generated by Claude Code
Ruling recorded — direction 1:
find()guarantees the array; anafterFindthat replaces it is refused loudly (summon #16, director seat, 2026-09-06T04:52:06Z)Provenance (who / verbatim / where): maintainer, 2026-09-06, live director chat, answering batch #52 item 4 (1 the engine guards the declared return · 2 the declaration widens to
Promise<any>and the dead-limb sweep stops; recommendation 1). Verbatim: 「#12036 已转交他人,其他同意」 — 「其他同意」 adopts the recommendation on this card.Governing text:
packages/objectql/src/engine.ts:9425declaresfind(...): Promise<any[]>and:9565returnshookContext.resultafterafterFindwith no re-check — the only one of the threereturn hookContext.resultsites with a concrete declared shape (triage 5551191172);packages/spec/src/data/hook.zod.ts:108-116(read events are one event per read regardless of shape; row filtering is the RLS layer's job and field masking is field metadata, not something a hook re-implements); ADR-0077 line 71 (a hook may "intercept or shape reads" — shaping rows inside the array, not replacing the array). Protocol baseline, maintainer 2026-09-05 (5552700625): 「本项目以协议为基准。所以开发应该对其协议」 — the declaration is the contract; direction 2 would have been a protocol change on its own card and is not taken.Freshness: card re-read in this stroke — 1 comment (triage), none since.
Ruled — 1.
- In
find(), immediately aftertriggerHooks('afterFind', hookContext)and BEFOREmaskSecretFields/stripSearchCompanionFromReadrun onhookContext.result(both assume the array), the engine refuses a non-array result with a named error: a hook that replaced the read's container is a hook-contract violation, reported at the seam rather than as aTypeErrorat one of ~140 call sites. The error code is registered in the ADR-0112 ledger (one code, one wording); the message names the hook event and the object. - Pins: (a) an
afterFindthat assignsctx.result = { records: [...] }⇒ refused with that code; (b) anafterFindthat mutates rows in place, or reassigns a different ARRAY ⇒ still returns an array (shaping stays legal); (c) the no-hook path unchanged. - Consequence recorded for the The
{ records }-normalizer gate's population is app-showcase page modules, but all three instances found so far are outside it — #14460's stated evidence threshold is now met #15094 class: once this lands, every array-or-envelope normalizer limb downstream offind()is dead BY TYPE — the reviewer's "reachable in principle?" is answered no by the engine, and the sweep proceeds on the declaration alone. - Not ruled, recorded for the engine seat to file as its own
finding:findOne(:9671) andupdatedeclarePromise<any>and so carry no enforceable declaration — a declaration question, not this card's.
Clause-②: yes — the accepted behaviour of the published hook contract narrows (a non-array
afterFindresult is now refused) ⇒ construction and review atCONTRACT_REVIEW_TIER;needs:contract-reviewhung by the PR creator on both carriers. Changeset:@objectstack/objectqlminor, stating the refusal and its code. Lanedomain:engine,priority:p2kept.State transition, one stroke:
needs-user-decision→pm:queue;priority:p2 · finding · domain:engineretained.
Generated by Claude Code
- In
Dispatch ·
domain:engineexecution roundPM session
session_01ARYe3yQTQCUFm5qPYNgKaJ. Assignee set at dispatch time.
⛔ The assignee field is not proof of who holds this card — several seats run under one shared GitHub identity, so this comment is the claim record.Zone 1 — binding, do not re-litigate
- ⭐ This card is RULED. Read the director seat's ruling (comment
5557017787, 2026-09-06T04:52) before anything else and implement direction 1:find()guarantees the array, and anafterFindthat replaces it is refused loudly. ⛔ Direction 2 is not taken — do not widen the declaration toPromise<any>, and do not reopen the fork the card records. - Where the refusal goes, quoted from the ruling: "immediately after
triggerHooks('afterFind', hookContext)and BEFOREmaskSecretFields/stripSearchCompanionFromReadrun onhookContext.result(both assume the array)". ⛔ Not at thereturn. The point is that the two consumers between them already assume an array. - What the refusal is: a named error, registered in the ADR-0112 ledger (one code, one wording), whose message names the hook event and the object. ⛔ Not a
TypeError, ⛔ not a silent coercion, ⛔ not aconsole.warn. - The three pins the ruling requires, verbatim: "(a) an
afterFindthat assignsctx.result = { records: [...] }⇒ refused with that code; (b) anafterFindthat mutates rows in place, or reassigns a different ARRAY ⇒ still returns an array (shaping stays legal); (c) the no-hook path unchanged." ⭐ (b) is the one that keeps this from being a behaviour regression — shaping stays legal, only replacing the container is refused. - Clause-②:
yes, ruled, not your call. "the accepted behaviour of the published hook contract narrows" ⇒ construction and review atCONTRACT_REVIEW_TIER, and you hangneeds:contract-reviewon BOTH carriers (this card and your PR) as the PR creator. ⛔ 免复核不放行. - Changeset:
@objectstack/objectqlminor, stating the refusal and its code. ⛔ Notpatch, ⛔ notskip-changeset. - Scope fence. ⛔
findOneandupdate(anddelete) are explicitly out of scope — the ruling files them as "Not ruled, recorded for the engine seat to file as its ownfinding". ⇒ If you want that recorded, file a card, ⛔ do not ride it in. ⛔ Do not touch the The{ records }-normalizer gate's population is app-showcase page modules, but all three instances found so far are outside it — #14460's stated evidence threshold is now met #15094 normalizer-limb sweep either; the ruling's item 3 is a consequence recorded for later, not work in this PR.
Zone 2 — PM readings, falsifiable, ⛔ NOT binding — re-derive and re-declare
- The ruling's line numbers were exact on
origin/mainat4e090ecd—engine.ts:9425isasync find(object: string, query?: EngineQueryOptions, options?: EngineReadOptions): Promise<any[]>and:9565is thereturn hookContext.resulton the hook path, withtriggerHooks('afterFind', …)at:9552,maskSecretFieldsat:9557andstripSearchCompanionFromReadat:9563.⚠️ mainmoves; ⛔ re-locate by text and publish the lines you actually get.⚠️ Note that triage's own earlier reading gave:9547/:9407for the same two things — the numbers demonstrably move, the text does not. - ⭐ The population of
return hookContext.resultsites has GROWN since triage measured it, and the ruling's claim still survives — but re-derive it yourself. Triage counted three (find,findOne,update). Today there are four::9565(find,Promise<any[]>),:9784(findOne:9671,Promise<any>),:12103(update:10837,Promise<any>),:13537(delete:13054,Promise<any>). ⇒findis still the only site with a concrete declared shape.⚠️ This seat measured that; ⛔ do not relay it — re-run it, and if a fifth site has appeared, say so. ⚠️ The error code's home may be a SECOND published surface. ADR-0112's vocabulary appears as a closed union inpackages/types/src/response-envelope.ts(see its## \code` is the closed ADR-0112 vocabulary, not `string`block). If the new code must join that union, the diff widens@objectstack/typesas well and the changeset must cover **both** packages. ⛔ Do not assume; read ADR-0112 (docs/adr/0112-error-code-vocabulary-and-ledger.md) and the ledger it names, and publish which surfaces you actually touched. ⛔ You may READdocs/adr/**`; you may not edit it.- The card's own measurement, for your reproduction: with an
afterFindassigningctx.result = { records: [ … ] }, a realObjectQLover a realSqlDriverreturnedOBJECT{records}where the no-hook read returnedARRAY(len=1). ⭐ Reproduce that first, before you change anything — a pin that has not been seen to fail is not a pin. Then show the same drive refused. - Reachability today: the only registered
afterFindinpackages/**isplugin-audit'sread-audit.ts, which callsrecordView(ctx)and never touchesctx.result.⚠️ Re-derive this — if some suite's test double assignsctx.result, your refusal will red it, and that is a real consequence to find before CI does, not after. ⛔ And ⛔ never skip/disable/quarantine such a test to get green: if one exists, it is either a legitimate shaping case (fix the double) or evidence the refusal is too broad (say so). #15597is settled and does not conflict. Its fourteen removed limbs were argued "even given" this seam. ⛔ Do not revisit it.
Zone 3 — suggested route (⛔ not binding)
The ruling's item 2(b) is the hard half: "reassigns a different ARRAY ⇒ still legal" means the check is on the container shape, not on identity —
Array.isArray(hookContext.result)is the whole predicate, and anything cleverer (comparing toopCtx.result, freezing, cloning) would break shaping. ⭐ The interesting question is what happens onundefined/null: a hook that assigns neither is not "replacing the container with an envelope", but it is also not an array. Decide it deliberately and say which way you went and why — the ruling names only the envelope case, so this is yours, and it belongs in the pin set either way.⛔ Worktree-first; ⛔ never
git stash; ⛔ never force-push a pushed branch; ⛔ never editcontent/docs/releases/**,docs/adr/**,.claude/**,skills/**,AGENTS.md,CLAUDE.md; ⛔ never skip/disable/quarantine a test; ⛔ do not narrow the gate family — derive it withdispatch-gates.mjsand run all of it.
Generated by Claude Code
- ⭐ This card is RULED. Read the director seat's ruling (comment
Claim: os-dev execution round for #15823, session
session_01ARYe3yQTQCUFm5qPYNgKaJ.
Branch: claude/issue-15823-find-afterfind-array-guard
Clause-②: yesRuled card — implementing direction 1 from the director ruling (comment
5557017787):find()guarantees the array; anafterFindthat replaces the container is refused loudly with a named ADR-0112 error, raised immediately aftertriggerHooks('afterFind', hookContext)and beforemaskSecretFields/stripSearchCompanionFromRead.Clause-② is
yesper the ruling ("the accepted behaviour of the published hook contract narrows"), soneeds:contract-reviewis hung on both carriers by me as the PR creator. ⛔ 免复核不放行 — this PR is not released by this seat.Base:
bdc02182b733a5ce7966ec961bf6b65f67d9fc72(origin/mainat claim time).
Generated by Claude Code
- added a commit that references this issue
on Sep 6, 2026 os-dev-report
{ "issue": 15823, "status": "done", "branch": "claude/issue-15823-find-afterfind-array-guard", "pr": "https://github.com/objectstack-ai/objectstack/pull/16232", "premise_still_valid": true, "summary": "Implements the 2026-09-06 director ruling (comment 5557017787), direction 1. In ObjectQL.find(), immediately after triggerHooks('afterFind', hookContext) and BEFORE maskSecretFields / stripSearchCompanionFromRead (both of which already assume the array), a non-array hookContext.result is refused with FindHookResultNotArrayError - ADR-0112 code FIND_HOOK_RESULT_NOT_ARRAY (registered in ERROR_CODE_LEDGER under @objectstack/objectql), status 500, message naming the hook event and the object. Array.isArray is the whole predicate, so shaping stays legal: mutate-in-place, reassign-a-different-array and reassign-[] all still answer an array. Two judgement calls declared and pinned: undefined/null are refused too (they break Promise of any[] exactly as much, and admitting them leaves a second indistinguishable hole in the same slot; a read that answers nothing assigns [], a hook that refuses throws), and 500 rather than 400/403 because the request was valid and a server-side hook broke a server-side contract - with the cost stated, that a declared 5xx has its prose withheld at the HTTP doors while the registered code crosses intact. Zone-2 re-derivations, all published: 4 return-hookContext.result sites (not 3), find :9425 the only one with a concrete declared shape (findOne :9671, update :10837, delete :13054 all Promise of any), no fifth site; the brief's anchors :9552/:9557/:9563 held exactly. Zone-2 item 3 FALSIFIED: the second published surface is @objectstack/spec, NOT @objectstack/types - response-envelope.ts imports ErrorCode as a type from spec and declares no union; packages/types is untouched by the diff and 0 of its published declarations carry the member (control: ErrorCode appears 9x in types/dist/index.d.ts). PM-side half-state: the assignee WAS set at dispatch (os-zhuang), so nothing anomalous there. Post-merge: main moved onto three of this branch's files, so origin/main was merged in via scripts/pm/os-regen-merge.sh and every reading above was re-taken on the merge head 5c8f04199; GitHub now reports mergeable:true.", "tests": "All readings via redirect-then-read, never through a pipe; verdicts quoted from each gate's own verdict line. FINAL UNION RUN ON THE MERGE HEAD 5c8f04199, working tree clean (git status --porcelain = 0 lines). (1) REPRODUCTION FIRST, on unmodified origin/main bdc02182b: plugin-auth/src/find-envelope-limb-removal.test.ts -t 'the control' => 1 passed, its assertion being expect(Array.isArray(value)).toBe(false) across fourteen real reads over a real ObjectQL on a real SqlDriver - the card's OBJECT{records} measurement reproduced before a line was changed. (2) NEW PINS: packages/objectql/src/engine-find-hook-result-shape.test.ts, 10 passed - (a) envelope refused, asserting code AND status (never a bare toThrow), class, event, object, observed shape and message text; (a') the refusal fires before either array-assuming consumer, driven with a poisoned length/index getter pair asserting 0 getter reads rather than asserting about source order; (b) mutate-in-place / different-array / empty-array all still answer arrays; (c) no-hook path unchanged; (d) undefined, null and a string each refused, naming their observed shape; plus ErrorCode-union membership with a control proving the union rejects an unregistered spelling. (3) CONSEQUENCE, found before CI: exactly one suite in packages/** registers an afterFind assigning a non-array - the #15597 discrimination control - and it went 1 failed / 24 passed under the guard. NOTHING skipped, disabled or quarantined: that case now asserts what is true (all fourteen reads REFUSE with the code, a strictly stronger statement) and a second case exercises expectBareArray's discrimination directly since no engine can hand it an envelope any more => 26 passed. The other two real afterFind registrations (rest/export-integration.test.ts FLS-delete and partial-masking) shape rows in place and are untouched; plugin-audit's read-audit.ts never touches ctx.result; every remaining 'ctx.result =' site belongs to a hand-built fake engine that never reaches this seam. (4) ABLATION - the pins were SEEN to fail (figures from the pre-merge tree 7dd55b9f1; the merge changed no line of the guard and both suites were re-run green on the merge head). Implementation committed first. Guard deleted with a WHOLE-LINE anchor (ANCHOR_HITS=1); mutation proved on disk BEFORE measuring (git hash-object 630e3c67 -> 4af01f1b; removed-text count 0, injected-marker count 1); then @objectstack/objectql REBUILT and ablation-dist-preflight --absent confirming the marker gone from all 8 built files - load-bearing because plugin-auth resolves @objectstack/objectql through dist/, unaliased (KNOWN_UNALIASED_TEST_IMPORTS). Results: engine-find-hook-result-shape 5 failed / 5 passed (the five refusal cases red; shaping/no-hook/ledger cases correctly independent of the guard), find-envelope-limb-removal 1 failed / 25 passed. RESTORE LEG proved in the same shell: whole-tree git status --porcelain 0 lines, blob back to the HEAD blob 630e3c67, git diff HEAD empty; then rebuilt and ablation-dist-preflight (present form) confirming the marker back in 4 built files with the tree clean. The mutating script carried trap ... EXIT INT TERM restoring through an absolute REPO_ROOT path. (5) MERGE: main moved onto three of this branch's files (engine.ts, objectql/src/index.ts, the census page) and GitHub reported mergeable:false. Landed origin/main through scripts/pm/os-regen-merge.sh - the repo's own sequence, which commits the merge BEFORE regenerating so an os-regen-driven artifact is re-derived from the merged tree rather than text-merged. The pre-commit hook held it for exactly one stale artifact (content/docs/permissions/system-context.mdx), regenerated with pnpm gen:system-context-census and discharged in the same commit; marker cleared. GitHub now reports mergeable:true. Step-4 assertion done: this branch's ledger row, engine guard, module, index export, changeset and both generated doc entries all verified present by quoted-exact git grep after the merge, and the guard is still at the ruled seam (dispatch :9566, refusal :9581, maskSecretFields :9591). (6) GATES: derived with node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack from the real change set (no hand-written path list) - 106 families, re-derived THREE times (twice pre-merge, once post-merge) and byte-identical every time. 106/106 GREEN at 5c8f04199. (7) pnpm lint (repo-wide eslint . --no-inline-config): exit 0, before AND after the merge - NOT narrowed, so no narrowing claim is owed. (8) Full workspace build turbo run build --filter=./packages/* --filter=./packages/*/*: 'Tasks: 71 successful, 71 total', re-run after the merge. pnpm --filter @objectstack/spec check:generated on the merged tree: 15/15 artifacts current. (9) pnpm --filter @objectstack/objectql typecheck green pre- and post-merge, and both new files proven INSIDE the tsconfig.test.json program via --listFiles (1 hit each; control on a non-existent path 0) - so that green is about my files, not a population that excludes them. (10) TWO GATES WENT RED ON THE WAY, both this branch's own, both repaired: check:system-context-census (the guard's 21 lines shifted 14 LINE-NUMBER doc anchors; measured GREEN AT THE MERGE BASE FIRST in a throwaway detached worktree, so the rot is mine, then the script's own --fix - line numbers only, verified by diff) and check:test-source-alias (the new pin's await import('@objectstack/spec/api') inside a clocked test body moved to a module-top import). (11) ONE NOT-MEASURED CHASED TO A READING rather than banked: check:type-check-debt first exited 3 (V8 OOM under my 4GB wrapper heap; the gate's own text says exit 3 is NOT a finding and NOT a pass). Re-run at 10GB => exit 0, 12 ledger entries re-measured, none above its recorded number. (12) SURFACE MEASUREMENT, with the root-barrel trap hit live and recorded: @objectstack/objectql dist/index.d.ts + dist/index.d.mts carry the new symbols (7 each); @objectstack/spec dist/api/index.d.ts (106) and the shared chunk dist/export.zod-w7_kGqGb.d.ts (13) - while packages/spec/dist/index.d.ts, the ROOT BARREL, carries 0 (live control on the same file: 23 export lines). Reading the root barrel alone would have answered 'nothing widened' with confidence. Control string FIND_HOOK_RESULT_NOT_AN_ARRAY appears in 0 published files, so the greps are live. NOT MEASURED, stated as such: remote CI on the PR (reported at draft-PR time per the standing contract, not waited on); check:react-declaration-parity (needs objectui's sdui.manifest.json, which this repo cannot produce); and one upstream script, scripts/check-adr-0087-registration.mjs, which moved on main after my merge - my in-tree copy is green and the upstream delta is confined to that gate's own header prose and self-test battery floor, which cannot bear on a changeset that declares no major, but I did not execute the newer copy. Six further families take a value from the workflow environment and have no local invocation; they sit outside the 106 by the deriver's own accounting.", "mcp_calls": "1 - a single targeted mcp__github__search_issues for dedup, which returned 'API rate limit already exceeded'. Channel declaration: repo-scoped REST was probed first and works (HTTP 200), and every read and write in this round went through it; GitHub's /search/issues is 403 for this seat ('sessions are bound to their configured repositories'), and after the MCP fallback was rate-limited the dedup was completed on the protocol's PRIMARY channel - one repo-scoped REST list of open domain:engine issues (74) plus local grep, with the control satisfied in the same read (#15823 itself present), so the empty result is a reading rather than a false zero.", "open_questions": [], "out_of_scope_findings": [ "filed as #16231: ObjectQL.findOne / update / delete return hookContext.result under a Promise-of-any declaration - nothing to guard, because nothing is declared. Unassigned, labelled finding + domain:engine, filed rather than ridden in exactly as the ruling's item 4 directs. Records the four-site table, names the three candidate answers (narrow the declarations and guard; narrow only; leave as any and record it) without proposing one, and distinguishes itself from its nearest neighbour #15267, which is the DRIVER-layer version of the same shape (IDataDriver doors on driver-sql / driver-turso), not the engine's own method declarations. Title had to be repaired via a file payload after bash command-substituted the backticks in a double-quoted node -e - read back and verified exact.", "OPERATIONAL, not a code finding, recorded because it is a live recurrence of a known incident: after the merge push, needs:contract-review was STRIPPED FROM BOTH CARRIERS by the auto-labeler's whole-set write (which had landed documentation / size/l / tests / tooling on the PR). Re-hung additively on both - the labeler's four kept, comparative read-back exact, nothing stripped - and re-read after a delay: both hold it now. The re-hang was owed by the push in any case, since a contract-review clear is bound to a head and mine moved. check-clause2-carriers --pair 16232 returns exit 0 on the current head, with the checker proven current against origin/main by blob hash first." ] }
Generated by Claude Code
- added a commit that references this issue
on Sep 9, 2026 - added a commit that references this issue
on Sep 17, 2026
Found by the dev round on #15597 (branch
claude/issue-15597-plugin-auth-dead-limbs) while establishing, per the #14843 standard, that the fourteenplugin-authenvelope limbs were really dead. Filed bare withpm:queueonly —domain:*, type and priority are triage's. Not ridden in on that card's PR: #15597 is fenced toplugin-authand explicitly forbids apackages/specor gate change, and this is anobjectqlengine question with its own reviewer.The fact, measured rather than read
packages/objectql/src/engine.tsdeclares:(spelled with brackets here on purpose — the angle-bracket form does not survive this body's sanitizer.)
It has two return paths. The ordinary one returns
opCtx.result as any[]. The other, on the hook path, is:An
afterFindhandler may assignctx.result, and nothing between that assignment and thereturnasserts the value is still an array. Driven on a realObjectQLover a realSqlDriver:find('sys_user', { limit: 10 }), no hooksARRAY(len=1)afterFindhook assigningctx.result = { records: [ … ] }OBJECT{records}So the declared return type is not enforced at the one seam that can break it, and the break is silent: no throw, no diagnostic, no log.
Why this is worth a card rather than a shrug
Nothing in this tree does it today — the only registered
afterFindinpackages/**isplugin-audit'sread-audit.tsread recorder, which callsrecordView(ctx)and never touchesctx.result. The finding is not "something is broken now".It is that this is the seam the whole
{ records }normalizer class keeps orbiting. The #15094 census counted 104 array-or-envelope normalizer blocks, ~70 of them dead-limb shaped, acrosspackages/**andexamples/**. Every author who wrote one was defending against a shape the contract says cannot occur — and at this seam, they were not simply wrong. A reviewer asked to delete such a limb has to establish reachability, and the honest answer today is "unreachable in practice, reachable in principle", which is a much more expensive review than "the engine guarantees an array".Two consequences, and they point opposite ways, which is why this wants a decision rather than a patch:
find()is meant to guarantee an array, the hook path should enforce it — reject (or loudly report) anafterFindthat replaces the result with a non-array, the way other hook contract violations are handled. Then every remaining dead limb in the census is unambiguously dead and can be removed on the type alone, which is a large simplification across the class.find()'s declared return is wrong and the ~70 dead limbs are not dead — they are the only thing standing between a misbehaving hook and aTypeError, and the class should stop being swept.⛔ This card proposes neither. It records the measurement and the fork.
What #15597 did with it, so the two do not contradict each other
That PR removes its fourteen limbs, and argues removal is right even given this seam: a hook that corrupted
find()into an envelope would be a contract violation, and the limb did not repair it — it silently absorbed it at fourteen sites while the ~140 otherfind()call sites in the same package broke anyway. Fourteen sites of false immunity is worse than one visible failure. That reasoning is aboutplugin-auth; whether the seam itself should be closed is this card's question.Dedup
REST is 403 for this seat and
ghis absent, so one targeted MCPsearch_issues, with the control satisfied in the same call: the query returned #14460 and #13706 — both known must-hits, cited by #15597's own card — so the empty result for this seam is a reading rather than a false zero. Four results total; the other two (#15124 anActionEngineFacade.findfilter-slot question, #7321 a closedfindDataarity bug) are a different subject. No open card covers the hook seam.Refs
#15597 (the round that measured it) · #15094 (the census and its ruling) · #14460 · #14843 · #15092 · #13706 (the precedent that a
find()need not resolve to an array)