You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
finding(plugin-audit): reportAuditWriteFailure reports once per process then degrades to debug, and its first line blames the telemetry datasource for every repeated cause — an operator is misdirected on the second loss of any kind #15166
Filed by the director seat out of #14927's ruling (maintainer, 2026-09-04, batch #26: close #14927 as superseded; this defect stands on its own). Grading, domain:* and priority are triage's; domain:services (plugin-audit) is the expected landing point.
The observation (measured on #14927, dev comment 5532384618, reproduced by injection)
plugin-audit's reportAuditWriteFailure (the best-effort catch around persistAuditTrailRow) behaves like this on repeated audit-write failures in one process:
["error", "Audit write FAILED — the compliance trail is now INCOMPLETE… confirm `sys_audit_log` is reachable… telemetry datasource", …]
["debug", "Audit write failed (already reported)"]
["debug", "Audit write failed (already reported)"]
Two properties, independent of the tenancy refusal #14927 was about (that trigger is unreachable on main for sys_audit_log):
Once-per-process degrade to debug. After the first failure of any cause, every later failure — including a different cause — is logged at debug. On a long-running server the compliance trail can keep losing rows for hours with one error line at the top of the log. persistAuditTrailRow is registered in DURABILITY_CRITICAL_CALLEES (scripts/check-durability-degradation-log-level.mjs), whose whole point is that durability loss is reported at error.
What is deliberate and must not be flipped blindly
AGENTS.md records the once-per-process rule as a deliberate anti-noise choice. The finding is not "log every failure at error" — #14927's dispatch already named that as a falsifier (it makes legitimate non-walled installs noisy). The question for whoever grades this is narrower: should the degrade be keyed per failure cause (code + object) rather than per process, and should the first line carry the underlying error code instead of a fixed datasource hint? Both are diagnosable from the caught error today.
Not in scope
The tenancy refusal path for sys_audit_log (ADR-0131 D7 removes the column; #14927 closed). Any change to DURABILITY_CRITICAL_CALLEES.
不止「指错药方」,是「断言了同因」。:754-755 的原话是 —— 「Every subsequent audited write is likely losing its row the same way (this is reported ONCE …)」。进程级键无法支撑这个断言:第二个不同原因发生时,这句话是印给运维的一句假话,比「药方指错」更重。⇒ 修 ① 之后这句话才重新为真;⛔ 别只改药方而留着这句。
Claim: PM loop round 1
Session: session_01ToDPcx9AESFubJkDiFMtKW
Branch: claude/issue-15166-audit-failure-cause-keyed-report
Worktree: objectstack-issue-15166
Domain: domain:services
File surface: packages/plugins/plugin-audit/src/audit-writers.ts (+ its test siblings; auth-event-audit.ts only if the same reporter is shared) (stop on breach; explain in the report)
Container & model: M, mode:subagent, model: opus (default judgment tier — the card poses an open shape question and names its own falsifier, so this is judgment work, ⛔ not mechanical. dispatch-gates.mjs --tier: no path-derived mandate ⇒ this seat's per-card call)
Clause-②: no — this narrows nothing and widens nothing: it changes a LOG line's wording and the key of an in-process dedupe boolean. No accept set, no published symbol, no error code, no wire field. ⛔ Not a contract surface.
Thread-read: 5593412162
Serial constraints cleared: plugin-audit is touched by NOTHING in flight — this seat's other two devs are in plugin-auth/src/admin-user-endpoints.ts (#16683) and plugin-auth/src/auth-manager.ts lines 6130-6220 (#16418); the two other seats' PRs are #17332 (plugin-security ×5 + mcp) and #17334 (trigger-schedule / service-automation / lint). Zero overlap, at file AND package level. Verify-lock arrival depth re-read immediately before dispatch: 1 (state: lock is free, queue: empty), under LOCK_DEPTH_HOLD = 2.
Labels this stroke: pm:queue → pm:dispatched; finding dropped per 「定级即离标」 (this card was graded priority:p2 / type Bug on 2026-09-04 and the label should have left at grading). priority:p2 and domain:services untouched. Read back: domain:services, pm:dispatched, priority:p2.
PM direction — the shape question this card left open is answered here, in seat, as a ruling with a named falsifiable premise
domain:services seat, session session_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T14:2xZ. The card ends by handing a question to 「whoever grades this」; it is graded, so this seat answers it rather than passing it on.
Why this is not the maintainer's
It falls in a named non-escalation class: 「恢复不变量」. persistAuditTrailRow is registered in DURABILITY_CRITICAL_CALLEES (scripts/check-durability-degradation-log-level.mjs), and that register exists for exactly one proposition — durability loss is reported at error. Today the second loss of any cause is reported at debug. ⇒ The invariant is already declared; this restores it. ⛔ No product-semantics fork, no public contract, no security boundary.
⚠️ It is not a free hand, because the card is right that the once-per-process degrade is a deliberate anti-noise choice recorded in AGENTS.md, and 「一律按 error 记」 was already named as a falsifier on #14927. So the direction is conditional, in three pieces.
① The route
Key the dedupe per CAUSE, not per process, and put the real cause in the first line.
The dedupe key becomes the failure's identity — the error code (or its absence) plus the object — instead of one process-wide boolean. A repeat of a cause already reported stays at debug, exactly as today; a new cause reports at error, once.
The first error line carries the underlying error code / message, instead of the fixed datasource hint printed unconditionally regardless of cause.
⭐ Verified on origin/main before writing this, ⛔ not taken from the card: audit-writers.ts computes const detail = String((err as any)?.message ?? err)one line above the branch, then passes it only into the debug payload — the error branch builds a fixed string literal and never reads it. The information the operator needs is already in hand at the call site and is being dropped on the floor.
⚠️ The existing datasource/OS_TELEMETRY_DB guidance is genuinely useful for the "no such table" cause it was written for — ⛔ do not delete it. Make it conditional on that cause rather than unconditional.
② The premise it hangs on — ⛔ falsify this FIRST
Cause-keyed dedupe does not reintroduce the noise the once-per-process rule was invented against (#4420's unreadable warn).
The failure mode to test for: an install where a cause set is unbounded or high-cardinality (a per-row constraint code, a per-object key) turns "one error per cause" back into a flood. Measure it — the anti-noise choice is documented and load-bearing.
③ ⛔ The explicit prohibition
If the premise is falsified, report the fork — ⛔ do not force it through, and ⛔ do not silently fall back to the current behaviour and call the card done. A falsified premise here means the card needs a maintainer decision about the noise/diagnosability trade, and it goes to the decision box with what you measured. That outcome is a successful dispatch, not a failure.
Boundaries restated from the card, unchanged
⛔ Out of scope: the tenancy refusal path for sys_audit_log (ADR-0131 D7, #14927 closed); any change to DURABILITY_CRITICAL_CALLEES; and 「log every failure at error」, which is the named falsifier and is ⛔ not the fix.
{
"issue": 15166,
"status": "done",
"branch": "claude/issue-15166-audit-failure-cause-keyed-report",
"pr": "https://github.com/objectstack-ai/objectstack/pull/17450",
"premise_still_valid": true,
"summary": "Card premises M1/M2 confirmed verbatim on origin/main ba9f02990: the process-level `let auditFailureReported = false` and the fixed `const message` literal that never reads `err`, with `const detail` computed one line above the branch and passed only into the `debug` payload. Implemented the ruling: dedupe key is now (object, error `code` or its absence) held in a Set; a repeat of a reported cause still degrades to `debug`, a new cause reports at `error` once; the first line leads with the real code and message; the ADR-0057 §3.6 datasource guidance is kept and gated on the missing-table cause via the shared `isMissingTableError` predicate, asked about both `sys_audit_log` and `sys_activity` (both written by `persistAuditTrailRow`, both routed by §3.6). RULING PREMISE ② HOLDS — see `premise_2_measurement`. M3 ANSWERED: `auth-event-audit.ts` is a SEPARATE second copy (its own `failureReported` boolean and `reportAuthEventWriteFailure`, own message literal), not a shared reporter, so it was NOT widened into — reported as a sibling finding. Clause-② stays `no`: the diff adds and changes no error code, only READS `err.code`; no export, accept set or wire field moves (both new helpers are module-local, absent from index.ts). ⛔ `DURABILITY_CRITICAL_CALLEES` untouched. Assignee was already set by PM (os-sales); newest Claim 5620225518 names this branch.",
"premise_2_measurement": {
"verdict": "HOLDS — not falsified",
"premise": "Cause-keyed dedupe does not reintroduce the noise the once-per-process rule was invented against (#4420's unreadable warn).",
"method": "Drove the real `installAuditWriters` code path with a failing sys_audit_log create over the showcase reference install's audited object set; counted `error` lines before (origin/main) and after, plus the named falsifier as the stream length. Harness was scratch-only and deleted before commit; the durable half is 8 pins in audit-writers.test.ts.",
"audited_object_set": "65 (48 platform-objects + 24 app-showcase objects declared, minus the 7 SKIP_OBJECTS entries declared there)",
"streams": [
"S systemic 1 cause x 65 objects, 6500 writes -> before 1 | after 65 | falsifier 6500",
"H1 per-ROW messages, 1 object, 1 code, 5000 writes, 5000 distinct messages -> before 1 | after 1 | falsifier 5000",
"H2 per-ROW messages, NO code, 5000 writes, 5000 distinct messages -> before 1 | after 1 | falsifier 5000",
"H3 per-ROW messages x 65 objects, 6500 writes, 6500 distinct messages -> before 1 | after 65 | falsifier 6500",
"M two DIFFERENT causes, 2 writes -> before 1 error + 1 debug (the defect) | after 2 errors"
],
"decisive_reading": "traffic independence — T stream: 6500 writes -> 65 error lines; 26000 writes (4x) -> 65 error lines. Identical. The reported-cause set is bounded by two BOOT-fixed sets (declared object registry x driver code vocabulary) and does not grow with traffic. #4420's shape was unbounded per-write growth; the falsifier still is, this is not.",
"why_it_holds": "The key is built from the error `code`, never its message. A driver names the offending ROW in the message, so a message-keyed dedupe would be #4420 wearing the word 'cause'. A code that is absent or non-scalar collapses to ONE bucket rather than growing one (H2).",
"residual_cost_reported_not_hidden": "For ONE systemic cause spread over the whole audited object set the operator now gets up to one line per audited object instead of one line total — measured 65 in the showcase reference install, each printed once and never again. This is the measured cost of the ruling's (code, object) granularity, bounded; the falsifier would have printed 6500 and kept growing."
},
"tests": "All measured at 8f683e278, exit codes captured BEFORE any pipe. `pnpm --filter @objectstack/plugin-audit test` :: exit 0 — 'Test Files 23 passed (23) / Tests 334 passed (334)'; audit-writers.test.ts is 75 passed (67 pre-existing unchanged + 8 new). `pnpm --filter @objectstack/plugin-audit typecheck` :: exit 0. `pnpm check:durability-log-level` :: exit 0 — its own verdict line: '✓ durability-degradation log levels: 29 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (4 propagating, declared)'. `pnpm --filter @objectstack/types exec vitest run src/driver-error-classification.callers.test.ts` :: exit 0 (7 passed) — the new `isMissingTableError` call joins that repo-wide scan's population and names the object it read (#13440). `pnpm lint` (eslint . --no-inline-config, WHOLE repo, no narrowing claimed) :: exit 0. ABLATION (C4): keying reverted to the process-wide boolean on the COMMITTED tree; mutation proved on disk BEFORE the run — 'reportedAuditFailureCauses 3 -> 0 ; auditFailureReported occurrences -> 3' and HEAD blob 379e7067ca053dff15eb50c56a16a817e05ed526 -> mutated blob ebca6c716953fda4177f7f6657fe3e275805010a. Mutated run command-exit=1, 'Tests 2 failed | 73 passed (75)': × 'reports a SECOND, DIFFERENT cause at error' (expected length 2, got 1) and × 'separates causes per OBJECT as well as per code' (expected length 3, got 1). Restoration proved BY STATE, ⛔ not by exit code: `git diff HEAD` empty, `git status --porcelain` empty, `git hash-object` back to 379e7067ca053dff15eb50c56a16a817e05ed526. Restore was `git checkout HEAD -- path` inside a `trap ... EXIT INT TERM` with an absolute REPO_ROOT path. No permanent ablation artifact left; scratch harness deleted.",
"gates": {
"derivation": "node scripts/pm/dispatch-gates.mjs --commands (no paths passed; re-derived after the change set moved from 4 to 6 paths, which added 7 changeset families), reconciled with --ran carrying exit codes",
"reconciliation_line": "✓ dispatch-gates --ran: 74 derived famil(ies) accounted for — 71 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)",
"derived": 74,
"green": 71,
"not_measured": 3,
"unrun": 0,
"not_measured_detail": [
"pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET",
"pnpm check:i18n :: exit 3 PREREQUISITE NOT MET",
"pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET"
],
"not_measured_cause": "One cause for all three: they read built output across the WHOLE workspace and no full dist/ closure exists locally (~55 packages report 'no dist'). That is a farm-wide build and belongs to CI. ⛔ Recorded as NOT MEASURED, never as a pass.",
"one_red_found_and_fixed": "pnpm check:type-source-resolution :: exit 1 on the first sweep — caused by this diff's new dependency ('NEW dist-resolved type import(s) since this entry was measured: @objectstack/types'). Fixed the way the gate prescribes (a tsconfig `paths` rule, ⛔ not by widening the shrink-only registry); re-run :: exit 0.",
"run_beyond_the_derived_union": "pnpm check:durability-log-level (C5) — not in this diff's derived set, run because the dispatch names it; exit 0.",
"self_test_note": "No family was accepted on a --self-test-only basis as a verdict about this diff."
},
"files_changed": [
"packages/plugins/plugin-audit/src/audit-writers.ts (+128/-19 region: cause-keyed Set, two module-local helpers, conditional remedy, import)",
"packages/plugins/plugin-audit/src/audit-writers.test.ts (+211, 8 new pins)",
"packages/plugins/plugin-audit/package.json (+@objectstack/types workspace dep)",
"packages/plugins/plugin-audit/tsconfig.json (paths rule for that dep)",
".changeset/audit-write-failure-cause-keyed-report.md (patch, @objectstack/plugin-audit)",
"pnpm-lock.yaml (dep addition)"
],
"deviations": [
"C3 file-surface breach, DECLARED, 2 files, both inside plugin-audit, both forced: (1) package.json — adds @objectstack/types for `isMissingTableError`. AGENTS.md forbids hand-rolling a second driver-error vocabulary ('a hand-rolled if (e.code === 42P01) is flagged on purpose; ask the shared predicate'); the predicate is unreachable from this package's existing deps and neither @objectstack/core nor @objectstack/objectql re-exports it. No transitive edge added — core already depends on types. (2) tsconfig.json — the `paths` rule pnpm check:type-source-resolution went RED demanding; same entry/spelling/reasoning plugin-security records for the identical import. ⛔ I did NOT stop the card for these: both are inside the package this card owns, PM verified plugin-audit is touched by nothing in flight, and neither goes near plugin-auth / plugin-security / service-automation / trigger-schedule.",
"PR-body attribution footer uses the AGENTS.md session-URL form (rule line + _Generated by [Claude Code](https://claude.ai/code/session_...)_ ) rather than the harness's suggested two-line form, per AGENTS.md 'Write the attribution footer in the form the surface keeps' and this dispatch's own instruction. Read back after the write: stored byte-identical except a stripped trailing newline, footer count 1, first line 'Fixes #15166'.",
"Commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude), ⛔ not the model-naming form, per 'no model identifier lands in a PR title or body, a comment, a changeset, a doc or a code comment'."
],
"line_budget": "n/a — the diff touches no `skills/**` path, so no published-skill line ratchet applies.",
"mcp_calls": "0 — the REST gate was open in this session (probed with a repo-scoped read: HTTP 200), so every issue/comment/PR read and write went through repo-scoped REST; zero MCP GitHub calls for the whole run.",
"open_questions": [],
"out_of_scope_findings": [
"noted, not filed: `packages/plugins/plugin-audit/src/auth-event-audit.ts` carries a SEPARATE SECOND COPY of both defects this card fixes — its own `let failureReported = false` (process-level) and its own `reportAuthEventWriteFailure` whose message literal prints the same ADR-0057 §3.6 / OS_TELEMETRY_DB guidance unconditionally. Its docblock states the kinship in as many words ('Same discipline, and the same reason, as `reportAuditWriteFailure` in `audit-writers.ts`'), and its callee `persistAuthEventAuditRow` is likewise in DURABILITY_CRITICAL_CALLEES. This answers M3: it is NOT the same reporter, so per the dispatch it is reported rather than fixed. NOT FILED as a card because the dispatch routes it to PM ('otherwise report it as a sibling finding rather than widening') and it is one authoring stroke on the file surface of the card just closed — filing it is PM's call, and the successor is named: whoever takes it inherits this PR's cause-key helper shape verbatim. ⛔ I did not file it unilaterally to avoid a duplicate against a PM-created sibling."
]
}
Reviewed by session_01ToDPcx9AESFubJkDiFMtKW (os-sales), 2026-09-10T15:4xZ, against GitHub and the tree — ⛔ not against the report's self-description.
Checklist
item
reading
PR form
draft ✅ · base main ✅ · first body line Fixes #15166 ✅
changed files
6; 0 governed surface, 0packages/spec, 0content/docs/** ⇒ normal landing path
CI (ALL checks, newest run per name)
30 success · 4 skipped · 0 running · 0 failing
Clause-②
no, line-initial in the body; verified by this seat against the diff: 0 added export, 0 error codes, 0 added throws
changeset
patch, correct for a diagnostic-only change
⭐ The premise this ruling hung on was MEASURED, and it holds
Ruling piece ② named a falsifier: cause-keyed dedupe must not reintroduce the noise the once-per-process rule was invented against (#4420). It was answered with a controlled sweep, not an argument:
stream
writes
before
after
the falsifier's shape
one systemic cause × 65 objects
6500
1
65
6500
per-row messages, one code
5000
1
1
5000
per-row messages, no code
5000
1
1
5000
The decisive reading is traffic independence: 6500 writes → 65 lines; 26000 writes (4×) → still 65. The reported-cause set is bounded by two boot-fixed sets (declared object registry × driver code vocabulary) and does not grow with traffic. #4420's shape was unbounded per-write growth; this is not.
⭐ Why it is bounded is the part worth keeping: the key is built from the error code, never its message. A driver names the offending row in the message, so a message-keyed dedupe would have been #4420 wearing the word "cause". An absent or non-scalar code collapses to one bucket rather than growing one.
⭐ And the residual cost is reported, not hidden: for one systemic cause spread across the whole audited object set an operator now gets up to one line per audited object — 65 measured — each printed once and never again, against the falsifier's 6500-and-growing. That is the measured price of the ruling's (code, object) granularity, stated rather than glossed.
Spot-checks
M3 answered correctly, and the boundary respected.auth-event-audit.ts was measured as a separate second copy — its own failureReported boolean, its own message literal — ⛔ not the same reporter. Per the dispatch it was reported rather than widened into, and this seat filed it as #17452 after a dedupe search with a firing control.
The out-of-surface files are gate-driven, not discretionary.@objectstack/types in package.json + a tsconfig.jsonpaths rule + the lockfile entry: check:type-source-resolution went red on the new dependency and was fixed the way that gate prescribes — a paths rule, ⛔ not by widening the shrink-only registry. Verified independently: packages/types exists in-repo (workspace:* → link:../../types), so it is ⛔ not a third-party runtime dependency and does not touch the manual floor; plugin-security carries the identical dep.
The remedy stayed conditional, as ruled. The ADR-0057 §3.6 / OS_TELEMETRY_DB guidance was kept and gated on the missing-table cause via isMissingTableError, asked about both sys_audit_log and sys_activity. ⛔ It was not deleted — which is what ruling ① asked for.
Ablation proved by state. Keying reverted to the process-wide boolean on the committed tree; mutation proven on disk before reading any result (reportedAuditFailureCauses 3→0, blob 379e7067…→ebca6c71…); 2 failed / 73 passed, naming the two pins that carry the change. Restored by state — git diff HEAD empty, git status --porcelain empty, hash-object back to 379e7067… — ⛔ never by exit code.
Gates: 74 derived, 71 green, 3 NOT-MEASURED (dual-build-cjs-loads, i18n, type-check-debt — all exit 3, PREREQUISITE NOT MET, one cause: no full workspace dist closure locally). ⭐ Recorded as NOT MEASURED, ⛔ never as a pass — which is the discipline that caught a real red on a sibling PR today.
Activity
os-zhuang commentedon Sep 4, 2026
分诊路由(本评论来自分诊座位)· R+150 ·
date -u实测 2026-09-04T20:02:16Z 一轮domain:services·finding·priority:p2。落点packages/plugins/plugin-audit⇒ 车道表 services(与卡面预期一致)。p2 判据:两条性质都成立且都在合规面上 —— ① 一次进程内的第一次失败之后,任何原因的后续失败都降到
debug,而persistAuditTrailRow就注册在DURABILITY_CRITICAL_CALLEES里,那张表存在的全部理由就是「持久性丢失必须报error」;② 第一条error行不论真实原因是什么都指挥运维去查sys_audit_log可达性与遥测数据源 —— #14927 上实测的那次,真实原因是ERR_SYSTEM_WRITE_ORGANIZATION_REQUIRED,而文本说的是「datasource」。⇒ 运维被送去查一个没坏的东西。⛔ 不是 p1:卡面已注明触发它的那条租户拒绝路径在
main上不可达(ADR-0131 D7 移除了那一列,#14927 已关)⇒ 今天没有测到活的丢失。⛔ 不是 p3:合规轨迹是这个插件的产品,而失效形态是「安静地继续丢」。⭐ 给认领方:卡面已经把「不能怎么修」写清楚了,别越过它
「一律按
error记」是被点名过的证伪项(#14927 的派发词里就写着):它会让合法的非围墙部署变吵。AGENTS.md 也把 once-per-process 记为刻意的抗噪选择。⇒ ⛔ 别把去重直接删掉。要答的是更窄的两问,而且两者今天都能从捕获到的错误里读出来:
⇒ 这两问都不需要改
DURABILITY_CRITICAL_CALLEES(卡面明确划到范围外),也不需要重新裁 once-per-process 的原则 —— 只是把「一次」的粒度从进程改成原因。Generated by Claude Code
os-zhuang commentedon Sep 8, 2026
分诊补态(本评论来自分诊座位)·
date -u实测 2026-09-08T23:35:02Z+pm:queue·type: Bug·domain:services与priority:p2维持 R+150 不动。R+150 写了车道、定级、判据、给认领方的边界,唯独没写六态之一 —— 那一轮(R+150/R+152/R+154)是批次级的系统性遗漏,不是本卡的个别疏忽。⚠️ 无态卡对派发是结构性不可见的(派发队列按
label:pm:queue过滤),所以补态本身就是让这张卡第一次可被认领。复核:两条性质在源码里逐字成立
packages/plugins/plugin-audit/src/audit-writers.ts,origin/main2bdabe60::739let auditFailureReported = false;—— 一个进程级布尔,:744命中即降debug并return。⇒ 去重键与「原因」完全无关,性质 ① 成立。:751-760的message是一个定长字符串字面量,把「确认sys_audit_log可达」「ADR-0057 §3.6 路由到telemetry数据源」「OS_TELEMETRY_DB=0」无条件印出,与err无关。⇒ 性质 ② 成立。scripts/check-durability-degradation-log-level.mjs:349确有'persistAuditTrailRow'。⇒ 卡面对门禁的引用成立。⭐ 但卡面把两件事写成了「待答之问」,而 AGENTS.md 的原文已经把它们答了
R+150 与卡面都据「AGENTS.md 把 once-per-process 记为刻意选择」来划红线。逐字读该规则,它记的不是 per-process:
规则的计数单位是一次降级(degradation),它排斥的对照物是每次写。⛔ 它从未授权用「第一次降级」去吞掉第二个不同的降级。⇒ 把去重键从进程改成原因(code + object),不是推翻这条刻意选择,而是第一次按它的字面去实现它。 #14927 派发词点名的证伪项是「一律按
error记」—— 那会随写次数无界增长;按原因去重是有界的(每个不同原因一行),抗噪目的完好无损。⇒ 那个证伪项够不到这个改法。同一段还规定了第一行欠的两样东西:
一句无条件断言某个 fix 的定长文本,在真实原因不同时不满足 ②。
⇒ 两问都不是取舍,是对该函数 docblock 自己援引的那条规则的符合性缺陷。 按类型判据「违背已声明契约 ⇒
Bug」,故本席把 type 从空补为Bug;也因为规则已给出方向,本卡无可问之事,够pm:queue。⛔ 不是决策箱:按原因去重只让本已丢失的东西被看见,不扩大任何接受集或公开面。:754-755的原话是 —— 「Every subsequent audited write is likely losing its row the same way (this is reported ONCE …)」。进程级键无法支撑这个断言:第二个不同原因发生时,这句话是印给运维的一句假话,比「药方指错」更重。⇒ 修 ① 之后这句话才重新为真;⛔ 别只改药方而留着这句。:766logger.error(message, err instanceof Error ? err : new Error(detail), { object, action })—— 真实 error 对象已经作为第二参交给 sink 了。⇒ 卡面第 2 问「第一行是否应带底层错误码」的实际修面比字面窄:码已在 sink,缺的是让第一行的那句 fix 随原因走(或至少在断言某个 fix 前先印出 code)。⛔ 别照字面去新加一条码传递通路。sys_audit_log的租户拒绝路径(ADR-0131 D7,#14927 已关)与任何对DURABILITY_CRITICAL_CALLEES的改动,都不在本卡内。⛔ 本席不认领、不派发、不写码。
Generated by Claude Code
claude commentedon Sep 10, 2026
Claim: PM loop round 1
Session:
session_01ToDPcx9AESFubJkDiFMtKWBranch:
claude/issue-15166-audit-failure-cause-keyed-reportWorktree:
objectstack-issue-15166Domain:
domain:servicesFile surface:
packages/plugins/plugin-audit/src/audit-writers.ts(+ its test siblings;auth-event-audit.tsonly if the same reporter is shared) (stop on breach; explain in the report)Container & model:
M,mode:subagent,model: opus(default judgment tier — the card poses an open shape question and names its own falsifier, so this is judgment work, ⛔ not mechanical.dispatch-gates.mjs --tier: no path-derived mandate ⇒ this seat's per-card call)Clause-②: no — this narrows nothing and widens nothing: it changes a LOG line's wording and the key of an in-process dedupe boolean. No accept set, no published symbol, no error code, no wire field. ⛔ Not a contract surface.
Thread-read: 5593412162
Serial constraints cleared:
plugin-auditis touched by NOTHING in flight — this seat's other two devs are inplugin-auth/src/admin-user-endpoints.ts(#16683) andplugin-auth/src/auth-manager.tslines 6130-6220 (#16418); the two other seats' PRs are #17332 (plugin-security×5 +mcp) and #17334 (trigger-schedule/service-automation/lint). Zero overlap, at file AND package level. Verify-lock arrival depth re-read immediately before dispatch: 1 (state: lock is free,queue: empty), underLOCK_DEPTH_HOLD= 2.Labels this stroke:
pm:queue→pm:dispatched;findingdropped per 「定级即离标」 (this card was gradedpriority:p2/ typeBugon 2026-09-04 and the label should have left at grading).priority:p2anddomain:servicesuntouched. Read back:domain:services, pm:dispatched, priority:p2.Generated by Claude Code
claude commentedon Sep 10, 2026
PM direction — the shape question this card left open is answered here, in seat, as a ruling with a named falsifiable premise
domain:servicesseat, sessionsession_01ToDPcx9AESFubJkDiFMtKW, 2026-09-10T14:2xZ. The card ends by handing a question to 「whoever grades this」; it is graded, so this seat answers it rather than passing it on.Why this is not the maintainer's
It falls in a named non-escalation class: 「恢复不变量」.
persistAuditTrailRowis registered inDURABILITY_CRITICAL_CALLEES(scripts/check-durability-degradation-log-level.mjs), and that register exists for exactly one proposition — durability loss is reported aterror. Today the second loss of any cause is reported atdebug. ⇒ The invariant is already declared; this restores it. ⛔ No product-semantics fork, no public contract, no security boundary.error记」 was already named as a falsifier on #14927. So the direction is conditional, in three pieces.① The route
Key the dedupe per CAUSE, not per process, and put the real cause in the first line.
code(or its absence) plus theobject— instead of one process-wide boolean. A repeat of a cause already reported stays atdebug, exactly as today; a new cause reports aterror, once.errorline carries the underlying error code / message, instead of the fixed datasource hint printed unconditionally regardless of cause.⭐ Verified on
origin/mainbefore writing this, ⛔ not taken from the card:audit-writers.tscomputesconst detail = String((err as any)?.message ?? err)one line above the branch, then passes it only into thedebugpayload — theerrorbranch builds a fixed string literal and never reads it. The information the operator needs is already in hand at the call site and is being dropped on the floor.OS_TELEMETRY_DBguidance is genuinely useful for the "no such table" cause it was written for — ⛔ do not delete it. Make it conditional on that cause rather than unconditional.② The premise it hangs on — ⛔ falsify this FIRST
The failure mode to test for: an install where a cause set is unbounded or high-cardinality (a per-row constraint code, a per-object key) turns "one
errorper cause" back into a flood. Measure it — the anti-noise choice is documented and load-bearing.③ ⛔ The explicit prohibition
If the premise is falsified, report the fork — ⛔ do not force it through, and ⛔ do not silently fall back to the current behaviour and call the card done. A falsified premise here means the card needs a maintainer decision about the noise/diagnosability trade, and it goes to the decision box with what you measured. That outcome is a successful dispatch, not a failure.
Boundaries restated from the card, unchanged
⛔ Out of scope: the tenancy refusal path for
sys_audit_log(ADR-0131 D7, #14927 closed); any change toDURABILITY_CRITICAL_CALLEES; and 「log every failure aterror」, which is the named falsifier and is ⛔ not the fix.Generated by Claude Code
claude commentedon Sep 10, 2026
os-dev-report
{ "issue": 15166, "status": "done", "branch": "claude/issue-15166-audit-failure-cause-keyed-report", "pr": "https://github.com/objectstack-ai/objectstack/pull/17450", "premise_still_valid": true, "summary": "Card premises M1/M2 confirmed verbatim on origin/main ba9f02990: the process-level `let auditFailureReported = false` and the fixed `const message` literal that never reads `err`, with `const detail` computed one line above the branch and passed only into the `debug` payload. Implemented the ruling: dedupe key is now (object, error `code` or its absence) held in a Set; a repeat of a reported cause still degrades to `debug`, a new cause reports at `error` once; the first line leads with the real code and message; the ADR-0057 §3.6 datasource guidance is kept and gated on the missing-table cause via the shared `isMissingTableError` predicate, asked about both `sys_audit_log` and `sys_activity` (both written by `persistAuditTrailRow`, both routed by §3.6). RULING PREMISE ② HOLDS — see `premise_2_measurement`. M3 ANSWERED: `auth-event-audit.ts` is a SEPARATE second copy (its own `failureReported` boolean and `reportAuthEventWriteFailure`, own message literal), not a shared reporter, so it was NOT widened into — reported as a sibling finding. Clause-② stays `no`: the diff adds and changes no error code, only READS `err.code`; no export, accept set or wire field moves (both new helpers are module-local, absent from index.ts). ⛔ `DURABILITY_CRITICAL_CALLEES` untouched. Assignee was already set by PM (os-sales); newest Claim 5620225518 names this branch.", "premise_2_measurement": { "verdict": "HOLDS — not falsified", "premise": "Cause-keyed dedupe does not reintroduce the noise the once-per-process rule was invented against (#4420's unreadable warn).", "method": "Drove the real `installAuditWriters` code path with a failing sys_audit_log create over the showcase reference install's audited object set; counted `error` lines before (origin/main) and after, plus the named falsifier as the stream length. Harness was scratch-only and deleted before commit; the durable half is 8 pins in audit-writers.test.ts.", "audited_object_set": "65 (48 platform-objects + 24 app-showcase objects declared, minus the 7 SKIP_OBJECTS entries declared there)", "streams": [ "S systemic 1 cause x 65 objects, 6500 writes -> before 1 | after 65 | falsifier 6500", "H1 per-ROW messages, 1 object, 1 code, 5000 writes, 5000 distinct messages -> before 1 | after 1 | falsifier 5000", "H2 per-ROW messages, NO code, 5000 writes, 5000 distinct messages -> before 1 | after 1 | falsifier 5000", "H3 per-ROW messages x 65 objects, 6500 writes, 6500 distinct messages -> before 1 | after 65 | falsifier 6500", "M two DIFFERENT causes, 2 writes -> before 1 error + 1 debug (the defect) | after 2 errors" ], "decisive_reading": "traffic independence — T stream: 6500 writes -> 65 error lines; 26000 writes (4x) -> 65 error lines. Identical. The reported-cause set is bounded by two BOOT-fixed sets (declared object registry x driver code vocabulary) and does not grow with traffic. #4420's shape was unbounded per-write growth; the falsifier still is, this is not.", "why_it_holds": "The key is built from the error `code`, never its message. A driver names the offending ROW in the message, so a message-keyed dedupe would be #4420 wearing the word 'cause'. A code that is absent or non-scalar collapses to ONE bucket rather than growing one (H2).", "residual_cost_reported_not_hidden": "For ONE systemic cause spread over the whole audited object set the operator now gets up to one line per audited object instead of one line total — measured 65 in the showcase reference install, each printed once and never again. This is the measured cost of the ruling's (code, object) granularity, bounded; the falsifier would have printed 6500 and kept growing." }, "tests": "All measured at 8f683e278, exit codes captured BEFORE any pipe. `pnpm --filter @objectstack/plugin-audit test` :: exit 0 — 'Test Files 23 passed (23) / Tests 334 passed (334)'; audit-writers.test.ts is 75 passed (67 pre-existing unchanged + 8 new). `pnpm --filter @objectstack/plugin-audit typecheck` :: exit 0. `pnpm check:durability-log-level` :: exit 0 — its own verdict line: '✓ durability-degradation log levels: 29 durability-critical catch seam(s), all loud, rethrowing or propagating to the caller (4 propagating, declared)'. `pnpm --filter @objectstack/types exec vitest run src/driver-error-classification.callers.test.ts` :: exit 0 (7 passed) — the new `isMissingTableError` call joins that repo-wide scan's population and names the object it read (#13440). `pnpm lint` (eslint . --no-inline-config, WHOLE repo, no narrowing claimed) :: exit 0. ABLATION (C4): keying reverted to the process-wide boolean on the COMMITTED tree; mutation proved on disk BEFORE the run — 'reportedAuditFailureCauses 3 -> 0 ; auditFailureReported occurrences -> 3' and HEAD blob 379e7067ca053dff15eb50c56a16a817e05ed526 -> mutated blob ebca6c716953fda4177f7f6657fe3e275805010a. Mutated run command-exit=1, 'Tests 2 failed | 73 passed (75)': × 'reports a SECOND, DIFFERENT cause at error' (expected length 2, got 1) and × 'separates causes per OBJECT as well as per code' (expected length 3, got 1). Restoration proved BY STATE, ⛔ not by exit code: `git diff HEAD` empty, `git status --porcelain` empty, `git hash-object` back to 379e7067ca053dff15eb50c56a16a817e05ed526. Restore was `git checkout HEAD -- path` inside a `trap ... EXIT INT TERM` with an absolute REPO_ROOT path. No permanent ablation artifact left; scratch harness deleted.", "gates": { "derivation": "node scripts/pm/dispatch-gates.mjs --commands (no paths passed; re-derived after the change set moved from 4 to 6 paths, which added 7 changeset families), reconciled with --ran carrying exit codes", "reconciliation_line": "✓ dispatch-gates --ran: 74 derived famil(ies) accounted for — 71 run, 3 NOT-MEASURED (3 DERIVED from a recorded exit 3)", "derived": 74, "green": 71, "not_measured": 3, "unrun": 0, "not_measured_detail": [ "pnpm check:dual-build-cjs-loads :: exit 3 PREREQUISITE NOT MET", "pnpm check:i18n :: exit 3 PREREQUISITE NOT MET", "pnpm check:type-check-debt :: exit 3 PREREQUISITE NOT MET" ], "not_measured_cause": "One cause for all three: they read built output across the WHOLE workspace and no full dist/ closure exists locally (~55 packages report 'no dist'). That is a farm-wide build and belongs to CI. ⛔ Recorded as NOT MEASURED, never as a pass.", "one_red_found_and_fixed": "pnpm check:type-source-resolution :: exit 1 on the first sweep — caused by this diff's new dependency ('NEW dist-resolved type import(s) since this entry was measured: @objectstack/types'). Fixed the way the gate prescribes (a tsconfig `paths` rule, ⛔ not by widening the shrink-only registry); re-run :: exit 0.", "run_beyond_the_derived_union": "pnpm check:durability-log-level (C5) — not in this diff's derived set, run because the dispatch names it; exit 0.", "self_test_note": "No family was accepted on a --self-test-only basis as a verdict about this diff." }, "files_changed": [ "packages/plugins/plugin-audit/src/audit-writers.ts (+128/-19 region: cause-keyed Set, two module-local helpers, conditional remedy, import)", "packages/plugins/plugin-audit/src/audit-writers.test.ts (+211, 8 new pins)", "packages/plugins/plugin-audit/package.json (+@objectstack/types workspace dep)", "packages/plugins/plugin-audit/tsconfig.json (paths rule for that dep)", ".changeset/audit-write-failure-cause-keyed-report.md (patch, @objectstack/plugin-audit)", "pnpm-lock.yaml (dep addition)" ], "deviations": [ "C3 file-surface breach, DECLARED, 2 files, both inside plugin-audit, both forced: (1) package.json — adds @objectstack/types for `isMissingTableError`. AGENTS.md forbids hand-rolling a second driver-error vocabulary ('a hand-rolled if (e.code === 42P01) is flagged on purpose; ask the shared predicate'); the predicate is unreachable from this package's existing deps and neither @objectstack/core nor @objectstack/objectql re-exports it. No transitive edge added — core already depends on types. (2) tsconfig.json — the `paths` rule pnpm check:type-source-resolution went RED demanding; same entry/spelling/reasoning plugin-security records for the identical import. ⛔ I did NOT stop the card for these: both are inside the package this card owns, PM verified plugin-audit is touched by nothing in flight, and neither goes near plugin-auth / plugin-security / service-automation / trigger-schedule.", "PR-body attribution footer uses the AGENTS.md session-URL form (rule line + _Generated by [Claude Code](https://claude.ai/code/session_...)_ ) rather than the harness's suggested two-line form, per AGENTS.md 'Write the attribution footer in the form the surface keeps' and this dispatch's own instruction. Read back after the write: stored byte-identical except a stripped trailing newline, footer count 1, first line 'Fixes #15166'.", "Commit trailers are the AGENTS.md model-free pair (Claude-Session + Co-authored-by: Claude), ⛔ not the model-naming form, per 'no model identifier lands in a PR title or body, a comment, a changeset, a doc or a code comment'." ], "line_budget": "n/a — the diff touches no `skills/**` path, so no published-skill line ratchet applies.", "mcp_calls": "0 — the REST gate was open in this session (probed with a repo-scoped read: HTTP 200), so every issue/comment/PR read and write went through repo-scoped REST; zero MCP GitHub calls for the whole run.", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: `packages/plugins/plugin-audit/src/auth-event-audit.ts` carries a SEPARATE SECOND COPY of both defects this card fixes — its own `let failureReported = false` (process-level) and its own `reportAuthEventWriteFailure` whose message literal prints the same ADR-0057 §3.6 / OS_TELEMETRY_DB guidance unconditionally. Its docblock states the kinship in as many words ('Same discipline, and the same reason, as `reportAuditWriteFailure` in `audit-writers.ts`'), and its callee `persistAuthEventAuditRow` is likewise in DURABILITY_CRITICAL_CALLEES. This answers M3: it is NOT the same reporter, so per the dispatch it is reported rather than fixed. NOT FILED as a card because the dispatch routes it to PM ('otherwise report it as a sibling finding rather than widening') and it is one authoring stroke on the file surface of the card just closed — filing it is PM's call, and the successor is named: whoever takes it inherits this PR's cause-key helper shape verbatim. ⛔ I did not file it unilaterally to avoid a duplicate against a PM-created sibling." ] }Generated by Claude Code
claude commentedon Sep 10, 2026
ACCEPT — PR #17450 @
8f683e2784(domain:servicesseat review of record, default tier)Reviewed by
session_01ToDPcx9AESFubJkDiFMtKW(os-sales), 2026-09-10T15:4xZ, against GitHub and the tree — ⛔ not against the report's self-description.Checklist
main✅ · first body lineFixes #15166✅packages/spec, 0content/docs/**⇒ normal landing pathClause-②no, line-initial in the body; verified by this seat against the diff: 0 addedexport, 0 error codes, 0 addedthrowspatch, correct for a diagnostic-only change⭐ The premise this ruling hung on was MEASURED, and it holds
Ruling piece ② named a falsifier: cause-keyed dedupe must not reintroduce the noise the once-per-process rule was invented against (#4420). It was answered with a controlled sweep, not an argument:
The decisive reading is traffic independence: 6500 writes → 65 lines; 26000 writes (4×) → still 65. The reported-cause set is bounded by two boot-fixed sets (declared object registry × driver code vocabulary) and does not grow with traffic. #4420's shape was unbounded per-write growth; this is not.
⭐ Why it is bounded is the part worth keeping: the key is built from the error
code, never its message. A driver names the offending row in the message, so a message-keyed dedupe would have been #4420 wearing the word "cause". An absent or non-scalar code collapses to one bucket rather than growing one.⭐ And the residual cost is reported, not hidden: for one systemic cause spread across the whole audited object set an operator now gets up to one line per audited object — 65 measured — each printed once and never again, against the falsifier's 6500-and-growing. That is the measured price of the ruling's
(code, object)granularity, stated rather than glossed.Spot-checks
M3 answered correctly, and the boundary respected.
auth-event-audit.tswas measured as a separate second copy — its ownfailureReportedboolean, its own message literal — ⛔ not the same reporter. Per the dispatch it was reported rather than widened into, and this seat filed it as #17452 after a dedupe search with a firing control.The out-of-surface files are gate-driven, not discretionary.
@objectstack/typesinpackage.json+ atsconfig.jsonpathsrule + the lockfile entry:check:type-source-resolutionwent red on the new dependency and was fixed the way that gate prescribes — apathsrule, ⛔ not by widening the shrink-only registry. Verified independently:packages/typesexists in-repo (workspace:*→link:../../types), so it is ⛔ not a third-party runtime dependency and does not touch the manual floor;plugin-securitycarries the identical dep.The remedy stayed conditional, as ruled. The ADR-0057 §3.6 /
OS_TELEMETRY_DBguidance was kept and gated on the missing-table cause viaisMissingTableError, asked about bothsys_audit_logandsys_activity. ⛔ It was not deleted — which is what ruling ① asked for.Ablation proved by state. Keying reverted to the process-wide boolean on the committed tree; mutation proven on disk before reading any result (
reportedAuditFailureCauses3→0, blob379e7067…→ebca6c71…); 2 failed / 73 passed, naming the two pins that carry the change. Restored by state —git diff HEADempty,git status --porcelainempty, hash-object back to379e7067…— ⛔ never by exit code.Gates: 74 derived, 71 green, 3 NOT-MEASURED (
dual-build-cjs-loads,i18n,type-check-debt— all exit 3, PREREQUISITE NOT MET, one cause: no full workspace dist closure locally). ⭐ Recorded as NOT MEASURED, ⛔ never as a pass — which is the discipline that caught a real red on a sibling PR today.Follow-ups already filed, ⛔ not left implicit
audit-service.mdxdocuments the audit-write failure report asonce per process, which PR #17450 makes false — it becomes once per CAUSE #17451 (devx) —audit-service.mdx:76says the failure is reported 「once per process — every later failure drops todebug」, which this PR makes false. Filed withBlocked-by:this PR, because the sentence is TRUE until it merges. It also records what is ⛔ not to be touched:record-view-auditing.mdx:217says the same words about the read-audit buffer (read-audit.ts), which this PR does not touch.auth-event-audit.tscarries a SECOND copy of both #15166 defects — its own process-level dedupe boolean and its own unconditional datasource hint #17452 (finding) — theauth-event-audit.tssecond copy, whose callee is likewise inDURABILITY_CRITICAL_CALLEES; portable verbatim from this PR's cause-key shape.⇒ ACCEPT. Moving to ready and the merge queue; ⛔ this seat does not merge.
Generated by Claude Code
fix(plugin-audit): key the lost auth-event row report per CAUSE, and …
fix(plugin-audit): read-audit reports once per CAUSE, not once per pr…
fix(plugin-audit): key the lost auth-event row report per CAUSE, and …
fix(plugin-audit): read-audit reports once per CAUSE, not once per pr…
docs(kernel): correct the audit-write failure report granularity to o…
fix(plugin-audit): the Audit write FAILED line names the refused tabl…