Skip to content

fix(plugin-security): bind the report sink above start()'s two bail-outs (#10706) - #11055

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-10706-security-plugin-logger-assignment
Aug 22, 2026
Merged

fix(plugin-security): bind the report sink above start()'s two bail-outs (#10706)#11055
os-warren merged 1 commit into
mainfrom
claude/issue-10706-security-plugin-logger-assignment

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#10706

SecurityPlugin.start() bound its report sink below the two bail-outs, so a degraded boot left this.logger at its = {} construction default for the lifetime of the instance. This moves the binding above both.

The mechanic

private logger … = {} (:776) is an empty object from construction, and this.logger = ctx.logger was its only assignment — sitting in the "capture handles" block at :890, below the two returns at :878 (ObjectQL/metadata unresolvable) and :883 (engine without registerMiddleware). Every report site is written this.logger.warn?.(…), so an unbound sink is not a state any caller can notice: the reports simply do not happen.

The assignment now runs immediately after the Starting Security Plugin... line. Independent of #10556's open design call on what the default sink should be — only the placement changes, the = {} default is untouched, and the fix is correct under every option there.

Step 1 — the card's one undemonstrated row: REFUTED, on two independent grounds

The card flagged checkAuthoredRowWrite as possibly live and asked the next seat to settle it. Verdict: latent, not live.

(a) No caller reaches it on the instance. Positive control first — the instrument names the known path before its silence is read as evidence: the sweep finds the service literal delegation (:1149), the registration (:1235), and every service-mediated consumer. Against that working instrument, the instance-level reaches are:

reachreceiververdict
plugin-sharing/sharing-service.ts:841probe = this.securityService?.()ctx.getService('security')service-mediated
service-analytics/plugin.ts:386 (getReadFilter)ctx.getService('security')service-mediated
security-plugin.ts:5564this.checkAuthoredRowWrite inside private assertControlledByParentWriteself-call, middleware-driven
plugin-dev/dev-plugin.ts:759childPlugins: Plugin[], driven only via init/start/destroynot reachable without a cast
controlled-by-parent-master-widener.test.ts:503,515vi.spyOn(h.plugin as any, …)test only, after a healthy boot

registerService('security', …) is at :1235, inside start() and below the bail-outs — so on a bailed-out boot the service is never registered and every one of those consumers gets undefined and fails closed to abstain.

(b) Even a hypothetical direct instance caller cannot reach the report site.checkAuthoredRowWrite guards if (!this.ql) return 'abstain'before its only this.logger.warn?.() at :3647. this.ql was assigned in the same straight-line block as the logger, with no branch between — so this.ql bound ⟹ logger bound. On exactly the boots where the sink is empty, the method short-circuits before reporting.

The defect is still real on its own terms: a sink that can never be bound after an early return is unrepresentable as a state the code can notice.

⚠️ The card's site count was short, and the omission hides a second public method

The card corrected itself from 6 to 11 this.logger.* sites. Re-derived by enclosing scope with the TypeScript AST — at 24ba050f3, the card's own revision, where :722/:836/:812–3084/:3501 all match it exactly — the true count is 18. All 11 the card listed are real; it omitted 7, including three in getReadFilter (:3701, :3734, :3765) — a public method in the same structural category as checkAuthoredRowWrite.

That omission matters, because getReadFilter has no this.ql guard. Its on-behalf-of refusal (:3735) depends on none of the handles captured below the bail-outs, so it is reachable on a bailed-out instance — it is caller-gated only, not doubly unreachable. getReadFilter, not checkAuthoredRowWrite, was the structurally-live path. Still latent (all callers are service-mediated), but one ground instead of two.

A plain grep -n 'this\.logger\.' finds 17 of the 18 on main: :5673 is written this.logger?.warn?.( — optional chain on the field itself — and no this\.logger\. pattern can see it. Reported as a derived count, not a grep hit count.

What is pinned — start-logger-binding.test.ts, 7 tests

  • both bail-outs, pinned separately: this.loggerisctx.logger (identity), and the sink RECEIVES a report — driven through getReadFilter's on-behalf-of refusal, the one site reachable on a bailed-out instance. Asserting "non-empty" would assert nothing: {} and a real logger both satisfy typeof x === 'object'.
  • still-bails (load-bearing): both returns still return — registerMiddleware not called, 'security' not among the registered names. A "fix" that deleted a bail-out would pass a logger-only suite while changing boot behaviour; these assertions refuse it.
  • bail-out stays loud: both still report security middleware not registered through ctx.logger.
  • normal boot unchanged: same sink, middleware registered, 'security' registered, neither bail-out taken.

Ablation — signature predicted before mutating

Prediction written to file before the mutation, then the fix hunk reverted in place:

predictedobserved
directionREDRED
tally2 failed | 5 passed (7)2 failed | 5 passed (7)
failing assertionexpect(sinkOf(plugin)).toBe(ctx.logger), both posturessame, :117, both postures
message{} received where ctx.logger expectedAssertionError: expected {} to be { info: [Function Mock], …(2) }
green controlsthe 5 still-bails / loud / normal-boot tests unmovedunmoved

probeSinkReceives never executes in the mutated leg — the identity assertion throws first. That is why both halves are asserted.

src/ vs dist/, argued from the files and then falsified. The subject is imported by the relative specifier ./security-plugin.js; vitest.config.ts carries only two anchored aliases (/^@objectstack\/driver-sql$/, /^@objectstack\/objectql$/), neither of which can match a relative path — so the subject is transformed from src/. The falsifiable consequence: the ablation mutated src/ and ran no build, and it reddened. Had the suite been reading dist/, an unbuilt src mutation would have stayed green.

Restore.git hash-object before ablation and after restore both 3a2ed20f24a1da854708d6d9c3e0b1adea44698a — byte-identical. The restore leg was then re-run to a real verdict rather than trusted on the hash: Test Files 1 passed (1) / Tests 7 passed (7).

Verification — all on the final commit 518ef0855, clean tree

Dependency closure built first; every exit code captured before any pipe (cmd > file 2>&1; EXIT=$?), and each gate quoted by its own verdict line.

  • pnpm --filter @objectstack/plugin-security testTest Files 70 passed (70) / Tests 1355 passed (1355)
  • pnpm --filter @objectstack/plugin-security typecheck — exit 0, > tsc --noEmit echoed (the package does ship the script; a filter matching none would exit 0 having run nothing)

Gate union from node scripts/pm/dispatch-gates.mjs with no path arguments — 3 paths vs merge base 2866d5f97, 136 families discovered:

gateverdict line
check:changeset-gate-self-tests✓ 118 + 212 + 116 assertions over real temp git repos
check:cross-package-test-inputsOK: 13 package(s) read outside themselves, all declared
check:objectui-changeset✓ digest + range self-tests all passed
check:slot-lookup✓ ratchet holds: 107 unswept sites in 25 files, none new
check:test-source-aliasOK — 72 packages with tests scanned
check:type-source-resolutionOK — 77 packages with a tsconfig.json scanned
check-adr-0087-registration.mjs✓ no declared-breaking changeset (1 non-breaking seen)
check-changeset-no-major.mjs✓ no major bump
check-ci-filter-parity.mjsOK: all 83 declared cross-package glob(s) covered
check-cross-package-test-inputs.mjsOK: 13 package(s), turbo.json hashes every glob
check-empty-changeset.mjs✓ 1 declaring changeset added
check-plugin-teardown-shape.mjs✓ 61 Plugin implementations, baseline fully burned down
check-affected-docs.mjs✓ 339 cases pass

Convention-triggered (new test file · i18n-owning package):

gateverdict line
check:query-options-erasure✓ ratchet holds: 67 unswept sites, none new
check:engine-double-contractOK — 377 pinned, 133 in the DEBT ledger, 2 exempt
check:where-matcher✓ 276 matchers, all answer or refuse loudly
check:type-check-coverageOK — 65/78 workspace packages type-checked
check:type-check-debt--re-measure: OK — 33 ledger entr(ies) re-measured in 266.9s … none above its recorded number
check:i18nOK (9 package(s) — all bundles in sync)
check:nul-bytesOK (scanned 6391 text file(s) … no raw ASCII control bytes)

⚠️check:i18n first returned PREREQUISITE NOT MET — the workspace CLI is not built … Nothing was checked. That is not measured, never a pass — the row above is the re-run after turbo run build over the workspace closure, which is also what let check:type-check-debt --re-measure run its ratchet instead of refusing.

Class #10309 (dispatch lists short by the five .changeset/**-triggered families, because the changeset does not exist when the PM derives): my re-derivation ran after the changeset was committed and did name all fivecheck:changeset-gate-self-tests, check:objectui-changeset, check-adr-0087-registration, check-changeset-no-major, check-empty-changeset. All green.

Not in scope


Generated by Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

Nothing in this diff resolved to a documentable surface (no symbol, route or SDK anchor derived from 1 changed package(s)), so this run has no opinion about the docs.

What this run could not see
  • 1 name(s) were too generic to anchor anything (single lowercase words)

Coarse fallback — 14 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 072d072d2a74e358e923f6f6a76acbf7621305c3packageMentionDocs.

@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 32578127586 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

失败的 job(日志抽取,best effort):

  • Console Pin Gate — 失败步骤: Build the Console SPA at the pinned objectui SHA

    ✗ Build failed in 6.14s
    

↳ 失败原因 是判读的关键:超时Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

跨 PR 相同签名(24h,按失败测试文件聚合):

  • ⚠️本次没有可用的聚合签名(日志里没有能解析出测试文件名的 FAIL 行)—— 这不是「没有同签名的其他 PR」,是这一轮没测到。跨 PR 聚合本次不可用,请手工比对其他 PR 的同类评论。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • 本 PR 过去 24h 无队列失败记录(首次)。
  • 过去 24h 队列共有 89 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Merged via the queue into main with commit 88e32a8Aug 22, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-10706-security-plugin-logger-assignment branch August 22, 2026 14:25
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentationImprovements or additions to documentationsize/mteststooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

plugin-security: start() can return before this.logger = ctx.logger, leaving the = {} sink permanent

2 participants

@os-warren@claude